authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 15:26:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 15:26:18-07:00
loge5dca2332fed450c6f1b3c7472666fbe80531c5a
tree06d561046512d0e98a2e6474bd06990ead410c18
parent507361bc7301982279b9f115bbae8f22268ce9d6
signature Signed by PGP key B5690EEEBB952194

refactor(usage): transcript readers come from their drivers (#17576)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

34 files changed, 1731 insertions(+), 1411 deletions(-)

apps/server/src/provider/Drivers/ClaudeDriver.ts+3-1
...@@ -34,6 +34,7 @@ import {...@@ -34,6 +34,7 @@ import {
34 type ClaudeAdapterV2DriverEnv,34 type ClaudeAdapterV2DriverEnv,
35} from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts";35} from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
36import { ProviderDriverError } from "../Errors.ts";36import { ProviderDriverError } from "../Errors.ts";
37import { claudeUsageReader } from "./claudeUsage.ts";
37import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts";38import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts";
38import * as ClaudeResetCredits from "../claudeResetCredits.ts";39import * as ClaudeResetCredits from "../claudeResetCredits.ts";
39import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts";40import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts";
...@@ -107,7 +108,7 @@ export type ClaudeDriverEnv =...@@ -107,7 +108,7 @@ export type ClaudeDriverEnv =
107 | ProviderEventLoggers.ProviderEventLoggers108 | ProviderEventLoggers.ProviderEventLoggers
108 | ServerConfig.ServerConfig;109 | ServerConfig.ServerConfig;
109110
110export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {111export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.Path> = {
111 driverKind: DRIVER_KIND,112 driverKind: DRIVER_KIND,
112 metadata: {113 metadata: {
113 displayName: "Claude",114 displayName: "Claude",
...@@ -115,6 +116,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -115,6 +116,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
115 },116 },
116 configSchema: ClaudeSettings,117 configSchema: ClaudeSettings,
117 defaultConfig: (): ClaudeSettings => decodeClaudeSettings({}),118 defaultConfig: (): ClaudeSettings => decodeClaudeSettings({}),
119 usage: claudeUsageReader,
118 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>120 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
119 Effect.gen(function* () {121 Effect.gen(function* () {
120 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;122 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
apps/server/src/provider/Drivers/CodexDriver.ts+3-1
...@@ -73,6 +73,7 @@ import {...@@ -73,6 +73,7 @@ import {
73 resolveCodexHomeLayout,73 resolveCodexHomeLayout,
74} from "./CodexHomeLayout.ts";74} from "./CodexHomeLayout.ts";
75import { makeManagedCodexProvider } from "./CodexManagedProvider.ts";75import { makeManagedCodexProvider } from "./CodexManagedProvider.ts";
76import { codexUsageReader } from "./codexUsage.ts";
76import * as CodexInstallation from "../CodexInstallation.ts";77import * as CodexInstallation from "../CodexInstallation.ts";
77import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";78import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
78import * as ServerEnvironment from "../../environment/ServerEnvironment.ts";79import * as ServerEnvironment from "../../environment/ServerEnvironment.ts";
...@@ -125,7 +126,7 @@ export type CodexDriverEnv =...@@ -125,7 +126,7 @@ export type CodexDriverEnv =
125 | ServerEnvironment.ServerEnvironmentIdentity126 | ServerEnvironment.ServerEnvironmentIdentity
126 | CodexInstallation.CodexInstallation;127 | CodexInstallation.CodexInstallation;
127128
128export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {129export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Path> = {
129 driverKind: DRIVER_KIND,130 driverKind: DRIVER_KIND,
130 metadata: {131 metadata: {
131 displayName: "Codex",132 displayName: "Codex",
...@@ -133,6 +134,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -133,6 +134,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
133 },134 },
134 configSchema: CodexSettings,135 configSchema: CodexSettings,
135 defaultConfig: (): CodexSettings => decodeCodexSettings({}),136 defaultConfig: (): CodexSettings => decodeCodexSettings({}),
137 usage: codexUsageReader,
136 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>138 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
137 Effect.gen(function* () {139 Effect.gen(function* () {
138 if (config.setupMode === "managed")140 if (config.setupMode === "managed")
apps/server/src/provider/Drivers/claudeUsage.test.ts created+74
...@@ -0,0 +1,74 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { parseClaudeLine } from "./claudeUsage.ts";
4
5/** Shaped after a real Claude Code assistant record. */
6function claudeLine(overrides: {
7 messageId: string;
8 contentType: string;
9 model?: string;
10 outputTokens?: number;
11 speed?: string;
12}): string {
13 return JSON.stringify({
14 type: "assistant",
15 timestamp: "2026-08-07T04:05:13.944Z",
16 sessionId: "5a128faa-8253-489e-b935-6c08e8e670c0",
17 cwd: "/home/theo/project",
18 message: {
19 id: overrides.messageId,
20 role: "assistant",
21 model: overrides.model ?? "claude-fable-5",
22 content: [{ type: overrides.contentType }],
23 usage: {
24 input_tokens: 2,
25 cache_creation_input_tokens: 66818,
26 cache_read_input_tokens: 1000,
27 output_tokens: overrides.outputTokens ?? 286,
28 ...(overrides.speed === undefined ? {} : { speed: overrides.speed }),
29 },
30 },
31 });
32}
33
34describe("parseClaudeLine", () => {
35 it("extracts token totals and a dedupe key", () => {
36 const record = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" }));
37
38 expect(record).not.toBeNull();
39 expect(record?.provider).toBe("claude");
40 expect(record?.model).toBe("claude-fable-5");
41 expect(record?.totals).toEqual({
42 uncachedInputTokens: 2,
43 cachedInputTokens: 1000,
44 cacheCreationTokens: 66818,
45 outputTokens: 286,
46 reasoningTokens: 0,
47 });
48 expect(record?.dedupeKey).toBe("msg_1:");
49 expect(record?.speed).toBe("standard");
50 });
51
52 it("marks fast-mode requests", () => {
53 const line = (speed: string) =>
54 parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed }));
55
56 expect(line("fast")?.speed).toBe("fast");
57 expect(line("standard")?.speed).toBe("standard");
58 });
59
60 it("gives every content block of one message the same dedupe key", () => {
61 // T3 Code writes one record per content block, each repeating the parent
62 // message's full usage. Summing them would overcount ~2.4x on real data.
63 const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" }));
64 const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" }));
65
66 expect(text?.dedupeKey).toBe(toolUse?.dedupeKey);
67 expect(text?.totals).toEqual(toolUse?.totals);
68 });
69
70 it("ignores records that are not assistant messages", () => {
71 expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull();
72 expect(parseClaudeLine("not json")).toBeNull();
73 });
74});
apps/server/src/provider/Drivers/claudeUsage.ts created+120
...@@ -0,0 +1,120 @@
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 */
7import * as NodeOS from "node:os";
8
9import type { ClaudeSettings } from "@t3tools/contracts";
10import * as Effect from "effect/Effect";
11import * as Path from "effect/Path";
12
13import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
14import {
15 parseTimestampMs,
16 tokenCount,
17 type ProviderUsageReader,
18 type TranscriptUsageFormat,
19 type UsageRecord,
20} from "@t3tools/provider-core/server/usage";
21
22/**
23 * Parses one line of a Claude Code transcript.
24 *
25 * T3 Code writes one record per assistant *content block*, and every one of
26 * those records repeats the same complete `usage` object for the parent
27 * message. Summing them overcounts by roughly 2.4x on a real workload, so the
28 * caller must drop repeats by `dedupeKey` and keep the first.
29 */
30export function parseClaudeLine(line: string): UsageRecord | null {
31 let parsed: unknown;
32 try {
33 parsed = JSON.parse(line);
34 } catch {
35 return null;
36 }
37 return parseClaudeRecord(parsed);
38}
39
40function parseClaudeRecord(parsed: unknown): UsageRecord | null {
41 if (typeof parsed !== "object" || parsed === null) return null;
42
43 const record = parsed as Record<string, unknown>;
44 if (record["type"] !== "assistant") return null;
45
46 const message = record["message"];
47 if (typeof message !== "object" || message === null) return null;
48 const messageRecord = message as Record<string, unknown>;
49
50 const usage = messageRecord["usage"];
51 if (typeof usage !== "object" || usage === null) return null;
52 const usageRecord = usage as Record<string, unknown>;
53
54 const timestampMs = parseTimestampMs(record["timestamp"]);
55 if (timestampMs === null) return null;
56
57 const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : "";
58 if (model.length === 0) return null;
59
60 const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null;
61 const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null;
62 // Matches ccusage: prefer the message/request pair, fall back to whichever
63 // half exists. Records with neither cannot be de-duplicated.
64 const dedupeKey =
65 messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`;
66
67 const cost = record["costUSD"];
68
69 return {
70 provider: "claude",
71 timestampMs,
72 model,
73 sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "",
74 totals: {
75 uncachedInputTokens: tokenCount(usageRecord["input_tokens"]),
76 cachedInputTokens: tokenCount(usageRecord["cache_read_input_tokens"]),
77 cacheCreationTokens: tokenCount(usageRecord["cache_creation_input_tokens"]),
78 outputTokens: tokenCount(usageRecord["output_tokens"]),
79 // Anthropic folds thinking tokens into output and does not break them out.
80 reasoningTokens: 0,
81 },
82 reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
83 speed: usageRecord["speed"] === "fast" ? "fast" : "standard",
84 dedupeKey,
85 };
86}
87
88const orEmpty = (record: UsageRecord | null): readonly UsageRecord[] =>
89 record === null ? [] : [record];
90
91export const claudeUsageFormat: TranscriptUsageFormat<void> = {
92 // Keeps the dedupe and cost metadata. The `usage` subtree keeps future token fields.
93 selectFields: {
94 type: true,
95 timestamp: true,
96 requestId: true,
97 sessionId: true,
98 costUSD: true,
99 message: { id: true, model: true, usage: true },
100 },
101 mightCarryUsage: (line) => line.includes('"usage"'),
102 parseLine: (line) => orEmpty(parseClaudeLine(line)),
103 parseProjected: (projected) => orEmpty(parseClaudeRecord(projected)),
104};
105
106export const claudeUsageReader: ProviderUsageReader<ClaudeSettings, Path.Path> = {
107 kind: "transcripts",
108 provider: "claude",
109 format: claudeUsageFormat,
110 directories: Effect.fn("claudeUsageReader.directories")(function* ({ config, environment }) {
111 // An undecodable config has no trustworthy home to read.
112 if (config === undefined) return [];
113 const path = yield* Path.Path;
114 const configured = config.homePath.trim();
115 const home = configured
116 ? expandHomePath(configured)
117 : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(NodeOS.homedir(), ".claude");
118 return [{ dir: path.resolve(home, "projects") }];
119 }),
120};
apps/server/src/provider/Drivers/codexUsage.test.ts created+193
...@@ -0,0 +1,193 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { initialCodexScanState, parseCodexLine } from "./codexUsage.ts";
4
5describe("parseCodexLine", () => {
6 const sessionMeta = JSON.stringify({
7 type: "session_meta",
8 timestamp: "2026-08-01T05:17:41.289Z",
9 payload: { type: "session_meta", id: "019fbbc1-b12c-7360-a685-28c181f0025f" },
10 });
11 const turnContext = JSON.stringify({
12 type: "turn_context",
13 timestamp: "2026-08-01T05:17:42.694Z",
14 payload: { type: "turn_context", model: "gpt-5.6-sol" },
15 });
16 const tokenCount = (inputTokens: number, cached: number, output: number, reasoning: number) =>
17 JSON.stringify({
18 type: "event_msg",
19 timestamp: "2026-08-01T05:17:49.919Z",
20 payload: {
21 type: "token_count",
22 info: {
23 last_token_usage: {
24 input_tokens: inputTokens,
25 cached_input_tokens: cached,
26 cache_write_input_tokens: 0,
27 output_tokens: output,
28 reasoning_output_tokens: reasoning,
29 },
30 },
31 },
32 });
33
34 it("attributes usage to the model from the preceding turn context", () => {
35 const state = initialCodexScanState();
36 parseCodexLine(sessionMeta, state);
37 parseCodexLine(turnContext, state);
38 const record = parseCodexLine(tokenCount(19239, 11008, 299, 116), state);
39
40 expect(record?.provider).toBe("codex");
41 expect(record?.model).toBe("gpt-5.6-sol");
42 expect(record?.sessionId).toBe("019fbbc1-b12c-7360-a685-28c181f0025f");
43 // Codex reports input_tokens inclusive of the cached portion.
44 expect(record?.totals.uncachedInputTokens).toBe(19239 - 11008);
45 expect(record?.totals.cachedInputTokens).toBe(11008);
46 expect(record?.totals.reasoningTokens).toBe(116);
47 });
48
49 it("skips a repeated token_count so deltas are not double counted", () => {
50 const state = initialCodexScanState();
51 parseCodexLine(turnContext, state);
52 const first = parseCodexLine(tokenCount(100, 0, 10, 0), state);
53 const repeat = parseCodexLine(tokenCount(100, 0, 10, 0), state);
54
55 expect(first).not.toBeNull();
56 expect(repeat).toBeNull();
57 });
58
59 it("drops usage that arrives before any model is known", () => {
60 const state = initialCodexScanState();
61 expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull();
62 });
63
64 it("does not let a pre-model event poison the duplicate signature", () => {
65 // A token_count before its turn_context is dropped; the identical event
66 // re-emitted once the model is known must still be counted.
67 const state = initialCodexScanState();
68 expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull();
69 parseCodexLine(turnContext, state);
70 expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull();
71 });
72
73 it("carries the service tier from the latest thread settings", () => {
74 const settings = (thread_settings: Record<string, unknown>) =>
75 JSON.stringify({
76 type: "event_msg",
77 timestamp: "2026-08-01T05:17:42.000Z",
78 payload: { type: "thread_settings_applied", thread_settings },
79 });
80 const state = initialCodexScanState();
81 parseCodexLine(turnContext, state);
82 const speedAfter = (line: string, output: number) => {
83 parseCodexLine(line, state);
84 return parseCodexLine(tokenCount(100, 0, output, 0), state)?.speed;
85 };
86
87 expect(parseCodexLine(tokenCount(100, 0, 1, 0), state)?.speed).toBe("standard");
88 expect(speedAfter(settings({ service_tier: "ultrafast" }), 2)).toBe("ultrafast");
89 expect(speedAfter(settings({ service_tier: "priority" }), 3)).toBe("fast");
90 // Codex omits the field when no tier was requested.
91 expect(speedAfter(settings({ model: "gpt-6-astra" }), 4)).toBe("standard");
92 });
93
94 // A forked/subagent rollout opens with the parent's history copied in and
95 // every line re-stamped to the fork instant, then the ancestors' session
96 // metas. Counting those again multiplied usage ~1.85x on real data (#5758).
97 describe("forked rollouts", () => {
98 const meta = (overrides: {
99 id: string;
100 timestamp: string;
101 forkedFromId?: string;
102 spawnParentId?: string;
103 }) =>
104 JSON.stringify({
105 type: "session_meta",
106 timestamp: overrides.timestamp,
107 payload: {
108 type: "session_meta",
109 id: overrides.id,
110 ...(overrides.forkedFromId === undefined
111 ? {}
112 : { forked_from_id: overrides.forkedFromId }),
113 ...(overrides.spawnParentId === undefined
114 ? {}
115 : {
116 source: {
117 subagent: { thread_spawn: { parent_thread_id: overrides.spawnParentId } },
118 },
119 }),
120 },
121 });
122 const stamped = (timestamp: string, line: string) => {
123 const parsed = JSON.parse(line) as { timestamp: string };
124 parsed.timestamp = timestamp;
125 return JSON.stringify(parsed);
126 };
127
128 it("keeps the child session id over copied ancestor metas", () => {
129 const state = initialCodexScanState();
130 parseCodexLine(meta({ id: "child", timestamp: "2026-08-01T05:00:00.000Z" }), state);
131 parseCodexLine(meta({ id: "parent", timestamp: "2026-08-01T05:00:00.000Z" }), state);
132 parseCodexLine(turnContext, state);
133 const record = parseCodexLine(tokenCount(100, 0, 10, 0), state);
134
135 expect(record?.sessionId).toBe("child");
136 });
137
138 it("drops the re-stamped copied burst and keeps the first real event", () => {
139 const state = initialCodexScanState();
140 const forkInstant = "2026-08-01T05:00:00.000Z";
141 parseCodexLine(meta({ id: "child", timestamp: forkInstant, forkedFromId: "parent" }), state);
142 parseCodexLine(meta({ id: "parent", timestamp: forkInstant }), state);
143 parseCodexLine(stamped(forkInstant, turnContext), state);
144
145 // Copied history: written in one burst at the fork instant.
146 expect(
147 parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state),
148 ).toBeNull();
149 expect(
150 parseCodexLine(stamped("2026-08-01T05:00:00.002Z", tokenCount(200, 0, 20, 0)), state),
151 ).toBeNull();
152
153 // The child's first genuine turn lands seconds later and must count.
154 const real = parseCodexLine(
155 stamped("2026-08-01T05:00:06.000Z", tokenCount(300, 0, 30, 0)),
156 state,
157 );
158 expect(real).not.toBeNull();
159 expect(real?.totals.outputTokens).toBe(30);
160
161 // Suppression never restarts, even for closely spaced later events.
162 const next = parseCodexLine(
163 stamped("2026-08-01T05:00:06.100Z", tokenCount(400, 0, 40, 0)),
164 state,
165 );
166 expect(next).not.toBeNull();
167 });
168
169 it("recognizes subagent spawns without forked_from_id", () => {
170 const state = initialCodexScanState();
171 const spawnInstant = "2026-08-01T05:00:00.000Z";
172 parseCodexLine(
173 meta({ id: "child", timestamp: spawnInstant, spawnParentId: "parent" }),
174 state,
175 );
176 parseCodexLine(stamped(spawnInstant, turnContext), state);
177 expect(
178 parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state),
179 ).toBeNull();
180 });
181
182 it("does not suppress anything in a rollout that is not a fork", () => {
183 const state = initialCodexScanState();
184 parseCodexLine(meta({ id: "root", timestamp: "2026-08-01T05:00:00.000Z" }), state);
185 parseCodexLine(stamped("2026-08-01T05:00:00.100Z", turnContext), state);
186 const record = parseCodexLine(
187 stamped("2026-08-01T05:00:00.200Z", tokenCount(100, 0, 10, 0)),
188 state,
189 );
190 expect(record).not.toBeNull();
191 });
192 });
193});
apps/server/src/provider/Drivers/codexUsage.ts created+265
...@@ -0,0 +1,265 @@
1/**
2 * Usage history for Codex: the rollout format of `<home>/sessions/**.jsonl`
3 * and where each instance's shared home keeps them.
4 *
5 * @module provider/Drivers/codexUsage
6 */
7import type { CodexSettings } from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11
12import {
13 parseTimestampMs,
14 tokenCount,
15 totalTokens,
16 type ProviderUsageReader,
17 type TranscriptUsageFormat,
18 type UsageRecord,
19 type UsageSpeed,
20} from "@t3tools/provider-core/server/usage";
21import type { UsageTokenTotals } from "@t3tools/contracts";
22
23import { resolveCodexHomeLayout } from "./CodexHomeLayout.ts";
24
25/**
26 * Rolling state for a single Codex rollout file.
27 *
28 * Codex `token_count` events carry no model or service tier, so both are
29 * carried forward: the model from the most recent `turn_context`, the tier from
30 * the most recent `thread_settings_applied`. Sessions that switch either
31 * mid-run attribute correctly from the switch onward.
32 *
33 * Persisted in the scan cache at each resume point. The field order is the
34 * stored JSON's, so keep it stable.
35 */
36export const CodexScanState = Schema.Struct({
37 model: Schema.mutableKey(Schema.String),
38 speed: Schema.mutableKey(Schema.Literals(["standard", "fast", "ultrafast"])),
39 sessionId: Schema.mutableKey(Schema.String),
40 lastUsageSignature: Schema.mutableKey(Schema.NullOr(Schema.String)),
41 sawSessionMeta: Schema.mutableKey(Schema.Boolean),
42 /** While true, leading usage events are re-stamped copies of parent history. */
43 suppressingForkCopies: Schema.mutableKey(Schema.Boolean),
44 forkCopyAnchorMs: Schema.mutableKey(Schema.Finite),
45});
46export type CodexScanState = typeof CodexScanState.Type;
47
48export function initialCodexScanState(): CodexScanState {
49 return {
50 model: "",
51 speed: "standard",
52 sessionId: "",
53 lastUsageSignature: null,
54 sawSessionMeta: false,
55 suppressingForkCopies: false,
56 forkCopyAnchorMs: 0,
57 };
58}
59
60/**
61 * A forked or subagent rollout opens with the parent's full history copied in,
62 * every line re-stamped to the fork instant. Those copies are written in one
63 * synchronous burst (observed gaps 0-40ms), while the child's first genuine
64 * usage event only lands after a real model turn (observed 5s+). One second of
65 * separation splits the two cleanly; `ccusage` uses the same threshold.
66 */
67const FORK_COPY_MAX_GAP_MS = 1000;
68
69/** Whether a `session_meta` payload marks the rollout as a fork or subagent. */
70function isForkedSessionMeta(payload: Record<string, unknown>): boolean {
71 if (typeof payload["forked_from_id"] === "string") return true;
72 const source = payload["source"];
73 if (typeof source !== "object" || source === null) return false;
74 const subagent = (source as Record<string, unknown>)["subagent"];
75 if (typeof subagent !== "object" || subagent === null) return false;
76 const spawn = (subagent as Record<string, unknown>)["thread_spawn"];
77 if (typeof spawn !== "object" || spawn === null) return false;
78 return typeof (spawn as Record<string, unknown>)["parent_thread_id"] === "string";
79}
80
81/**
82 * Feeds one line of a Codex rollout into `state`, returning a record when the
83 * line was a usage event.
84 *
85 * Deltas come from `last_token_usage`. Summing those across a session
86 * reconciles with the session's final `total_token_usage`, provided
87 * consecutive duplicate events are dropped, which this does.
88 */
89export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null {
90 let parsed: unknown;
91 try {
92 parsed = JSON.parse(line);
93 } catch {
94 return null;
95 }
96 return parseCodexRecord(parsed, state);
97}
98
99function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | null {
100 if (typeof parsed !== "object" || parsed === null) return null;
101
102 const record = parsed as Record<string, unknown>;
103 const payload = record["payload"];
104 if (typeof payload !== "object" || payload === null) return null;
105 const payloadRecord = payload as Record<string, unknown>;
106 const payloadType = payloadRecord["type"];
107
108 if (record["type"] === "session_meta") {
109 // Only the first meta describes this file's own session. A forked rollout
110 // repeats the ancestors' metas right after it; letting those through would
111 // reassign every subsequent record to an ancestor session.
112 if (state.sawSessionMeta) return null;
113 state.sawSessionMeta = true;
114 const id = payloadRecord["id"] ?? payloadRecord["session_id"];
115 if (typeof id === "string") state.sessionId = id;
116 const metaTimestampMs = parseTimestampMs(record["timestamp"]);
117 if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) {
118 state.suppressingForkCopies = true;
119 state.forkCopyAnchorMs = metaTimestampMs;
120 }
121 return null;
122 }
123
124 if (record["type"] === "turn_context") {
125 if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"];
126 return null;
127 }
128
129 if (payloadType === "thread_settings_applied") {
130 const settings = payloadRecord["thread_settings"];
131 if (typeof settings === "object" && settings !== null) {
132 state.speed = codexSpeed((settings as Record<string, unknown>)["service_tier"]);
133 }
134 return null;
135 }
136
137 if (payloadType !== "token_count") return null;
138
139 const info = payloadRecord["info"];
140 if (typeof info !== "object" || info === null) return null;
141 const last = (info as Record<string, unknown>)["last_token_usage"];
142 if (typeof last !== "object" || last === null) return null;
143 const lastRecord = last as Record<string, unknown>;
144
145 // Only an event that is otherwise eligible may consume the duplicate
146 // signature. A token_count arriving before its turn_context (no model yet)
147 // must not poison it, or the re-emitted copy after the model is known would
148 // be skipped as a duplicate and those tokens never counted.
149 const timestampMs = parseTimestampMs(record["timestamp"]);
150 if (timestampMs === null) return null;
151 if (state.model.length === 0) return null;
152
153 // Codex re-emits an unchanged token_count on some stream boundaries. Summing
154 // those would double count, so identical consecutive payloads are skipped.
155 const signature = JSON.stringify(lastRecord);
156 if (signature === state.lastUsageSignature) return null;
157 state.lastUsageSignature = signature;
158
159 // In a forked rollout the copied parent history was already counted from the
160 // parent's own file. Drop the leading burst; the first usage event separated
161 // from its predecessor by a real turn's worth of time ends it for good.
162 if (state.suppressingForkCopies) {
163 if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) {
164 state.forkCopyAnchorMs = timestampMs;
165 return null;
166 }
167 state.suppressingForkCopies = false;
168 }
169
170 const inputTokens = tokenCount(lastRecord["input_tokens"]);
171 const cachedInputTokens = tokenCount(lastRecord["cached_input_tokens"]);
172 const cacheCreationTokens = tokenCount(lastRecord["cache_write_input_tokens"]);
173 const outputTokens = tokenCount(lastRecord["output_tokens"]);
174
175 const totals: UsageTokenTotals = {
176 // Codex reports `input_tokens` inclusive of the cached portion.
177 uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens),
178 cachedInputTokens,
179 cacheCreationTokens,
180 outputTokens,
181 // Reported inside output_tokens, surfaced separately for the token mix.
182 reasoningTokens: Math.min(outputTokens, tokenCount(lastRecord["reasoning_output_tokens"])),
183 };
184
185 if (totalTokens(totals) === 0) return null;
186
187 return {
188 provider: "codex",
189 timestampMs,
190 model: state.model,
191 sessionId: state.sessionId,
192 totals,
193 // Codex does not report cost in the rollout.
194 reportedCostUsd: null,
195 speed: state.speed,
196 // Events surviving the fork-copy suppression above are unique to this
197 // rollout, so they need no global dedup.
198 dedupeKey: null,
199 };
200}
201
202/**
203 * Maps a Codex `service_tier` to its billing speed. Codex omits the field when
204 * no tier was requested, which bills as standard, as do `default` and
205 * `standard`. `fast` is accepted as an alias of `priority`.
206 */
207function codexSpeed(serviceTier: unknown): UsageSpeed {
208 if (serviceTier === "priority" || serviceTier === "fast") return "fast";
209 if (serviceTier === "ultrafast") return "ultrafast";
210 return "standard";
211}
212
213const orEmpty = (record: UsageRecord | null): readonly UsageRecord[] =>
214 record === null ? [] : [record];
215
216export const codexUsageFormat: TranscriptUsageFormat<CodexScanState> = {
217 // Keeps the fields the reducer reads as well as the usage itself.
218 selectFields: {
219 type: true,
220 timestamp: true,
221 payload: {
222 type: true,
223 id: true,
224 session_id: true,
225 model: true,
226 thread_settings: { service_tier: true },
227 forked_from_id: true,
228 source: { subagent: { thread_spawn: { parent_thread_id: true } } },
229 info: { last_token_usage: true },
230 },
231 },
232 // `turn_context`, `thread_settings_applied` and `session_meta` lines hold no
233 // usage, but they carry the model, tier and session the reducer needs.
234 mightCarryUsage: (line) =>
235 line.includes('"token_count"') ||
236 line.includes('"turn_context"') ||
237 line.includes('"thread_settings_applied"') ||
238 line.includes('"session_meta"'),
239 parseLine: (line, state) => orEmpty(parseCodexLine(line, state)),
240 parseProjected: (projected, state) => orEmpty(parseCodexRecord(projected, state)),
241 state: { initial: initialCodexScanState, schema: CodexScanState },
242 // A rollout that moved after it was read leaves a copy in both places.
243 sharedSessionsAcrossFiles: true,
244};
245
246export const codexUsageReader: ProviderUsageReader<CodexSettings, Path.Path> = {
247 kind: "transcripts",
248 provider: "codex",
249 format: codexUsageFormat,
250 directories: Effect.fn("codexUsageReader.directories")(function* ({ config, environment }) {
251 // An undecodable config has no trustworthy home to read.
252 if (config === undefined) return [];
253 const path = yield* Path.Path;
254 const environmentHome = environment.CODEX_HOME?.trim();
255 const layout = yield* resolveCodexHomeLayout(
256 config.setupMode !== "managed" &&
257 !config.homePath.trim() &&
258 !config.shadowHomePath.trim() &&
259 environmentHome
260 ? { ...config, homePath: environmentHome }
261 : config,
262 );
263 return [{ dir: path.resolve(layout.sharedHomePath, "sessions") }];
264 }),
265};
apps/server/src/provider/builtInDrivers.ts+19-1
...@@ -32,7 +32,10 @@ import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server";...@@ -32,7 +32,10 @@ import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server";
32import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server";32import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server";
33import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server";33import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server";
34import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server";34import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server";
35import type { AnyProviderDriver } from "@t3tools/provider-core/server/driver";35import type {
36 AnyProviderDriver,
37 ProviderUsageReaderEnv,
38} from "@t3tools/provider-core/server/driver";
3639
37/**40/**
38 * Union of infrastructure services required to construct any built-in41 * Union of infrastructure services required to construct any built-in
...@@ -66,3 +69,18 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv...@@ -66,3 +69,18 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv
66 MuseDriver,69 MuseDriver,
67 AcpRegistryDriver,70 AcpRegistryDriver,
68];71];
72
73/** Services the built-in usage readers need. */
74export type BuiltInUsageReadersEnv =
75 | ProviderUsageReaderEnv<typeof ClaudeDriver>
76 | ProviderUsageReaderEnv<typeof CodexDriver>
77 | ProviderUsageReaderEnv<typeof GrokDriver>;
78
79/**
80 * The drivers that keep usage history, in the order the usage page reads
81 * them: transcript readers first, then scan readers. Aggregation keeps the
82 * first copy of a duplicate record, so the order is part of the result.
83 */
84export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray<
85 AnyProviderDriver<BuiltInDriversEnv, BuiltInUsageReadersEnv>
86> = [ClaudeDriver, CodexDriver, GrokDriver];
apps/server/src/usage/UsageService.test.ts+3-1
...@@ -33,7 +33,7 @@ import { HttpClient, HttpClientResponse } from "effect/http";...@@ -33,7 +33,7 @@ import { HttpClient, HttpClientResponse } from "effect/http";
33import * as ServerConfig from "../config.ts";33import * as ServerConfig from "../config.ts";
34import * as ServerSettings from "../serverSettings.ts";34import * as ServerSettings from "../serverSettings.ts";
35import * as CursorUsageReader from "./cursorUsageReader.ts";35import * as CursorUsageReader from "./cursorUsageReader.ts";
36import type { UsageRecord } from "./usageTranscripts.ts";36import type { UsageRecord } from "@t3tools/provider-core/server/usage";
37import * as UsageService from "./UsageService.ts";37import * as UsageService from "./UsageService.ts";
3838
39const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));39const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
...@@ -805,6 +805,8 @@ describe("UsageService", () => {...@@ -805,6 +805,8 @@ describe("UsageService", () => {
805 },805 },
806 [ProviderInstanceId.make("grok-work")]: {806 [ProviderInstanceId.make("grok-work")]: {
807 driver: ProviderDriverKind.make("grok"),807 driver: ProviderDriverKind.make("grok"),
808 // An undecodable config must not hide history Grok reads by home alone.
809 config: { customModels: "not-a-list" },
808 environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }],810 environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }],
809 },811 },
810 },812 },
apps/server/src/usage/UsageService.ts+97-82
...@@ -21,8 +21,6 @@...@@ -21,8 +21,6 @@
21import * as NodeOS from "node:os";21import * as NodeOS from "node:os";
2222
23import {23import {
24 ClaudeSettings,
25 CodexSettings,
26 type ProviderInstanceConfig,24 type ProviderInstanceConfig,
27 ProviderInstanceId,25 ProviderInstanceId,
28 USAGE_CONTRACT_VERSION,26 USAGE_CONTRACT_VERSION,
...@@ -56,9 +54,15 @@ import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";...@@ -56,9 +54,15 @@ import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
56import * as ServerConfig from "../config.ts";54import * as ServerConfig from "../config.ts";
57import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";55import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
58import * as ServerSettings from "../serverSettings.ts";56import * as ServerSettings from "../serverSettings.ts";
59import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
60import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAuthSupport.ts";57import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAuthSupport.ts";
58import { BUILT_IN_USAGE_DRIVERS, type BuiltInUsageReadersEnv } from "../provider/builtInDrivers.ts";
59import type { ProviderDriver } from "@t3tools/provider-core/server/driver";
61import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";60import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
61import type {
62 ProviderUsageInstance,
63 TranscriptUsageFormat,
64 UsageRecord,
65} from "@t3tools/provider-core/server/usage";
62import { readOpenCodeUsage } from "./opencodeUsageReader.ts";66import { readOpenCodeUsage } from "./opencodeUsageReader.ts";
63import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts";67import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts";
64import {68import {
...@@ -90,7 +94,6 @@ import {...@@ -90,7 +94,6 @@ import {
90 type CachedFile,94 type CachedFile,
91 type ScanCache,95 type ScanCache,
92} from "./usageScanCache.ts";96} from "./usageScanCache.ts";
93import type { UsageRecord } from "./usageTranscripts.ts";
9497
95const LITELLM_RATES_URL =98const LITELLM_RATES_URL =
96 "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";99 "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
...@@ -119,8 +122,24 @@ const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read.";...@@ -119,8 +122,24 @@ const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read.";
119/** Transcripts parsed at once. More gains little once the disk stays busy. */122/** Transcripts parsed at once. More gains little once the disk stays busy. */
120const TRANSCRIPT_READ_CONCURRENCY = 4;123const TRANSCRIPT_READ_CONCURRENCY = 4;
121124
122const decodeCodexSettings = Schema.decodeOption(CodexSettings);125/** The transcript readers, in driver order. */
123const decodeClaudeSettings = Schema.decodeOption(ClaudeSettings);126const transcriptReaders = BUILT_IN_USAGE_DRIVERS.flatMap((driver) =>
127 driver.usage?.kind === "transcripts" ? [{ driver, reader: driver.usage }] : [],
128);
129
130/** Transcript formats by provider, for decoding the persisted scan cache. */
131const transcriptFormats = new Map(
132 transcriptReaders.map(({ reader }) => [reader.provider, reader.format] as const),
133);
134
135/** One transcript directory to scan. */
136interface TranscriptSource {
137 readonly provider: UsageProviderKind;
138 readonly format: TranscriptUsageFormat<unknown>;
139 readonly dir: string;
140 readonly volumeId: string;
141 readonly fileName?: string;
142}
124143
125/** On-disk shape of the rate snapshot. */144/** On-disk shape of the rate snapshot. */
126const RatesCacheFile = Schema.Struct({145const RatesCacheFile = Schema.Struct({
...@@ -145,13 +164,21 @@ function isLaterRead(a: CachedFile, b: CachedFile): boolean {...@@ -145,13 +164,21 @@ function isLaterRead(a: CachedFile, b: CachedFile): boolean {
145 return a.mtimeMs > b.mtimeMs || (a.mtimeMs === b.mtimeMs && a.size > b.size);164 return a.mtimeMs > b.mtimeMs || (a.mtimeMs === b.mtimeMs && a.size > b.size);
146}165}
147166
167/** Providers whose format lets one session's records appear in several files. */
168const sharedSessionProviders: ReadonlySet<UsageProviderKind> = new Set(
169 transcriptReaders.flatMap(({ reader }) =>
170 reader.format.sharedSessionsAcrossFiles ? [reader.provider] : [],
171 ),
172);
173
148/**174/**
149 * Codex sessions with records in more than one file, such as a rollout that175 * Sessions of `sharedSessionProviders` with records in more than one file,
150 * moved after it was read. Only these need cross-file dedupe keys: within one176 * such as a rollout that moved after it was read. Only these need cross-file
151 * file the occurrence count already keeps every key unique, so keying the rest177 * dedupe keys: within one file the occurrence count already keeps every key
152 * would only build and hash a string for each of their records.178 * unique, so keying the rest would only build and hash a string for each of
179 * their records.
153 */180 */
154function sharedCodexSessions(181function sharedSessions(
155 files: readonly { readonly records: readonly UsageRecord[] }[],182 files: readonly { readonly records: readonly UsageRecord[] }[],
156): ReadonlySet<string> {183): ReadonlySet<string> {
157 const firstFile = new Map<string, number>();184 const firstFile = new Map<string, number>();
...@@ -159,7 +186,8 @@ function sharedCodexSessions(...@@ -159,7 +186,8 @@ function sharedCodexSessions(
159 for (const [index, file] of files.entries()) {186 for (const [index, file] of files.entries()) {
160 let previous = "";187 let previous = "";
161 for (const { provider, sessionId } of file.records) {188 for (const { provider, sessionId } of file.records) {
162 if (provider !== "codex" || sessionId === previous || sessionId.length === 0) continue;189 if (!sharedSessionProviders.has(provider) || sessionId === previous || sessionId.length === 0)
190 continue;
163 previous = sessionId;191 previous = sessionId;
164 const first = firstFile.get(sessionId);192 const first = firstFile.get(sessionId);
165 if (first === undefined) firstFile.set(sessionId, index);193 if (first === undefined) firstFile.set(sessionId, index);
...@@ -218,6 +246,8 @@ export const make = Effect.gen(function* () {...@@ -218,6 +246,8 @@ export const make = Effect.gen(function* () {
218 const hostEnvironment = yield* HostProcessEnvironment;246 const hostEnvironment = yield* HostProcessEnvironment;
219 const platform = yield* HostProcessPlatform;247 const platform = yield* HostProcessPlatform;
220 const cursorAccountReader = yield* CursorUsageReader.CursorAccountReader;248 const cursorAccountReader = yield* CursorUsageReader.CursorAccountReader;
249 // The readers yield their own services; scans run them against this context.
250 const readerContext = yield* Effect.context<BuiltInUsageReadersEnv>();
221251
222 const fileCache: ScanCache = new Map();252 const fileCache: ScanCache = new Map();
223 const antigravityCache = makeAntigravityUsageCache();253 const antigravityCache = makeAntigravityUsageCache();
...@@ -338,60 +368,45 @@ export const make = Effect.gen(function* () {...@@ -338,60 +368,45 @@ export const make = Effect.gen(function* () {
338 ),368 ),
339 );369 );
340370
341 /** Resolves the transcript directory for each provider. */371 /**
372 * Every instance of `driver` with its decoded config, or none when it does
373 * not decode. Disabled accounts still have history. An unconfigured default
374 * slot runs with default config, just as it does in the provider registry.
375 */
376 const usageInstances = <Config>(
377 driver: ProviderDriver<Config, unknown, unknown>,
378 settings: ServerSettingsValue,
379 ): Array<ProviderUsageInstance<Config>> => {
380 const entries: Array<
381 readonly [ProviderInstanceId, Pick<ProviderInstanceConfig, "config" | "environment">, boolean]
382 > = Object.entries(settings.providerInstances)
383 .filter(([, instance]) => instance.driver === driver.driverKind)
384 .map(([id, instance]) => [ProviderInstanceId.make(id), instance, true] as const);
385 if (!Object.hasOwn(settings.providerInstances, driver.driverKind)) {
386 entries.push([ProviderInstanceId.make(driver.driverKind), {}, false]);
387 }
388 const decodeConfig = Schema.decodeUnknownOption(driver.configSchema);
389 return entries.map(([instanceId, instance, configured]) => ({
390 instanceId,
391 config: Option.getOrUndefined(decodeConfig(instance.config ?? {})),
392 environment: mergeProviderInstanceEnvironment(instance.environment, hostEnvironment),
393 configured,
394 }));
395 };
396
397 /** Resolves every transcript directory the usage readers point at. */
342 const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* (398 const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* (
343 settings: ServerSettingsValue,399 settings: ServerSettingsValue,
344 retentionCutoffMs: number,400 retentionCutoffMs: number,
345 ) {401 ) {
346 const dirs: Array<{402 const dirs: Array<TranscriptSource> = [];
347 provider: UsageProviderKind;
348 dir: string;
349 volumeId: string;
350 fileName?: string;
351 }> = [];
352 const seen = new Set<string>();403 const seen = new Set<string>();
353 for (const driver of ["claudeAgent", "codex", "grok"] as const) {404 for (const { driver, reader } of transcriptReaders) {
354 // Disabled accounts still have history. An unconfigured default slot405 const { provider, format } = reader;
355 // runs with default config, just as it does in the provider registry.406 const directories = yield* Effect.forEach(usageInstances(driver, settings), (instance) =>
356 const instances: Array<407 reader.directories(instance),
357 Pick<ProviderInstanceConfig, "config" | "environment"> & { instanceId: ProviderInstanceId }408 );
358 > = Object.entries(settings.providerInstances)409 for (const { dir: directory, fileName } of directories.flat()) {
359 .filter(([, instance]) => instance.driver === driver)
360 .map(([id, instance]) => ({ ...instance, instanceId: ProviderInstanceId.make(id) }));
361 if (!Object.hasOwn(settings.providerInstances, driver)) {
362 instances.push({ instanceId: ProviderInstanceId.make(driver) });
363 }
364 for (const instance of instances) {
365 const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment);
366 const provider = driver === "claudeAgent" ? "claude" : driver;
367 let home: string;
368 if (driver === "codex") {
369 const decoded = decodeCodexSettings(instance.config ?? {});
370 if (Option.isNone(decoded)) continue;
371 const codexConfig = decoded.value;
372 const environmentHome = environment.CODEX_HOME?.trim();
373 const layout = yield* resolveCodexHomeLayout(
374 codexConfig.setupMode !== "managed" &&
375 !codexConfig.homePath.trim() &&
376 !codexConfig.shadowHomePath.trim() &&
377 environmentHome
378 ? { ...codexConfig, homePath: environmentHome }
379 : codexConfig,
380 );
381 home = layout.sharedHomePath;
382 } else if (driver === "claudeAgent") {
383 const decoded = decodeClaudeSettings(instance.config ?? {});
384 if (Option.isNone(decoded)) continue;
385 const configured = decoded.value.homePath.trim();
386 home = configured
387 ? expandHomePath(configured)
388 : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(NodeOS.homedir(), ".claude");
389 } else {
390 home = expandHomePath(
391 environment.GROK_HOME?.trim() || path.join(NodeOS.homedir(), ".grok"),
392 );
393 }
394 const directory = path.resolve(home, provider === "claude" ? "projects" : "sessions");
395 const sourceKey = provider + "\0" + directory;410 const sourceKey = provider + "\0" + directory;
396 const previous = sourceCache.get(sourceKey);411 const previous = sourceCache.get(sourceKey);
397 // Keep canonical paths and source fingerprints stable after root cleanup,412 // Keep canonical paths and source fingerprints stable after root cleanup,
...@@ -423,9 +438,10 @@ export const make = Effect.gen(function* () {...@@ -423,9 +438,10 @@ export const make = Effect.gen(function* () {
423 seen.add(key);438 seen.add(key);
424 dirs.push({439 dirs.push({
425 provider,440 provider,
441 format,
426 dir,442 dir,
427 volumeId,443 volumeId,
428 ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}),444 ...(fileName === undefined ? {} : { fileName }),
429 });445 });
430 }446 }
431 }447 }
...@@ -456,7 +472,9 @@ export const make = Effect.gen(function* () {...@@ -456,7 +472,9 @@ export const make = Effect.gen(function* () {
456 cacheDirty = document !== null;472 cacheDirty = document !== null;
457 }473 }
458 if (document === null) return;474 if (document === null) return;
459 for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry);475 for (const [path, entry] of decodeScanCache(document, transcriptFormats)) {
476 fileCache.set(path, entry);
477 }
460 const sources = decodeCachedSources(document);478 const sources = decodeCachedSources(document);
461 if (Option.isSome(sources)) {479 if (Option.isSome(sources)) {
462 for (const [key, source] of Object.entries(sources.value.sources))480 for (const [key, source] of Object.entries(sources.value.sources))
...@@ -536,6 +554,7 @@ export const make = Effect.gen(function* () {...@@ -536,6 +554,7 @@ export const make = Effect.gen(function* () {
536 size: number,554 size: number,
537 mtimeMs: number,555 mtimeMs: number,
538 provider: UsageProviderKind,556 provider: UsageProviderKind,
557 format: TranscriptUsageFormat<unknown>,
539 ): Effect.Effect<{558 ): Effect.Effect<{
540 readonly records: readonly UsageRecord[];559 readonly records: readonly UsageRecord[];
541 readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined };560 readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined };
...@@ -566,7 +585,7 @@ export const make = Effect.gen(function* () {...@@ -566,7 +585,7 @@ export const make = Effect.gen(function* () {
566 : undefined;585 : undefined;
567586
568 const parsed = yield* Effect.promise(() =>587 const parsed = yield* Effect.promise(() =>
569 readTranscriptRecords(filePath, provider, resumeFrom),588 readTranscriptRecords(filePath, format, resumeFrom),
570 );589 );
571 // A read failure is not an empty transcript: caching it under this590 // A read failure is not an empty transcript: caching it under this
572 // (size, mtime) would silently drop the file's usage until it changes.591 // (size, mtime) would silently drop the file's usage until it changes.
...@@ -611,15 +630,10 @@ export const make = Effect.gen(function* () {...@@ -611,15 +630,10 @@ export const make = Effect.gen(function* () {
611 }630 }
612631
613 const scanTranscriptDir = Effect.fn("UsageService.scanTranscriptDir")(function* (632 const scanTranscriptDir = Effect.fn("UsageService.scanTranscriptDir")(function* (
614 source: {633 source: TranscriptSource,
615 readonly provider: UsageProviderKind;
616 readonly dir: string;
617 readonly volumeId: string;
618 readonly fileName?: string;
619 },
620 windowStartMs: number,634 windowStartMs: number,
621 ) {635 ) {
622 const { provider, dir, volumeId, fileName } = source;636 const { provider, format, dir, volumeId, fileName } = source;
623 const exists = yield* fileSystem637 const exists = yield* fileSystem
624 .exists(dir)638 .exists(dir)
625 .pipe(Effect.catchCause(() => Effect.succeed(false)));639 .pipe(Effect.catchCause(() => Effect.succeed(false)));
...@@ -632,7 +646,7 @@ export const make = Effect.gen(function* () {...@@ -632,7 +646,7 @@ export const make = Effect.gen(function* () {
632 const read = yield* Effect.forEach(646 const read = yield* Effect.forEach(
633 files,647 files,
634 (file) =>648 (file) =>
635 readFileRecords(file.path, file.size, file.mtimeMs, provider).pipe(649 readFileRecords(file.path, file.size, file.mtimeMs, provider, format).pipe(
636 Effect.map((result) => ({ path: file.path, ...result })),650 Effect.map((result) => ({ path: file.path, ...result })),
637 ),651 ),
638 { concurrency: TRANSCRIPT_READ_CONCURRENCY },652 { concurrency: TRANSCRIPT_READ_CONCURRENCY },
...@@ -818,10 +832,8 @@ export const make = Effect.gen(function* () {...@@ -818,10 +832,8 @@ export const make = Effect.gen(function* () {
818 retentionCutoffMs: number,832 retentionCutoffMs: number,
819 awaitRefresh: boolean,833 awaitRefresh: boolean,
820 ) {834 ) {
821 // The home resolvers ask for `Path` themselves; satisfy them from the
822 // instance we already hold so the scan stays context-free.
823 const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe(835 const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe(
824 Effect.provideService(Path.Path, path),836 Effect.provideContext(readerContext),
825 );837 );
826838
827 const home = NodeOS.homedir();839 const home = NodeOS.homedir();
...@@ -1100,7 +1112,7 @@ export const make = Effect.gen(function* () {...@@ -1100,7 +1112,7 @@ export const make = Effect.gen(function* () {
1100 }1112 }
1101 return retainedFiles;1113 return retainedFiles;
1102 });1114 });
1103 const sharedSessions = sharedCodexSessions(filesByDir.flat());1115 const crossFileSessions = sharedSessions(filesByDir.flat());
11041116
1105 for (const [1117 for (const [
1106 index,1118 index,
...@@ -1118,12 +1130,15 @@ export const make = Effect.gen(function* () {...@@ -1118,12 +1130,15 @@ export const make = Effect.gen(function* () {
1118 continue;1130 continue;
1119 }1131 }
1120 scannedFiles += 1;1132 scannedFiles += 1;
1121 const codexEventOccurrences = new Map<string, number>();1133 const eventOccurrences = new Map<string, number>();
1122 for (const record of file.records) {1134 for (const record of file.records) {
1123 let usageRecord = record;1135 let usageRecord = record;
1124 if (record.provider === "codex" && sharedSessions.has(record.sessionId)) {1136 if (
1125 // Match moved rollout copies without collapsing repeated equal events1137 sharedSessionProviders.has(record.provider) &&
1126 // within one rollout (timestamps can have only second precision).1138 crossFileSessions.has(record.sessionId)
1139 ) {
1140 // Match moved copies without collapsing repeated equal events
1141 // within one file (timestamps can have only second precision).
1127 // Only sessions seen in several files can have a copy to match.1142 // Only sessions seen in several files can have a copy to match.
1128 const key = encodeUsageRecordKey([1143 const key = encodeUsageRecordKey([
1129 record.provider,1144 record.provider,
...@@ -1132,8 +1147,8 @@ export const make = Effect.gen(function* () {...@@ -1132,8 +1147,8 @@ export const make = Effect.gen(function* () {
1132 record.model,1147 record.model,
1133 record.totals,1148 record.totals,
1134 ]);1149 ]);
1135 const occurrence = (codexEventOccurrences.get(key) ?? 0) + 1;1150 const occurrence = (eventOccurrences.get(key) ?? 0) + 1;
1136 codexEventOccurrences.set(key, occurrence);1151 eventOccurrences.set(key, occurrence);
1137 usageRecord = { ...record, dedupeKey: key + ":" + occurrence };1152 usageRecord = { ...record, dedupeKey: key + ":" + occurrence };
1138 }1153 }
1139 // Only sessions contributing in-window count; the mtime slack can1154 // Only sessions contributing in-window count; the mtime slack can
apps/server/src/usage/antigravityUsageReader.ts+1-1
...@@ -5,7 +5,7 @@ import * as NodePath from "node:path";...@@ -5,7 +5,7 @@ import * as NodePath from "node:path";
5import * as NodeSqlite from "node:sqlite";5import * as NodeSqlite from "node:sqlite";
6import * as NodeTimersPromises from "node:timers/promises";6import * as NodeTimersPromises from "node:timers/promises";
77
8import type { UsageRecord } from "./usageTranscripts.ts";8import type { UsageRecord } from "@t3tools/provider-core/server/usage";
99
10type FieldValue = number | bigint | Uint8Array;10type FieldValue = number | bigint | Uint8Array;
11type Fields = Map<number, FieldValue[]>;11type Fields = Map<number, FieldValue[]>;
apps/server/src/usage/cursorAccountCache.test.ts+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
22
3import { cursorFetchRange, mergeCursorFetch } from "./cursorAccountCache.ts";3import { cursorFetchRange, mergeCursorFetch } from "./cursorAccountCache.ts";
4import type { UsageRecord } from "./usageTranscripts.ts";4import type { UsageRecord } from "@t3tools/provider-core/server/usage";
55
6const DAY_MS = 24 * 60 * 60 * 1000;6const DAY_MS = 24 * 60 * 60 * 1000;
77
apps/server/src/usage/cursorAccountCache.ts+1-1
...@@ -13,7 +13,7 @@...@@ -13,7 +13,7 @@
13 * @module cursorAccountCache13 * @module cursorAccountCache
14 */14 */
15import { cursorRateModel } from "./cursorUsageReader.ts";15import { cursorRateModel } from "./cursorUsageReader.ts";
16import type { UsageRecord } from "./usageTranscripts.ts";16import type { UsageRecord } from "@t3tools/provider-core/server/usage";
1717
18export type CursorCredentialSource = string | { readonly kind: "keychain" };18export type CursorCredentialSource = string | { readonly kind: "keychain" };
1919
apps/server/src/usage/cursorUsageReader.ts+1-1
...@@ -8,7 +8,7 @@ import * as Context from "effect/Context";...@@ -8,7 +8,7 @@ import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
1010
11import type { UsageRecord } from "./usageTranscripts.ts";11import type { UsageRecord } from "@t3tools/provider-core/server/usage";
12import {12import {
13 CursorKeychainTimeoutError,13 CursorKeychainTimeoutError,
14 readMacCursorAccessToken,14 readMacCursorAccessToken,
apps/server/src/usage/opencodeUsageReader.ts+1-1
...@@ -5,7 +5,7 @@ import * as NodePath from "node:path";...@@ -5,7 +5,7 @@ import * as NodePath from "node:path";
5import * as NodeSqlite from "node:sqlite";5import * as NodeSqlite from "node:sqlite";
6import * as NodeTimersPromises from "node:timers/promises";6import * as NodeTimersPromises from "node:timers/promises";
77
8import { totalTokens, type UsageRecord } from "./usageTranscripts.ts";8import { totalTokens, type UsageRecord } from "@t3tools/provider-core/server/usage";
99
10function object(value: unknown): Record<string, unknown> {10function object(value: unknown): Record<string, unknown> {
11 return typeof value === "object" && value !== null && !Array.isArray(value)11 return typeof value === "object" && value !== null && !Array.isArray(value)
apps/server/src/usage/usageAggregation.test.ts+1-1
...@@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest";...@@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest";
22
3import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";3import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
4import type { RateTable } from "./usagePricing.ts";4import type { RateTable } from "./usagePricing.ts";
5import type { UsageRecord } from "./usageTranscripts.ts";5import type { UsageRecord } from "@t3tools/provider-core/server/usage";
66
7const rates: RateTable = new Map([7const rates: RateTable = new Map([
8 [8 [
apps/server/src/usage/usageAggregation.ts+1-1
...@@ -20,7 +20,7 @@ import type {...@@ -20,7 +20,7 @@ import type {
20 UsageTokenTotals,20 UsageTokenTotals,
21} from "@t3tools/contracts";21} from "@t3tools/contracts";
2222
23import { EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts";23import { EMPTY_TOTALS, type UsageRecord } from "@t3tools/provider-core/server/usage";
24import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts";24import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts";
2525
26/**26/**
apps/server/src/usage/usagePricing.test.ts+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
22
3import { cursorRateModel } from "./cursorUsageReader.ts";3import { cursorRateModel } from "./cursorUsageReader.ts";
4import type { UsageSpeed } from "./usageTranscripts.ts";4import type { UsageSpeed } from "@t3tools/provider-core/server/usage";
5import {5import {
6 cacheSavingsUsd,6 cacheSavingsUsd,
7 createOverrideRateTable,7 createOverrideRateTable,
apps/server/src/usage/usagePricing.ts+1-1
...@@ -14,7 +14,7 @@ import type {...@@ -14,7 +14,7 @@ import type {
14 UsageTokenTotals,14 UsageTokenTotals,
15} from "@t3tools/contracts";15} from "@t3tools/contracts";
1616
17import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts";17import type { UsageRecord, UsageSpeed } from "@t3tools/provider-core/server/usage";
1818
19/** Token rates for one billing speed. All values are USD per token. */19/** Token rates for one billing speed. All values are USD per token. */
20export interface TokenRates {20export interface TokenRates {
apps/server/src/usage/usageScanCache.test.ts+29-16
...@@ -9,7 +9,9 @@ import {...@@ -9,7 +9,9 @@ import {
9 type CachedFile,9 type CachedFile,
10 type ScanCache,10 type ScanCache,
11} from "./usageScanCache.ts";11} from "./usageScanCache.ts";
12import type { UsageRecord } from "./usageTranscripts.ts";12import type { UsageRecord } from "@t3tools/provider-core/server/usage";
13
14import { TEST_FORMAT_MAP } from "./usageTestFormats.ts";
1315
14function record(overrides: Partial<UsageRecord> = {}): UsageRecord {16function record(overrides: Partial<UsageRecord> = {}): UsageRecord {
15 return {17 return {
...@@ -36,7 +38,7 @@ function position(overrides: Partial<CachedFile["position"]> = {}): CachedFile["...@@ -36,7 +38,7 @@ function position(overrides: Partial<CachedFile["position"]> = {}): CachedFile["
36 resumeOffset: 120,38 resumeOffset: 120,
37 guardLength: 64,39 guardLength: 64,
38 guardHash: 0xdeadbeef,40 guardHash: 0xdeadbeef,
39 codexState: null,41 state: null,
40 ...overrides,42 ...overrides,
41 };43 };
42}44}
...@@ -85,7 +87,7 @@ describe("scan cache round trip", () => {...@@ -85,7 +87,7 @@ describe("scan cache round trip", () => {
85 ],87 ],
86 tailRecords: [],88 tailRecords: [],
87 position: position({89 position: position({
88 codexState: {90 state: {
89 model: "gpt-6-astra",91 model: "gpt-6-astra",
90 speed: "ultrafast",92 speed: "ultrafast",
91 sessionId: "session-c",93 sessionId: "session-c",
...@@ -97,7 +99,10 @@ describe("scan cache round trip", () => {...@@ -97,7 +99,10 @@ describe("scan cache round trip", () => {
97 }),99 }),
98 });100 });
99101
100 const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original))));102 const restored = decodeScanCache(
103 JSON.parse(JSON.stringify(encodeScanCache(original))),
104 TEST_FORMAT_MAP,
105 );
101106
102 expect(restored.size).toBe(4);107 expect(restored.size).toBe(4);
103 expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl"));108 expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl"));
...@@ -117,7 +122,9 @@ describe("scan cache round trip", () => {...@@ -117,7 +122,9 @@ describe("scan cache round trip", () => {
117 },122 },
118 };123 };
119124
120 expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false);125 expect(
126 decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).has("/a.jsonl"),
127 ).toBe(false);
121 });128 });
122129
123 it("drops an entry whose guard length is outside the supported range", () => {130 it("drops an entry whose guard length is outside the supported range", () => {
...@@ -129,7 +136,9 @@ describe("scan cache round trip", () => {...@@ -129,7 +136,9 @@ describe("scan cache round trip", () => {
129 files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, gl: 1e20 } },136 files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, gl: 1e20 } },
130 };137 };
131138
132 expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false);139 expect(
140 decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).has("/a.jsonl"),
141 ).toBe(false);
133 });142 });
134143
135 it("drops an entry whose speed is not a known index", () => {144 it("drops an entry whose speed is not a known index", () => {
...@@ -140,14 +149,16 @@ describe("scan cache round trip", () => {...@@ -140,14 +149,16 @@ describe("scan cache round trip", () => {
140 files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, r: [[...row.slice(0, 10), true]] } },149 files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, r: [[...row.slice(0, 10), true]] } },
141 };150 };
142151
143 expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false);152 expect(
153 decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).has("/a.jsonl"),
154 ).toBe(false);
144 });155 });
145156
146 it("rejects a document from before records carried a speed", () => {157 it("rejects a document from before records carried a speed", () => {
147 const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]]));158 const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]]));
148 const previous = { ...encoded, version: 3 };159 const previous = { ...encoded, version: 3 };
149160
150 expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0);161 expect(decodeScanCache(JSON.parse(JSON.stringify(previous)), TEST_FORMAT_MAP).size).toBe(0);
151 });162 });
152163
153 it("rewrites only changed entries and still restores the whole cache", () => {164 it("rewrites only changed entries and still restores the whole cache", () => {
...@@ -157,7 +168,7 @@ describe("scan cache round trip", () => {...@@ -157,7 +168,7 @@ describe("scan cache round trip", () => {
157 ["/b.jsonl", 200, [record({ sessionId: "session-b" })]],168 ["/b.jsonl", 200, [record({ sessionId: "session-b" })]],
158 ]);169 ]);
159 const sources = { "claude\u0000/projects": { dir: "/projects", volumeId: "1:2" } };170 const sources = { "claude\u0000/projects": { dir: "/projects", volumeId: "1:2" } };
160 expect(decodeScanCache(JSON.parse(write(cache, { sources })))).toEqual(cache);171 expect(decodeScanCache(JSON.parse(write(cache, { sources })), TEST_FORMAT_MAP)).toEqual(cache);
161172
162 // The replacement adds intern entries; /a's memoised indexes must hold.173 // The replacement adds intern entries; /a's memoised indexes must hold.
163 cache.set("/b.jsonl", {174 cache.set("/b.jsonl", {
...@@ -166,7 +177,7 @@ describe("scan cache round trip", () => {...@@ -166,7 +177,7 @@ describe("scan cache round trip", () => {
166 records: [record({ sessionId: "session-c", model: "claude-opus-5-5", dedupeKey: "msg_3:" })],177 records: [record({ sessionId: "session-c", model: "claude-opus-5-5", dedupeKey: "msg_3:" })],
167 });178 });
168 const document = JSON.parse(write(cache, { sources }));179 const document = JSON.parse(write(cache, { sources }));
169 expect(decodeScanCache(document)).toEqual(cache);180 expect(decodeScanCache(document, TEST_FORMAT_MAP)).toEqual(cache);
170 expect(document.sources).toEqual(sources);181 expect(document.sources).toEqual(sources);
171 });182 });
172183
...@@ -181,9 +192,11 @@ describe("scan cache round trip", () => {...@@ -181,9 +192,11 @@ describe("scan cache round trip", () => {
181192
182 it("treats a corrupt or foreign document as an empty cache", () => {193 it("treats a corrupt or foreign document as an empty cache", () => {
183 // A bad cache should cost one cold scan, never a broken page.194 // A bad cache should cost one cold scan, never a broken page.
184 expect(decodeScanCache(null).size).toBe(0);195 expect(decodeScanCache(null, TEST_FORMAT_MAP).size).toBe(0);
185 expect(decodeScanCache("nonsense").size).toBe(0);196 expect(decodeScanCache("nonsense", TEST_FORMAT_MAP).size).toBe(0);
186 expect(decodeScanCache({ version: 999, models: [], sessions: [], files: {} }).size).toBe(0);197 expect(
198 decodeScanCache({ version: 999, models: [], sessions: [], files: {} }, TEST_FORMAT_MAP).size,
199 ).toBe(0);
187 });200 });
188201
189 it("skips malformed file entries but keeps good ones", () => {202 it("skips malformed file entries but keeps good ones", () => {
...@@ -193,7 +206,7 @@ describe("scan cache round trip", () => {...@@ -193,7 +206,7 @@ describe("scan cache round trip", () => {
193 files: { ...encoded.files, "/bad.jsonl": { s: "nope", m: 1, p: "claude", r: [] } },206 files: { ...encoded.files, "/bad.jsonl": { s: "nope", m: 1, p: "claude", r: [] } },
194 };207 };
195208
196 const restored = decodeScanCache(JSON.parse(JSON.stringify(withJunk)));209 const restored = decodeScanCache(JSON.parse(JSON.stringify(withJunk)), TEST_FORMAT_MAP);
197 expect([...restored.keys()]).toEqual(["/good.jsonl"]);210 expect([...restored.keys()]).toEqual(["/good.jsonl"]);
198 });211 });
199212
...@@ -203,7 +216,7 @@ describe("scan cache round trip", () => {...@@ -203,7 +216,7 @@ describe("scan cache round trip", () => {
203 const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]]));216 const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]]));
204 const poisoned = { ...encoded, models: [1] };217 const poisoned = { ...encoded, models: [1] };
205218
206 expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).size).toBe(0);219 expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).size).toBe(0);
207 });220 });
208221
209 it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => {222 it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => {
...@@ -223,7 +236,7 @@ describe("scan cache round trip", () => {...@@ -223,7 +236,7 @@ describe("scan cache round trip", () => {
223 },236 },
224 };237 };
225238
226 const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned)));239 const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP);
227 expect(restored.has("/a.jsonl")).toBe(false);240 expect(restored.has("/a.jsonl")).toBe(false);
228 });241 });
229});242});
apps/server/src/usage/usageScanCache.ts+52-51
...@@ -15,9 +15,14 @@...@@ -15,9 +15,14 @@
15 * @module usageScanCache15 * @module usageScanCache
16 */16 */
17import type { UsageProviderKind } from "@t3tools/contracts";17import type { UsageProviderKind } from "@t3tools/contracts";
18import type {
19 TranscriptUsageFormat,
20 UsageRecord,
21 UsageSpeed,
22} from "@t3tools/provider-core/server/usage";
23import * as Schema from "effect/Schema";
1824
19import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts";25import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts";
20import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts.ts";
2126
22// v2: Codex fork-copy suppression changed what a file parses to, so v127// v2: Codex fork-copy suppression changed what a file parses to, so v1
23// entries would keep serving double-counted records forever.28// entries would keep serving double-counted records forever.
...@@ -25,7 +30,7 @@ import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts...@@ -25,7 +30,7 @@ import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts
25// re-parses only its appended bytes instead of starting over.30// re-parses only its appended bytes instead of starting over.
26// v4: records carry Claude fast mode, which v3 rows never captured.31// v4: records carry Claude fast mode, which v3 rows never captured.
27// v5: Codex records carry their service tier. v4 rows store speed the same32// v5: Codex records carry their service tier. v4 rows store speed the same
28// way, so v4 entries still load; see `decodeScanCache` for v4 Codex entries.33// way, so v4 entries still load; see `decodeScanCache` for v4 stateful entries.
29const USAGE_SCAN_CACHE_VERSION = 5 as const;34const USAGE_SCAN_CACHE_VERSION = 5 as const;
30const SPEED_COMPATIBLE_SINCE_VERSION = 4;35const SPEED_COMPATIBLE_SINCE_VERSION = 4;
3136
...@@ -41,10 +46,6 @@ export const LEGACY_SCAN_CACHE_FILE_NAME = "usage-scan-cache.json";...@@ -41,10 +46,6 @@ export const LEGACY_SCAN_CACHE_FILE_NAME = "usage-scan-cache.json";
41/** Serialised as the index into this list. */46/** Serialised as the index into this list. */
42const SPEEDS: readonly UsageSpeed[] = ["standard", "fast", "ultrafast"];47const SPEEDS: readonly UsageSpeed[] = ["standard", "fast", "ultrafast"];
4348
44function isSpeed(value: unknown): value is UsageSpeed {
45 return SPEEDS.some((speed) => speed === value);
46}
47
48export interface CachedFile {49export interface CachedFile {
49 readonly size: number;50 readonly size: number;
50 readonly mtimeMs: number;51 readonly mtimeMs: number;
...@@ -92,8 +93,8 @@ interface SerializedFile {...@@ -92,8 +93,8 @@ interface SerializedFile {
92 readonly o: number;93 readonly o: number;
93 readonly gl: number;94 readonly gl: number;
94 readonly gh: number;95 readonly gh: number;
95 /** Codex reducer state at `o`; `null` for stateless providers. */96 /** The format's encoded reducer state at `o`; `null` for stateless formats. */
96 readonly cs: CodexScanState | null;97 readonly cs: unknown;
97}98}
9899
99interface SerializedCache {100interface SerializedCache {
...@@ -147,7 +148,7 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile...@@ -147,7 +148,7 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile
147 o: entry.position.resumeOffset,148 o: entry.position.resumeOffset,
148 gl: entry.position.guardLength,149 gl: entry.position.guardLength,
149 gh: entry.position.guardHash,150 gh: entry.position.guardHash,
150 cs: entry.position.codexState,151 cs: entry.position.state,
151 };152 };
152}153}
153154
...@@ -210,10 +211,15 @@ function isRecordArray(value: unknown): value is readonly unknown[] {...@@ -210,10 +211,15 @@ function isRecordArray(value: unknown): value is readonly unknown[] {
210 * Rebuilds the cache from a parsed document.211 * Rebuilds the cache from a parsed document.
211 *212 *
212 * Anything malformed yields an empty cache rather than an error: a corrupt213 * Anything malformed yields an empty cache rather than an error: a corrupt
213 * cache should cost one cold scan, never a broken page.214 * cache should cost one cold scan, never a broken page. Entries of a provider
215 * without a transcript format in `formats` are dropped.
214 */216 */
215export function decodeScanCache(document: unknown): ScanCache {217export function decodeScanCache(
218 document: unknown,
219 formats: ReadonlyMap<UsageProviderKind, TranscriptUsageFormat<unknown>>,
220): ScanCache {
216 const cache: ScanCache = new Map();221 const cache: ScanCache = new Map();
222 const isValidState = makeStateValidators(formats);
217 if (typeof document !== "object" || document === null) return cache;223 if (typeof document !== "object" || document === null) return cache;
218224
219 const root = document as Partial<SerializedCache>;225 const root = document as Partial<SerializedCache>;
...@@ -300,7 +306,9 @@ export function decodeScanCache(document: unknown): ScanCache {...@@ -300,7 +306,9 @@ export function decodeScanCache(document: unknown): ScanCache {
300 if (typeof raw !== "object" || raw === null) continue;306 if (typeof raw !== "object" || raw === null) continue;
301 const entry = raw as Partial<SerializedFile>;307 const entry = raw as Partial<SerializedFile>;
302 if (typeof entry.s !== "number" || typeof entry.m !== "number") continue;308 if (typeof entry.s !== "number" || typeof entry.m !== "number") continue;
303 if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue;309 if (entry.p === undefined) continue;
310 const format = formats.get(entry.p);
311 if (format === undefined) continue;
304 if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue;312 if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue;
305 // Position fields feed byte offsets and a Buffer allocation in the reader,313 // Position fields feed byte offsets and a Buffer allocation in the reader,
306 // so anything outside their real ranges must reject the entry: a bogus314 // so anything outside their real ranges must reject the entry: a bogus
...@@ -320,27 +328,34 @@ export function decodeScanCache(document: unknown): ScanCache {...@@ -320,27 +328,34 @@ export function decodeScanCache(document: unknown): ScanCache {
320 ) {328 ) {
321 continue;329 continue;
322 }330 }
323 // v4 Codex records predate service tiers, so they all priced as standard.331 // v4 records of stateful formats (Codex) predate service tiers, so they
324 // Keep them, because the rollout may be gone, but make a live rollout332 // all priced as standard. Keep them, because the rollout may be gone, but
325 // re-parse whole: no file has size -1, and a zero position cannot resume.333 // make a live rollout re-parse whole: no file has size -1, and a zero
326 const legacyCodex = entry.p === "codex" && version < USAGE_SCAN_CACHE_VERSION;334 // position cannot resume.
327 const codexState = legacyCodex ? null : decodeCodexState(entry.cs);335 const legacy = format.state !== undefined && version < USAGE_SCAN_CACHE_VERSION;
328 if (codexState === undefined) continue;336 // A corrupt state disqualifies the entry: resuming with it would attach
337 // appended usage to the wrong model or replay fork-copied history.
338 if (!legacy && !isValidState.get(entry.p)?.(entry.cs)) continue;
329339
330 const provider: UsageProviderKind = entry.p;340 const provider = entry.p;
331 const records = decodeRecords(entry.r, provider);341 const records = decodeRecords(entry.r, provider);
332 const tailRecords = decodeRecords(entry.t, provider);342 const tailRecords = decodeRecords(entry.t, provider);
333 if (records === null || tailRecords === null) continue;343 if (records === null || tailRecords === null) continue;
334344
335 cache.set(path, {345 cache.set(path, {
336 size: legacyCodex ? -1 : entry.s,346 size: legacy ? -1 : entry.s,
337 mtimeMs: entry.m,347 mtimeMs: entry.m,
338 provider,348 provider,
339 records,349 records,
340 tailRecords,350 tailRecords,
341 position: legacyCodex351 position: legacy
342 ? { resumeOffset: 0, guardLength: 0, guardHash: 0, codexState: null }352 ? { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }
343 : { resumeOffset: entry.o, guardLength: entry.gl, guardHash: entry.gh, codexState },353 : {
354 resumeOffset: entry.o,
355 guardLength: entry.gl,
356 guardHash: entry.gh,
357 state: entry.cs,
358 },
344 });359 });
345 }360 }
346361
...@@ -348,35 +363,21 @@ export function decodeScanCache(document: unknown): ScanCache {...@@ -348,35 +363,21 @@ export function decodeScanCache(document: unknown): ScanCache {
348}363}
349364
350/**365/**
351 * Validates a persisted Codex reducer state. Returns `undefined` for a corrupt366 * Per provider, whether a persisted state is valid: `null`, or one the
352 * value, which disqualifies the entry: resuming with a bad state would attach367 * format's schema accepts.
353 * appended usage to the wrong model or replay fork-copied history.
354 */368 */
355function decodeCodexState(value: unknown): CodexScanState | null | undefined {369function makeStateValidators(
356 if (value === null) return null;370 formats: ReadonlyMap<UsageProviderKind, TranscriptUsageFormat<unknown>>,
357 if (typeof value !== "object") return undefined;371): ReadonlyMap<UsageProviderKind, (value: unknown) => boolean> {
358 const state = value as Partial<CodexScanState>;372 return new Map(
359 if (373 [...formats].map(([provider, format]) => {
360 typeof state.model !== "string" ||374 const isState = format.state === undefined ? undefined : Schema.is(format.state.schema);
361 !isSpeed(state.speed) ||375 return [
362 typeof state.sessionId !== "string" ||376 provider,
363 (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") ||377 (value: unknown) => value === null || (isState !== undefined && isState(value)),
364 typeof state.sawSessionMeta !== "boolean" ||378 ] as const;
365 typeof state.suppressingForkCopies !== "boolean" ||379 }),
366 typeof state.forkCopyAnchorMs !== "number" ||380 );
367 !Number.isFinite(state.forkCopyAnchorMs)
368 ) {
369 return undefined;
370 }
371 return {
372 model: state.model,
373 speed: state.speed,
374 sessionId: state.sessionId,
375 lastUsageSignature: state.lastUsageSignature ?? null,
376 sawSessionMeta: state.sawSessionMeta,
377 suppressingForkCopies: state.suppressingForkCopies,
378 forkCopyAnchorMs: state.forkCopyAnchorMs,
379 };
380}381}
381382
382/** Keeps saved usage after transcript cleanup, until the reporting retention expires. */383/** Keeps saved usage after transcript cleanup, until the reporting retention expires. */
apps/server/src/usage/usageTestFormats.ts created+21
...@@ -0,0 +1,21 @@
1/**
2 * The built-in transcript formats by provider, for engine and cache tests.
3 *
4 * @module usageTestFormats
5 */
6import type { UsageProviderKind } from "@t3tools/contracts";
7import type { TranscriptUsageFormat } from "@t3tools/provider-core/server/usage";
8import { grokUsageFormat } from "@t3tools/provider-grok/server/usage";
9
10import { claudeUsageFormat } from "../provider/Drivers/claudeUsage.ts";
11import { codexUsageFormat } from "../provider/Drivers/codexUsage.ts";
12
13export const TEST_FORMATS: Record<"claude" | "codex" | "grok", TranscriptUsageFormat<unknown>> = {
14 claude: claudeUsageFormat,
15 codex: codexUsageFormat,
16 grok: grokUsageFormat,
17};
18
19export const TEST_FORMAT_MAP = new Map<UsageProviderKind, TranscriptUsageFormat<unknown>>(
20 Object.entries(TEST_FORMATS) as Array<[UsageProviderKind, TranscriptUsageFormat<unknown>]>,
21);
apps/server/src/usage/usageTranscriptReader.test.ts+18-15
...@@ -8,6 +8,7 @@ import * as NodeSqlite from "node:sqlite";...@@ -8,6 +8,7 @@ import * as NodeSqlite from "node:sqlite";
88
9import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest";9import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest";
1010
11import { TEST_FORMATS } from "./usageTestFormats.ts";
11import { readTranscriptRecords } from "./usageTranscriptReader.ts";12import { readTranscriptRecords } from "./usageTranscriptReader.ts";
12import { readOpenCodeUsage } from "./opencodeUsageReader.ts";13import { readOpenCodeUsage } from "./opencodeUsageReader.ts";
13import { readCursorAccountUsage } from "./cursorUsageReader.ts";14import { readCursorAccountUsage } from "./cursorUsageReader.ts";
...@@ -99,20 +100,20 @@ describe("readTranscriptRecords resume", () => {...@@ -99,20 +100,20 @@ describe("readTranscriptRecords resume", () => {
99 it("parses only appended lines when resuming a grown file", async () => {100 it("parses only appended lines when resuming a grown file", async () => {
100 const path = NodePath.join(dir, "claude.jsonl");101 const path = NodePath.join(dir, "claude.jsonl");
101 await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7));102 await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7));
102 const first = await readTranscriptRecords(path, "claude");103 const first = await readTranscriptRecords(path, TEST_FORMATS.claude);
103 assert.isNotNull(first);104 assert.isNotNull(first);
104 assert.strictEqual(first.records.length, 2);105 assert.strictEqual(first.records.length, 2);
105 assert.isFalse(first.resumed);106 assert.isFalse(first.resumed);
106107
107 await NodeFSP.appendFile(path, claudeLine(3, 11));108 await NodeFSP.appendFile(path, claudeLine(3, 11));
108 const second = await readTranscriptRecords(path, "claude", first.position);109 const second = await readTranscriptRecords(path, TEST_FORMATS.claude, first.position);
109 assert.isNotNull(second);110 assert.isNotNull(second);
110 assert.isTrue(second.resumed);111 assert.isTrue(second.resumed);
111 assert.strictEqual(second.records.length, 1);112 assert.strictEqual(second.records.length, 1);
112 assert.strictEqual(second.records[0]?.totals.outputTokens, 11);113 assert.strictEqual(second.records[0]?.totals.outputTokens, 11);
113114
114 // The stitched result matches a from-scratch parse of the whole file.115 // The stitched result matches a from-scratch parse of the whole file.
115 const full = await readTranscriptRecords(path, "claude");116 const full = await readTranscriptRecords(path, TEST_FORMATS.claude);
116 assert.isNotNull(full);117 assert.isNotNull(full);
117 assert.deepStrictEqual([...first.records, ...second.records], [...full.records]);118 assert.deepStrictEqual([...first.records, ...second.records], [...full.records]);
118 });119 });
...@@ -123,7 +124,7 @@ describe("readTranscriptRecords resume", () => {...@@ -123,7 +124,7 @@ describe("readTranscriptRecords resume", () => {
123 path,124 path,
124 codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexTierLine("ultrafast"),125 codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexTierLine("ultrafast"),
125 );126 );
126 const first = await readTranscriptRecords(path, "codex");127 const first = await readTranscriptRecords(path, TEST_FORMATS.codex);
127 assert.isNotNull(first);128 assert.isNotNull(first);
128 assert.strictEqual(first.records.length, 0);129 assert.strictEqual(first.records.length, 0);
129130
...@@ -131,7 +132,7 @@ describe("readTranscriptRecords resume", () => {...@@ -131,7 +132,7 @@ describe("readTranscriptRecords resume", () => {
131 // session_meta of its own; model, tier, and session must come from the132 // session_meta of its own; model, tier, and session must come from the
132 // state captured before the boundary.133 // state captured before the boundary.
133 await NodeFSP.appendFile(path, codexUsageLine(9, 5));134 await NodeFSP.appendFile(path, codexUsageLine(9, 5));
134 const second = await readTranscriptRecords(path, "codex", first.position);135 const second = await readTranscriptRecords(path, TEST_FORMATS.codex, first.position);
135 assert.isNotNull(second);136 assert.isNotNull(second);
136 assert.isTrue(second.resumed);137 assert.isTrue(second.resumed);
137 assert.strictEqual(second.records.length, 1);138 assert.strictEqual(second.records.length, 1);
...@@ -146,14 +147,14 @@ describe("readTranscriptRecords resume", () => {...@@ -146,14 +147,14 @@ describe("readTranscriptRecords resume", () => {
146 path,147 path,
147 codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexUsageLine(9, 5),148 codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexUsageLine(9, 5),
148 );149 );
149 const first = await readTranscriptRecords(path, "codex");150 const first = await readTranscriptRecords(path, TEST_FORMATS.codex);
150 assert.isNotNull(first);151 assert.isNotNull(first);
151 assert.strictEqual(first.records.length, 1);152 assert.strictEqual(first.records.length, 1);
152153
153 // Codex re-emits an unchanged token_count on stream boundaries; the copy154 // Codex re-emits an unchanged token_count on stream boundaries; the copy
154 // lands after the resume point and must still be dropped.155 // lands after the resume point and must still be dropped.
155 await NodeFSP.appendFile(path, codexUsageLine(9, 5) + codexUsageLine(21, 8));156 await NodeFSP.appendFile(path, codexUsageLine(9, 5) + codexUsageLine(21, 8));
156 const second = await readTranscriptRecords(path, "codex", first.position);157 const second = await readTranscriptRecords(path, TEST_FORMATS.codex, first.position);
157 assert.isNotNull(second);158 assert.isNotNull(second);
158 assert.isTrue(second.resumed);159 assert.isTrue(second.resumed);
159 assert.deepStrictEqual(160 assert.deepStrictEqual(
...@@ -166,7 +167,7 @@ describe("readTranscriptRecords resume", () => {...@@ -166,7 +167,7 @@ describe("readTranscriptRecords resume", () => {
166 const path = NodePath.join(dir, "claude.jsonl");167 const path = NodePath.join(dir, "claude.jsonl");
167 const unterminated = claudeLine(2, 7).trimEnd();168 const unterminated = claudeLine(2, 7).trimEnd();
168 await NodeFSP.writeFile(path, claudeLine(1, 5) + unterminated);169 await NodeFSP.writeFile(path, claudeLine(1, 5) + unterminated);
169 const first = await readTranscriptRecords(path, "claude");170 const first = await readTranscriptRecords(path, TEST_FORMATS.claude);
170 assert.isNotNull(first);171 assert.isNotNull(first);
171 assert.strictEqual(first.records.length, 1);172 assert.strictEqual(first.records.length, 1);
172 assert.strictEqual(first.tailRecords.length, 1);173 assert.strictEqual(first.tailRecords.length, 1);
...@@ -175,7 +176,7 @@ describe("readTranscriptRecords resume", () => {...@@ -175,7 +176,7 @@ describe("readTranscriptRecords resume", () => {
175 // Completing the line and appending another re-reads from the resume176 // Completing the line and appending another re-reads from the resume
176 // point, so the once-tail record arrives exactly once as a line record.177 // point, so the once-tail record arrives exactly once as a line record.
177 await NodeFSP.appendFile(path, `\n${claudeLine(3, 11)}`);178 await NodeFSP.appendFile(path, `\n${claudeLine(3, 11)}`);
178 const second = await readTranscriptRecords(path, "claude", first.position);179 const second = await readTranscriptRecords(path, TEST_FORMATS.claude, first.position);
179 assert.isNotNull(second);180 assert.isNotNull(second);
180 assert.isTrue(second.resumed);181 assert.isTrue(second.resumed);
181 assert.deepStrictEqual(182 assert.deepStrictEqual(
...@@ -188,12 +189,12 @@ describe("readTranscriptRecords resume", () => {...@@ -188,12 +189,12 @@ describe("readTranscriptRecords resume", () => {
188 it("re-parses from the start when the guard bytes no longer match", async () => {189 it("re-parses from the start when the guard bytes no longer match", async () => {
189 const path = NodePath.join(dir, "claude.jsonl");190 const path = NodePath.join(dir, "claude.jsonl");
190 await NodeFSP.writeFile(path, claudeLine(1, 5));191 await NodeFSP.writeFile(path, claudeLine(1, 5));
191 const first = await readTranscriptRecords(path, "claude");192 const first = await readTranscriptRecords(path, TEST_FORMATS.claude);
192 assert.isNotNull(first);193 assert.isNotNull(first);
193194
194 // Same path, larger size, different content: a replaced file, not growth.195 // Same path, larger size, different content: a replaced file, not growth.
195 await NodeFSP.writeFile(path, claudeLine(4, 13) + claudeLine(5, 17));196 await NodeFSP.writeFile(path, claudeLine(4, 13) + claudeLine(5, 17));
196 const second = await readTranscriptRecords(path, "claude", first.position);197 const second = await readTranscriptRecords(path, TEST_FORMATS.claude, first.position);
197 assert.isNotNull(second);198 assert.isNotNull(second);
198 assert.isFalse(second.resumed);199 assert.isFalse(second.resumed);
199 assert.deepStrictEqual(200 assert.deepStrictEqual(
...@@ -205,11 +206,11 @@ describe("readTranscriptRecords resume", () => {...@@ -205,11 +206,11 @@ describe("readTranscriptRecords resume", () => {
205 it("re-parses from the start when the file shrank below the resume point", async () => {206 it("re-parses from the start when the file shrank below the resume point", async () => {
206 const path = NodePath.join(dir, "claude.jsonl");207 const path = NodePath.join(dir, "claude.jsonl");
207 await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7));208 await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7));
208 const first = await readTranscriptRecords(path, "claude");209 const first = await readTranscriptRecords(path, TEST_FORMATS.claude);
209 assert.isNotNull(first);210 assert.isNotNull(first);
210211
211 await NodeFSP.writeFile(path, claudeLine(3, 11));212 await NodeFSP.writeFile(path, claudeLine(3, 11));
212 const second = await readTranscriptRecords(path, "claude", first.position);213 const second = await readTranscriptRecords(path, TEST_FORMATS.claude, first.position);
213 assert.isNotNull(second);214 assert.isNotNull(second);
214 assert.isFalse(second.resumed);215 assert.isFalse(second.resumed);
215 assert.deepStrictEqual(216 assert.deepStrictEqual(
...@@ -236,7 +237,7 @@ describe("readTranscriptRecords resume", () => {...@@ -236,7 +237,7 @@ describe("readTranscriptRecords resume", () => {
236 })}\n`;237 })}\n`;
237 await NodeFSP.writeFile(path, bigLine + claudeLine(2, 7));238 await NodeFSP.writeFile(path, bigLine + claudeLine(2, 7));
238239
239 const parsed = await readTranscriptRecords(path, "claude");240 const parsed = await readTranscriptRecords(path, TEST_FORMATS.claude);
240 assert.isNotNull(parsed);241 assert.isNotNull(parsed);
241 assert.deepStrictEqual(242 assert.deepStrictEqual(
242 parsed.records.map((record) => record.totals.outputTokens),243 parsed.records.map((record) => record.totals.outputTokens),
...@@ -245,7 +246,9 @@ describe("readTranscriptRecords resume", () => {...@@ -245,7 +246,9 @@ describe("readTranscriptRecords resume", () => {
245 });246 });
246247
247 it("returns null for an unreadable file", async () => {248 it("returns null for an unreadable file", async () => {
248 assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude"));249 assert.isNull(
250 await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), TEST_FORMATS.claude),
251 );
249 });252 });
250});253});
251254
apps/server/src/usage/usageTranscriptReader.ts+45-101
...@@ -19,23 +19,16 @@ import * as NodeFSP from "node:fs/promises";...@@ -19,23 +19,16 @@ import * as NodeFSP from "node:fs/promises";
19import * as NodePath from "node:path";19import * as NodePath from "node:path";
20import * as NodeStringDecoder from "node:string_decoder";20import * as NodeStringDecoder from "node:string_decoder";
2121
22import type { UsageProviderKind } from "@t3tools/contracts";22import type {
23 SelectedFields,
24 TranscriptUsageFormat,
25 UsageRecord,
26} from "@t3tools/provider-core/server/usage";
27import * as Option from "effect/Option";
28import * as Schema from "effect/Schema";
2329
24import { createTranscriptJsonReader } from "../project/AgentSessionJson.ts";30import { createTranscriptJsonReader } from "../project/AgentSessionJson.ts";
2531
26import {
27 initialCodexScanState,
28 mightCarryUsage,
29 parseClaudeLine,
30 parseClaudeRecord,
31 parseCodexLine,
32 parseCodexRecord,
33 parseGrokLine,
34 parseGrokRecord,
35 type CodexScanState,
36 type UsageRecord,
37} from "./usageTranscripts.ts";
38
39export interface TranscriptFile {32export interface TranscriptFile {
40 readonly path: string;33 readonly path: string;
41 readonly size: number;34 readonly size: number;
...@@ -60,8 +53,11 @@ export interface TranscriptParsePosition {...@@ -60,8 +53,11 @@ export interface TranscriptParsePosition {
60 readonly guardLength: number;53 readonly guardLength: number;
61 /** FNV-1a hash of that window. */54 /** FNV-1a hash of that window. */
62 readonly guardHash: number;55 readonly guardHash: number;
63 /** Codex reducer state as of `resumeOffset`; `null` for stateless providers. */56 /**
64 readonly codexState: CodexScanState | null;57 * The format's reducer state as of `resumeOffset`, encoded by its schema;
58 * `null` for stateless formats.
59 */
60 readonly state: unknown;
65}61}
6662
67export interface TranscriptParseResult {63export interface TranscriptParseResult {
...@@ -89,45 +85,7 @@ const NEWLINE = 0x0a;...@@ -89,45 +85,7 @@ const NEWLINE = 0x0a;
89const STAT_CONCURRENCY = 32;85const STAT_CONCURRENCY = 32;
90const CARRIAGE_RETURN = 0x0d;86const CARRIAGE_RETURN = 0x0d;
9187
92type SelectedFields = { readonly [key: string]: true | SelectedFields };88function selectUsageFields(fields: SelectedFields) {
93
94// Keep the fields consumed by usageTranscripts, including reducer state and
95// dedupe/cost metadata. A selected subtree (usage) keeps future token fields.
96const USAGE_FIELDS: Record<"claude" | "codex" | "grok", SelectedFields> = {
97 claude: {
98 type: true,
99 timestamp: true,
100 requestId: true,
101 sessionId: true,
102 costUSD: true,
103 message: { id: true, model: true, usage: true },
104 },
105 codex: {
106 type: true,
107 timestamp: true,
108 payload: {
109 type: true,
110 id: true,
111 session_id: true,
112 model: true,
113 thread_settings: { service_tier: true },
114 forked_from_id: true,
115 source: { subagent: { thread_spawn: { parent_thread_id: true } } },
116 info: { last_token_usage: true },
117 },
118 },
119 grok: {
120 timestamp: true,
121 params: {
122 sessionId: true,
123 _meta: { agentTimestampMs: true },
124 update: { sessionUpdate: true, prompt_id: true, usage: true },
125 },
126 },
127};
128
129function selectUsageFields(provider: UsageProviderKind) {
130 const fields = USAGE_FIELDS[provider === "codex" || provider === "grok" ? provider : "claude"];
131 return (path: ReadonlyArray<string | number | null>): boolean => {89 return (path: ReadonlyArray<string | number | null>): boolean => {
132 let selected: true | SelectedFields = fields;90 let selected: true | SelectedFields = fields;
133 for (const key of path) {91 for (const key of path) {
...@@ -257,14 +215,12 @@ async function guardMatches(...@@ -257,14 +215,12 @@ async function guardMatches(
257 * still match, so only appended lines are read; otherwise the whole file is215 * still match, so only appended lines are read; otherwise the whole file is
258 * re-parsed from the start and `resumed` reports `false`.216 * re-parsed from the start and `resumed` reports `false`.
259 *217 *
260 * Codex carries the active model on `turn_context` lines and the service tier218 * A stateful format resumes with the state saved at that position; a saved
261 * on `thread_settings_applied` lines. Neither holds usage of its own, but both219 * state its schema rejects re-parses the file whole.
262 * still have to pass through the reducer to keep attribution and pricing
263 * correct.
264 */220 */
265export async function readTranscriptRecords(221export async function readTranscriptRecords<State>(
266 filePath: string,222 filePath: string,
267 provider: UsageProviderKind,223 format: TranscriptUsageFormat<State>,
268 resumeFrom?: TranscriptParsePosition,224 resumeFrom?: TranscriptParsePosition,
269 options?: { readonly streamingThresholdBytes?: number },225 options?: { readonly streamingThresholdBytes?: number },
270): Promise<TranscriptParseResult | null> {226): Promise<TranscriptParseResult | null> {
...@@ -277,41 +233,31 @@ export async function readTranscriptRecords(...@@ -277,41 +233,31 @@ export async function readTranscriptRecords(
277 }233 }
278234
279 try {235 try {
280 let codexState = initialCodexScanState();236 const codec = format.state;
237 const decodeState = codec === undefined ? undefined : Schema.decodeUnknownOption(codec.schema);
238 const encodeState = codec === undefined ? undefined : Schema.encodeSync(codec.schema);
239 // Stateless formats never read their state argument.
240 let state = codec === undefined ? (undefined as State) : codec.initial();
281 let resumed = false;241 let resumed = false;
282 let start = 0;242 let start = 0;
243 const savedState =
244 resumeFrom === undefined || decodeState === undefined || resumeFrom.state === null
245 ? Option.none()
246 : decodeState(resumeFrom.state);
283 if (247 if (
284 resumeFrom !== undefined &&248 resumeFrom !== undefined &&
285 resumeFrom.resumeOffset > 0 &&249 resumeFrom.resumeOffset > 0 &&
286 (provider !== "codex" || resumeFrom.codexState !== null) &&250 (codec === undefined || Option.isSome(savedState)) &&
287 (await guardMatches(handle, resumeFrom))251 (await guardMatches(handle, resumeFrom))
288 ) {252 ) {
289 if (resumeFrom.codexState !== null) codexState = { ...resumeFrom.codexState };253 if (Option.isSome(savedState)) state = savedState.value;
290 start = resumeFrom.resumeOffset;254 start = resumeFrom.resumeOffset;
291 resumed = true;255 resumed = true;
292 }256 }
293257
294 const parseLine = (line: string, state: CodexScanState, out: UsageRecord[]): void => {258 const parseLine = (line: string, lineState: State, out: UsageRecord[]): void => {
295 if (provider === "codex") {259 if (!format.mightCarryUsage(line)) return;
296 if (260 for (const record of format.parseLine(line, lineState)) out.push(record);
297 !mightCarryUsage(line, provider) &&
298 !line.includes('"turn_context"') &&
299 !line.includes('"thread_settings_applied"') &&
300 !line.includes('"session_meta"')
301 ) {
302 return;
303 }
304 const record = parseCodexLine(line, state);
305 if (record !== null) out.push(record);
306 return;
307 }
308 if (!mightCarryUsage(line, provider)) return;
309 if (provider === "grok") {
310 for (const grokRecord of parseGrokLine(line)) out.push(grokRecord);
311 return;
312 }
313 const record = parseClaudeLine(line);
314 if (record !== null) out.push(record);
315 };261 };
316262
317 const toLineString = (lineBuffer: Buffer): string => {263 const toLineString = (lineBuffer: Buffer): string => {
...@@ -331,7 +277,7 @@ export async function readTranscriptRecords(...@@ -331,7 +277,7 @@ export async function readTranscriptRecords(
331 let pendingBytes = 0;277 let pendingBytes = 0;
332 let streaming: ReturnType<typeof createTranscriptJsonReader> | undefined;278 let streaming: ReturnType<typeof createTranscriptJsonReader> | undefined;
333 let decoder: NodeStringDecoder.StringDecoder | undefined;279 let decoder: NodeStringDecoder.StringDecoder | undefined;
334 const selectPath = selectUsageFields(provider);280 const selectPath = selectUsageFields(format.selectFields);
335281
336 const append = (segment: Buffer) => {282 const append = (segment: Buffer) => {
337 if (!streaming && pendingBytes + segment.length <= streamingThresholdBytes) {283 if (!streaming && pendingBytes + segment.length <= streamingThresholdBytes) {
...@@ -350,25 +296,17 @@ export async function readTranscriptRecords(...@@ -350,25 +296,17 @@ export async function readTranscriptRecords(
350 }296 }
351 streaming.write(decoder!.write(segment));297 streaming.write(decoder!.write(segment));
352 };298 };
353 const finish = (state: CodexScanState, out: UsageRecord[]) => {299 const finish = (lineState: State, out: UsageRecord[]) => {
354 if (streaming) {300 if (streaming) {
355 streaming.write(decoder!.end());301 streaming.write(decoder!.end());
356 const projected = streaming.finish();302 const projected = streaming.finish();
357 if (provider === "grok") {303 for (const record of format.parseProjected(projected, lineState)) out.push(record);
358 out.push(...parseGrokRecord(projected));
359 } else {
360 const record =
361 provider === "codex"
362 ? parseCodexRecord(projected, state)
363 : parseClaudeRecord(projected);
364 if (record !== null) out.push(record);
365 }
366 } else if (pendingBytes > 0) {304 } else if (pendingBytes > 0) {
367 const line =305 const line =
368 pendingChunks.length === 1306 pendingChunks.length === 1
369 ? pendingChunks[0]!307 ? pendingChunks[0]!
370 : Buffer.concat(pendingChunks, pendingBytes);308 : Buffer.concat(pendingChunks, pendingBytes);
371 parseLine(toLineString(line), state, out);309 parseLine(toLineString(line), lineState, out);
372 }310 }
373 pendingChunks = [];311 pendingChunks = [];
374 pendingBytes = 0;312 pendingBytes = 0;
...@@ -391,10 +329,10 @@ export async function readTranscriptRecords(...@@ -391,10 +329,10 @@ export async function readTranscriptRecords(
391 // Most lines fit in the current chunk. Avoid buffering/streaming329 // Most lines fit in the current chunk. Avoid buffering/streaming
392 // machinery on this hot path.330 // machinery on this hot path.
393 if (!streaming && pendingBytes === 0) {331 if (!streaming && pendingBytes === 0) {
394 parseLine(toLineString(chunk.subarray(lineStart, newlineIndex)), codexState, records);332 parseLine(toLineString(chunk.subarray(lineStart, newlineIndex)), state, records);
395 } else {333 } else {
396 append(chunk.subarray(lineStart, newlineIndex));334 append(chunk.subarray(lineStart, newlineIndex));
397 finish(codexState, records);335 finish(state, records);
398 }336 }
399 lineStart = newlineIndex + 1;337 lineStart = newlineIndex + 1;
400 resumeOffset = scanOffset + lineStart;338 resumeOffset = scanOffset + lineStart;
...@@ -402,8 +340,14 @@ export async function readTranscriptRecords(...@@ -402,8 +340,14 @@ export async function readTranscriptRecords(
402 scanOffset += chunk.length;340 scanOffset += chunk.length;
403 }341 }
404342
343 // The unfinished tail parses against a copy, so the saved state stays at
344 // `resumeOffset` and the next scan replays the tail from it.
345 const encodedState = encodeState === undefined ? null : encodeState(state);
405 const tailRecords: UsageRecord[] = [];346 const tailRecords: UsageRecord[] = [];
406 finish({ ...codexState }, tailRecords);347 finish(
348 decodeState === undefined ? state : Option.getOrThrow(decodeState(encodedState)),
349 tailRecords,
350 );
407351
408 const guardLength = Math.min(GUARD_LENGTH, resumeOffset);352 const guardLength = Math.min(GUARD_LENGTH, resumeOffset);
409 let guardHash = 0;353 let guardHash = 0;
...@@ -420,7 +364,7 @@ export async function readTranscriptRecords(...@@ -420,7 +364,7 @@ export async function readTranscriptRecords(
420 resumeOffset,364 resumeOffset,
421 guardLength,365 guardLength,
422 guardHash,366 guardHash,
423 codexState: provider === "codex" ? codexState : null,367 state: encodedState,
424 },368 },
425 resumed,369 resumed,
426 };370 };
apps/server/src/usage/usageTranscriptStreaming.test.ts+6-2
...@@ -5,6 +5,7 @@ import * as NodePath from "node:path";...@@ -5,6 +5,7 @@ import * as NodePath from "node:path";
55
6import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";6import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
77
8import { TEST_FORMATS } from "./usageTestFormats.ts";
8import {9import {
9 readTranscriptRecords as readWithDefaultThreshold,10 readTranscriptRecords as readWithDefaultThreshold,
10 type TranscriptParsePosition,11 type TranscriptParsePosition,
...@@ -16,7 +17,10 @@ const readTranscriptRecords = (...@@ -16,7 +17,10 @@ const readTranscriptRecords = (
16 path: string,17 path: string,
17 provider: "claude" | "codex" | "grok",18 provider: "claude" | "codex" | "grok",
18 position?: TranscriptParsePosition,19 position?: TranscriptParsePosition,
19) => readWithDefaultThreshold(path, provider, position, { streamingThresholdBytes: 256 * 1024 });20) =>
21 readWithDefaultThreshold(path, TEST_FORMATS[provider], position, {
22 streamingThresholdBytes: 256 * 1024,
23 });
2024
21let dir: string;25let dir: string;
22beforeEach(async () => {26beforeEach(async () => {
...@@ -144,7 +148,7 @@ describe("large usage records", () => {...@@ -144,7 +148,7 @@ describe("large usage records", () => {
144 );148 );
145 const actual = await scan(large, provider);149 const actual = await scan(large, provider);
146 expect(actual.records).toEqual(expected.records);150 expect(actual.records).toEqual(expected.records);
147 expect(actual.position.codexState).toEqual(expected.position.codexState);151 expect(actual.position.state).toEqual(expected.position.state);
148 }152 }
149 },153 },
150 );154 );
apps/server/src/usage/usageTranscripts.test.ts deleted-598
...@@ -1,598 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 GROK_COST_USD_TICKS_PER_DOLLAR,
5 initialCodexScanState,
6 parseClaudeLine,
7 parseCodexLine,
8 parseGrokLine,
9 totalTokens,
10} from "./usageTranscripts.ts";
11
12/** Shaped after a real Claude Code assistant record. */
13function claudeLine(overrides: {
14 messageId: string;
15 contentType: string;
16 model?: string;
17 outputTokens?: number;
18 speed?: string;
19}): string {
20 return JSON.stringify({
21 type: "assistant",
22 timestamp: "2026-08-07T04:05:13.944Z",
23 sessionId: "5a128faa-8253-489e-b935-6c08e8e670c0",
24 cwd: "/home/theo/project",
25 message: {
26 id: overrides.messageId,
27 role: "assistant",
28 model: overrides.model ?? "claude-fable-5",
29 content: [{ type: overrides.contentType }],
30 usage: {
31 input_tokens: 2,
32 cache_creation_input_tokens: 66818,
33 cache_read_input_tokens: 1000,
34 output_tokens: overrides.outputTokens ?? 286,
35 ...(overrides.speed === undefined ? {} : { speed: overrides.speed }),
36 },
37 },
38 });
39}
40
41describe("parseClaudeLine", () => {
42 it("extracts token totals and a dedupe key", () => {
43 const record = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" }));
44
45 expect(record).not.toBeNull();
46 expect(record?.provider).toBe("claude");
47 expect(record?.model).toBe("claude-fable-5");
48 expect(record?.totals).toEqual({
49 uncachedInputTokens: 2,
50 cachedInputTokens: 1000,
51 cacheCreationTokens: 66818,
52 outputTokens: 286,
53 reasoningTokens: 0,
54 });
55 expect(record?.dedupeKey).toBe("msg_1:");
56 expect(record?.speed).toBe("standard");
57 });
58
59 it("marks fast-mode requests", () => {
60 const line = (speed: string) =>
61 parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed }));
62
63 expect(line("fast")?.speed).toBe("fast");
64 expect(line("standard")?.speed).toBe("standard");
65 });
66
67 it("gives every content block of one message the same dedupe key", () => {
68 // T3 Code writes one record per content block, each repeating the parent
69 // message's full usage. Summing them would overcount ~2.4x on real data.
70 const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" }));
71 const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" }));
72
73 expect(text?.dedupeKey).toBe(toolUse?.dedupeKey);
74 expect(text?.totals).toEqual(toolUse?.totals);
75 });
76
77 it("ignores records that are not assistant messages", () => {
78 expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull();
79 expect(parseClaudeLine("not json")).toBeNull();
80 });
81});
82
83describe("parseCodexLine", () => {
84 const sessionMeta = JSON.stringify({
85 type: "session_meta",
86 timestamp: "2026-08-01T05:17:41.289Z",
87 payload: { type: "session_meta", id: "019fbbc1-b12c-7360-a685-28c181f0025f" },
88 });
89 const turnContext = JSON.stringify({
90 type: "turn_context",
91 timestamp: "2026-08-01T05:17:42.694Z",
92 payload: { type: "turn_context", model: "gpt-5.6-sol" },
93 });
94 const tokenCount = (inputTokens: number, cached: number, output: number, reasoning: number) =>
95 JSON.stringify({
96 type: "event_msg",
97 timestamp: "2026-08-01T05:17:49.919Z",
98 payload: {
99 type: "token_count",
100 info: {
101 last_token_usage: {
102 input_tokens: inputTokens,
103 cached_input_tokens: cached,
104 cache_write_input_tokens: 0,
105 output_tokens: output,
106 reasoning_output_tokens: reasoning,
107 },
108 },
109 },
110 });
111
112 it("attributes usage to the model from the preceding turn context", () => {
113 const state = initialCodexScanState();
114 parseCodexLine(sessionMeta, state);
115 parseCodexLine(turnContext, state);
116 const record = parseCodexLine(tokenCount(19239, 11008, 299, 116), state);
117
118 expect(record?.provider).toBe("codex");
119 expect(record?.model).toBe("gpt-5.6-sol");
120 expect(record?.sessionId).toBe("019fbbc1-b12c-7360-a685-28c181f0025f");
121 // Codex reports input_tokens inclusive of the cached portion.
122 expect(record?.totals.uncachedInputTokens).toBe(19239 - 11008);
123 expect(record?.totals.cachedInputTokens).toBe(11008);
124 expect(record?.totals.reasoningTokens).toBe(116);
125 });
126
127 it("skips a repeated token_count so deltas are not double counted", () => {
128 const state = initialCodexScanState();
129 parseCodexLine(turnContext, state);
130 const first = parseCodexLine(tokenCount(100, 0, 10, 0), state);
131 const repeat = parseCodexLine(tokenCount(100, 0, 10, 0), state);
132
133 expect(first).not.toBeNull();
134 expect(repeat).toBeNull();
135 });
136
137 it("drops usage that arrives before any model is known", () => {
138 const state = initialCodexScanState();
139 expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull();
140 });
141
142 it("does not let a pre-model event poison the duplicate signature", () => {
143 // A token_count before its turn_context is dropped; the identical event
144 // re-emitted once the model is known must still be counted.
145 const state = initialCodexScanState();
146 expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull();
147 parseCodexLine(turnContext, state);
148 expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).not.toBeNull();
149 });
150
151 it("carries the service tier from the latest thread settings", () => {
152 const settings = (thread_settings: Record<string, unknown>) =>
153 JSON.stringify({
154 type: "event_msg",
155 timestamp: "2026-08-01T05:17:42.000Z",
156 payload: { type: "thread_settings_applied", thread_settings },
157 });
158 const state = initialCodexScanState();
159 parseCodexLine(turnContext, state);
160 const speedAfter = (line: string, output: number) => {
161 parseCodexLine(line, state);
162 return parseCodexLine(tokenCount(100, 0, output, 0), state)?.speed;
163 };
164
165 expect(parseCodexLine(tokenCount(100, 0, 1, 0), state)?.speed).toBe("standard");
166 expect(speedAfter(settings({ service_tier: "ultrafast" }), 2)).toBe("ultrafast");
167 expect(speedAfter(settings({ service_tier: "priority" }), 3)).toBe("fast");
168 // Codex omits the field when no tier was requested.
169 expect(speedAfter(settings({ model: "gpt-6-astra" }), 4)).toBe("standard");
170 });
171
172 // A forked/subagent rollout opens with the parent's history copied in and
173 // every line re-stamped to the fork instant, then the ancestors' session
174 // metas. Counting those again multiplied usage ~1.85x on real data (#5758).
175 describe("forked rollouts", () => {
176 const meta = (overrides: {
177 id: string;
178 timestamp: string;
179 forkedFromId?: string;
180 spawnParentId?: string;
181 }) =>
182 JSON.stringify({
183 type: "session_meta",
184 timestamp: overrides.timestamp,
185 payload: {
186 type: "session_meta",
187 id: overrides.id,
188 ...(overrides.forkedFromId === undefined
189 ? {}
190 : { forked_from_id: overrides.forkedFromId }),
191 ...(overrides.spawnParentId === undefined
192 ? {}
193 : {
194 source: {
195 subagent: { thread_spawn: { parent_thread_id: overrides.spawnParentId } },
196 },
197 }),
198 },
199 });
200 const stamped = (timestamp: string, line: string) => {
201 const parsed = JSON.parse(line) as { timestamp: string };
202 parsed.timestamp = timestamp;
203 return JSON.stringify(parsed);
204 };
205
206 it("keeps the child session id over copied ancestor metas", () => {
207 const state = initialCodexScanState();
208 parseCodexLine(meta({ id: "child", timestamp: "2026-08-01T05:00:00.000Z" }), state);
209 parseCodexLine(meta({ id: "parent", timestamp: "2026-08-01T05:00:00.000Z" }), state);
210 parseCodexLine(turnContext, state);
211 const record = parseCodexLine(tokenCount(100, 0, 10, 0), state);
212
213 expect(record?.sessionId).toBe("child");
214 });
215
216 it("drops the re-stamped copied burst and keeps the first real event", () => {
217 const state = initialCodexScanState();
218 const forkInstant = "2026-08-01T05:00:00.000Z";
219 parseCodexLine(meta({ id: "child", timestamp: forkInstant, forkedFromId: "parent" }), state);
220 parseCodexLine(meta({ id: "parent", timestamp: forkInstant }), state);
221 parseCodexLine(stamped(forkInstant, turnContext), state);
222
223 // Copied history: written in one burst at the fork instant.
224 expect(
225 parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state),
226 ).toBeNull();
227 expect(
228 parseCodexLine(stamped("2026-08-01T05:00:00.002Z", tokenCount(200, 0, 20, 0)), state),
229 ).toBeNull();
230
231 // The child's first genuine turn lands seconds later and must count.
232 const real = parseCodexLine(
233 stamped("2026-08-01T05:00:06.000Z", tokenCount(300, 0, 30, 0)),
234 state,
235 );
236 expect(real).not.toBeNull();
237 expect(real?.totals.outputTokens).toBe(30);
238
239 // Suppression never restarts, even for closely spaced later events.
240 const next = parseCodexLine(
241 stamped("2026-08-01T05:00:06.100Z", tokenCount(400, 0, 40, 0)),
242 state,
243 );
244 expect(next).not.toBeNull();
245 });
246
247 it("recognizes subagent spawns without forked_from_id", () => {
248 const state = initialCodexScanState();
249 const spawnInstant = "2026-08-01T05:00:00.000Z";
250 parseCodexLine(
251 meta({ id: "child", timestamp: spawnInstant, spawnParentId: "parent" }),
252 state,
253 );
254 parseCodexLine(stamped(spawnInstant, turnContext), state);
255 expect(
256 parseCodexLine(stamped("2026-08-01T05:00:00.001Z", tokenCount(100, 0, 10, 0)), state),
257 ).toBeNull();
258 });
259
260 it("does not suppress anything in a rollout that is not a fork", () => {
261 const state = initialCodexScanState();
262 parseCodexLine(meta({ id: "root", timestamp: "2026-08-01T05:00:00.000Z" }), state);
263 parseCodexLine(stamped("2026-08-01T05:00:00.100Z", turnContext), state);
264 const record = parseCodexLine(
265 stamped("2026-08-01T05:00:00.200Z", tokenCount(100, 0, 10, 0)),
266 state,
267 );
268 expect(record).not.toBeNull();
269 });
270 });
271});
272
273describe("totalTokens", () => {
274 it("does not add reasoning on top of output", () => {
275 expect(
276 totalTokens({
277 uncachedInputTokens: 10,
278 cachedInputTokens: 20,
279 cacheCreationTokens: 30,
280 outputTokens: 40,
281 reasoningTokens: 25,
282 }),
283 ).toBe(100);
284 });
285});
286
287describe("parseGrokLine", () => {
288 /** Shaped after a real Grok Build `turn_completed` session update. */
289 function turnCompleted(overrides?: {
290 sessionId?: string;
291 promptId?: string;
292 timestamp?: number;
293 agentTimestampMs?: number;
294 usage?: Record<string, unknown>;
295 modelUsage?: Record<string, Record<string, unknown>> | null;
296 }): string {
297 const modelUsage =
298 overrides && "modelUsage" in overrides
299 ? overrides.modelUsage
300 : {
301 "grok-4.5-build": {
302 inputTokens: 20_272,
303 outputTokens: 272,
304 totalTokens: 20_544,
305 cachedReadTokens: 11_264,
306 cacheCreationTokens: 0,
307 reasoningTokens: 180,
308 costUsdTicks: 230_272_000,
309 },
310 };
311
312 return JSON.stringify({
313 timestamp: overrides?.timestamp ?? 1_786_372_566,
314 method: "_x.ai/session/update",
315 params: {
316 sessionId: overrides?.sessionId ?? "019fec1a-12f7-72f2-9b1f-7778a00aea3c",
317 update: {
318 sessionUpdate: "turn_completed",
319 prompt_id: overrides?.promptId ?? "prompt-1",
320 stop_reason: "end_turn",
321 usage: {
322 inputTokens: 20_272,
323 outputTokens: 272,
324 totalTokens: 20_544,
325 cachedReadTokens: 11_264,
326 cacheCreationTokens: 0,
327 reasoningTokens: 180,
328 costUsdTicks: 230_272_000,
329 ...(modelUsage === null ? {} : { modelUsage }),
330 ...overrides?.usage,
331 },
332 },
333 _meta: {
334 eventId: "event-1",
335 agentTimestampMs: overrides?.agentTimestampMs ?? 1_786_372_566_485,
336 },
337 },
338 });
339 }
340
341 it("extracts per-model totals and provider-reported cost ticks", () => {
342 const records = parseGrokLine(turnCompleted());
343
344 expect(records).toHaveLength(1);
345 const [record] = records;
346 expect(record?.provider).toBe("grok");
347 expect(record?.model).toBe("grok-4.5-build");
348 expect(record?.sessionId).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c");
349 expect(record?.timestampMs).toBe(1_786_372_566_485);
350 expect(record?.totals).toEqual({
351 uncachedInputTokens: 20_272 - 11_264,
352 cachedInputTokens: 11_264,
353 cacheCreationTokens: 0,
354 outputTokens: 272,
355 reasoningTokens: 180,
356 });
357 expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12);
358 expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok-4.5-build");
359 });
360
361 it("emits one record per model when modelUsage has several entries", () => {
362 const records = parseGrokLine(
363 turnCompleted({
364 modelUsage: {
365 "grok-4.5": {
366 inputTokens: 1000,
367 outputTokens: 50,
368 cachedReadTokens: 400,
369 reasoningTokens: 20,
370 costUsdTicks: 50_000_000,
371 },
372 "grok-composer-2.5-fast": {
373 inputTokens: 200,
374 outputTokens: 30,
375 cachedReadTokens: 100,
376 reasoningTokens: 0,
377 costUsdTicks: 10_000_000,
378 },
379 },
380 }),
381 );
382
383 expect(records.map((record) => record.model).toSorted()).toEqual([
384 "grok-4.5",
385 "grok-composer-2.5-fast",
386 ]);
387 expect(records.every((record) => record.provider === "grok")).toBe(true);
388 expect(records.find((record) => record.model === "grok-4.5")?.reportedCostUsd).toBeCloseTo(
389 0.005,
390 12,
391 );
392 });
393
394 it("inherits top-level cost ticks for a single model without its own ticks", () => {
395 const records = parseGrokLine(
396 turnCompleted({
397 modelUsage: {
398 "grok-4.5-build": {
399 inputTokens: 1000,
400 outputTokens: 10,
401 cachedReadTokens: 0,
402 reasoningTokens: 0,
403 },
404 },
405 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
406 }),
407 );
408
409 expect(records).toHaveLength(1);
410 expect(records[0]?.reportedCostUsd).toBe(1);
411 });
412
413 it("falls back to a generic grok model when modelUsage is absent", () => {
414 const records = parseGrokLine(turnCompleted({ modelUsage: null }));
415
416 expect(records).toHaveLength(1);
417 const [record] = records;
418 expect(record?.provider).toBe("grok");
419 expect(record?.model).toBe("grok");
420 expect(record?.totals).toEqual({
421 uncachedInputTokens: 20_272 - 11_264,
422 cachedInputTokens: 11_264,
423 cacheCreationTokens: 0,
424 outputTokens: 272,
425 reasoningTokens: 180,
426 });
427 expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12);
428 expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok");
429 });
430
431 it("pro-rates top-level cost ticks across multi-model turns without per-model ticks", () => {
432 const records = parseGrokLine(
433 turnCompleted({
434 modelUsage: {
435 "grok-4.5": {
436 inputTokens: 300,
437 outputTokens: 0,
438 cachedReadTokens: 0,
439 reasoningTokens: 0,
440 },
441 "grok-composer-2.5-fast": {
442 inputTokens: 100,
443 outputTokens: 0,
444 cachedReadTokens: 0,
445 reasoningTokens: 0,
446 },
447 },
448 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
449 }),
450 );
451
452 expect(records).toHaveLength(2);
453 const byModel = Object.fromEntries(records.map((record) => [record.model, record]));
454 expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12);
455 expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12);
456 const sum =
457 (byModel["grok-4.5"]?.reportedCostUsd ?? 0) +
458 (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0);
459 expect(sum).toBeCloseTo(1, 12);
460 });
461
462 it("pro-rates aggregate cost when a zero-token sibling carries costUsdTicks: 0", () => {
463 const records = parseGrokLine(
464 turnCompleted({
465 modelUsage: {
466 "grok-4.5": {
467 inputTokens: 300,
468 outputTokens: 0,
469 cachedReadTokens: 0,
470 reasoningTokens: 0,
471 },
472 "grok-composer-2.5-fast": {
473 inputTokens: 100,
474 outputTokens: 0,
475 cachedReadTokens: 0,
476 reasoningTokens: 0,
477 },
478 "empty-sibling": {
479 inputTokens: 0,
480 outputTokens: 0,
481 cachedReadTokens: 0,
482 reasoningTokens: 0,
483 costUsdTicks: 0,
484 },
485 },
486 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
487 }),
488 );
489
490 expect(records).toHaveLength(2);
491 expect(records.every((record) => record.model !== "empty-sibling")).toBe(true);
492 const byModel = Object.fromEntries(records.map((record) => [record.model, record]));
493 expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12);
494 expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12);
495 const sum =
496 (byModel["grok-4.5"]?.reportedCostUsd ?? 0) +
497 (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0);
498 expect(sum).toBeCloseTo(1, 12);
499 });
500
501 it("allocates leftover aggregate ticks to models that omit per-model ticks", () => {
502 const records = parseGrokLine(
503 turnCompleted({
504 modelUsage: {
505 "grok-4.5": {
506 inputTokens: 300,
507 outputTokens: 0,
508 cachedReadTokens: 0,
509 reasoningTokens: 0,
510 costUsdTicks: 0.4 * GROK_COST_USD_TICKS_PER_DOLLAR,
511 },
512 "grok-composer-2.5-fast": {
513 inputTokens: 100,
514 outputTokens: 0,
515 cachedReadTokens: 0,
516 reasoningTokens: 0,
517 },
518 },
519 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
520 }),
521 );
522
523 expect(records).toHaveLength(2);
524 const byModel = Object.fromEntries(records.map((record) => [record.model, record]));
525 expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.4, 12);
526 expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.6, 12);
527 const sum =
528 (byModel["grok-4.5"]?.reportedCostUsd ?? 0) +
529 (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0);
530 expect(sum).toBeCloseTo(1, 12);
531 });
532
533 it("does not invent a colliding dedupe key when prompt_id is missing", () => {
534 const line = JSON.stringify({
535 timestamp: 1_786_372_566,
536 method: "_x.ai/session/update",
537 params: {
538 sessionId: "s1",
539 update: {
540 sessionUpdate: "turn_completed",
541 usage: {
542 inputTokens: 10,
543 outputTokens: 2,
544 modelUsage: {
545 "grok-4.5": { inputTokens: 10, outputTokens: 2 },
546 },
547 },
548 },
549 },
550 });
551
552 expect(parseGrokLine(line)[0]?.dedupeKey).toBeNull();
553 });
554
555 it("ignores non-turn lines and empty usage", () => {
556 expect(parseGrokLine(JSON.stringify({ method: "session/update", params: {} }))).toEqual([]);
557 expect(parseGrokLine("not json")).toEqual([]);
558 expect(
559 parseGrokLine(
560 turnCompleted({
561 modelUsage: {
562 "grok-4.5-build": {
563 inputTokens: 0,
564 outputTokens: 0,
565 cachedReadTokens: 0,
566 reasoningTokens: 0,
567 costUsdTicks: 0,
568 },
569 },
570 }),
571 ),
572 ).toEqual([]);
573 });
574
575 it("falls back to the outer unix-seconds timestamp when agent meta is missing", () => {
576 const line = JSON.stringify({
577 timestamp: 1_786_372_566,
578 method: "_x.ai/session/update",
579 params: {
580 sessionId: "s1",
581 update: {
582 sessionUpdate: "turn_completed",
583 prompt_id: "p1",
584 usage: {
585 inputTokens: 10,
586 outputTokens: 2,
587 modelUsage: {
588 "grok-4.5": { inputTokens: 10, outputTokens: 2 },
589 },
590 },
591 },
592 },
593 });
594
595 const records = parseGrokLine(line);
596 expect(records[0]?.timestampMs).toBe(1_786_372_566_000);
597 });
598});
apps/server/src/usage/usageTranscripts.ts deleted-530
...@@ -1,530 +0,0 @@
1/**
2 * Pure parsers for the provider CLIs' on-disk session transcripts.
3 *
4 * Each parser is a line-at-a-time reducer so callers can stream large files
5 * without materialising them. None of them touch the filesystem.
6 *
7 * @module usageTranscripts
8 */
9import type { UsageProviderKind, UsageTokenTotals } from "@t3tools/contracts";
10
11/**
12 * Billing speed of a request. Faster speeds bill at a model-specific premium.
13 * Claude fast mode and Codex `priority` are `fast`; Codex `ultrafast` is its
14 * own, more expensive tier.
15 */
16export type UsageSpeed = "standard" | "fast" | "ultrafast";
17
18export interface UsageRecord {
19 readonly provider: UsageProviderKind;
20 readonly timestampMs: number;
21 readonly model: string;
22 /**
23 * Rate-table key when the provider's display name carries tiers the table
24 * does not know, such as Cursor's `claude-opus-5-5-high`. Defaults to `model`.
25 */
26 readonly rateModel?: string;
27 readonly sessionId: string;
28 readonly totals: UsageTokenTotals;
29 readonly reportedCostUsd: number | null;
30 /** Only Claude Code and Codex record a speed; other providers are `standard`. */
31 readonly speed: UsageSpeed;
32 /**
33 * Key for cross-file de-duplication, or `null` when the record is inherently
34 * unique and needs no dedup.
35 */
36 readonly dedupeKey: string | null;
37}
38
39const EMPTY_TOTALS: UsageTokenTotals = {
40 uncachedInputTokens: 0,
41 cachedInputTokens: 0,
42 cacheCreationTokens: 0,
43 outputTokens: 0,
44 reasoningTokens: 0,
45};
46
47function int(value: unknown): number {
48 return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
49}
50
51function parseTimestampMs(value: unknown): number | null {
52 if (typeof value !== "string") return null;
53 const parsed = Date.parse(value);
54 return Number.isNaN(parsed) ? null : parsed;
55}
56
57export function totalTokens(totals: UsageTokenTotals): number {
58 // reasoningTokens is a subset of outputTokens and must not be added again.
59 return (
60 totals.uncachedInputTokens +
61 totals.cachedInputTokens +
62 totals.cacheCreationTokens +
63 totals.outputTokens
64 );
65}
66
67/**
68 * Cheap substring gate applied before `JSON.parse`.
69 *
70 * Transcripts are mostly tool output; only a minority of lines carry usage. On
71 * a 30-day window this skips roughly half the lines outright and is worth about
72 * an order of magnitude.
73 */
74export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean {
75 if (provider === "claude") return line.includes('"usage"');
76 if (provider === "grok") return line.includes('"turn_completed"');
77 return line.includes('"token_count"');
78}
79
80/**
81 * Grok reports cost in integer ticks where `1 USD = 10^10` ticks. See Grok
82 * headless `total_cost_usd_ticks`. Convert to dollars for pricing.
83 */
84export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000;
85
86function grokCostTicksToUsd(ticks: unknown): number | null {
87 if (typeof ticks !== "number" || !Number.isFinite(ticks) || ticks < 0) return null;
88 return ticks / GROK_COST_USD_TICKS_PER_DOLLAR;
89}
90
91/* -------------------------------------------------------------------------- */
92/* Claude Code */
93/* -------------------------------------------------------------------------- */
94
95/**
96 * Parses one line of a Claude Code transcript.
97 *
98 * T3 Code writes one record per assistant *content block*, and every one of
99 * those records repeats the same complete `usage` object for the parent
100 * message. Summing them overcounts by roughly 2.4x on a real workload, so the
101 * caller must drop repeats by `dedupeKey` and keep the first.
102 */
103export function parseClaudeLine(line: string): UsageRecord | null {
104 let parsed: unknown;
105 try {
106 parsed = JSON.parse(line);
107 } catch {
108 return null;
109 }
110 return parseClaudeRecord(parsed);
111}
112
113export function parseClaudeRecord(parsed: unknown): UsageRecord | null {
114 if (typeof parsed !== "object" || parsed === null) return null;
115
116 const record = parsed as Record<string, unknown>;
117 if (record["type"] !== "assistant") return null;
118
119 const message = record["message"];
120 if (typeof message !== "object" || message === null) return null;
121 const messageRecord = message as Record<string, unknown>;
122
123 const usage = messageRecord["usage"];
124 if (typeof usage !== "object" || usage === null) return null;
125 const usageRecord = usage as Record<string, unknown>;
126
127 const timestampMs = parseTimestampMs(record["timestamp"]);
128 if (timestampMs === null) return null;
129
130 const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : "";
131 if (model.length === 0) return null;
132
133 const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null;
134 const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null;
135 // Matches ccusage: prefer the message/request pair, fall back to whichever
136 // half exists. Records with neither cannot be de-duplicated.
137 const dedupeKey =
138 messageId === null && requestId === null ? null : `${messageId ?? ""}:${requestId ?? ""}`;
139
140 const cost = record["costUSD"];
141
142 return {
143 provider: "claude",
144 timestampMs,
145 model,
146 sessionId: typeof record["sessionId"] === "string" ? record["sessionId"] : "",
147 totals: {
148 uncachedInputTokens: int(usageRecord["input_tokens"]),
149 cachedInputTokens: int(usageRecord["cache_read_input_tokens"]),
150 cacheCreationTokens: int(usageRecord["cache_creation_input_tokens"]),
151 outputTokens: int(usageRecord["output_tokens"]),
152 // Anthropic folds thinking tokens into output and does not break them out.
153 reasoningTokens: 0,
154 },
155 reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
156 speed: usageRecord["speed"] === "fast" ? "fast" : "standard",
157 dedupeKey,
158 };
159}
160
161/* -------------------------------------------------------------------------- */
162/* Codex */
163/* -------------------------------------------------------------------------- */
164
165/**
166 * Rolling state for a single Codex rollout file.
167 *
168 * Codex `token_count` events carry no model or service tier, so both are
169 * carried forward: the model from the most recent `turn_context`, the tier from
170 * the most recent `thread_settings_applied`. Sessions that switch either
171 * mid-run attribute correctly from the switch onward.
172 */
173export interface CodexScanState {
174 model: string;
175 speed: UsageSpeed;
176 sessionId: string;
177 lastUsageSignature: string | null;
178 sawSessionMeta: boolean;
179 /** While true, leading usage events are re-stamped copies of parent history. */
180 suppressingForkCopies: boolean;
181 forkCopyAnchorMs: number;
182}
183
184export function initialCodexScanState(): CodexScanState {
185 return {
186 model: "",
187 speed: "standard",
188 sessionId: "",
189 lastUsageSignature: null,
190 sawSessionMeta: false,
191 suppressingForkCopies: false,
192 forkCopyAnchorMs: 0,
193 };
194}
195
196/**
197 * A forked or subagent rollout opens with the parent's full history copied in,
198 * every line re-stamped to the fork instant. Those copies are written in one
199 * synchronous burst (observed gaps 0-40ms), while the child's first genuine
200 * usage event only lands after a real model turn (observed 5s+). One second of
201 * separation splits the two cleanly; `ccusage` uses the same threshold.
202 */
203const FORK_COPY_MAX_GAP_MS = 1000;
204
205/** Whether a `session_meta` payload marks the rollout as a fork or subagent. */
206function isForkedSessionMeta(payload: Record<string, unknown>): boolean {
207 if (typeof payload["forked_from_id"] === "string") return true;
208 const source = payload["source"];
209 if (typeof source !== "object" || source === null) return false;
210 const subagent = (source as Record<string, unknown>)["subagent"];
211 if (typeof subagent !== "object" || subagent === null) return false;
212 const spawn = (subagent as Record<string, unknown>)["thread_spawn"];
213 if (typeof spawn !== "object" || spawn === null) return false;
214 return typeof (spawn as Record<string, unknown>)["parent_thread_id"] === "string";
215}
216
217/**
218 * Feeds one line of a Codex rollout into `state`, returning a record when the
219 * line was a usage event.
220 *
221 * Deltas come from `last_token_usage`. Summing those across a session
222 * reconciles with the session's final `total_token_usage`, provided
223 * consecutive duplicate events are dropped, which this does.
224 */
225export function parseCodexLine(line: string, state: CodexScanState): UsageRecord | null {
226 let parsed: unknown;
227 try {
228 parsed = JSON.parse(line);
229 } catch {
230 return null;
231 }
232 return parseCodexRecord(parsed, state);
233}
234
235export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | null {
236 if (typeof parsed !== "object" || parsed === null) return null;
237
238 const record = parsed as Record<string, unknown>;
239 const payload = record["payload"];
240 if (typeof payload !== "object" || payload === null) return null;
241 const payloadRecord = payload as Record<string, unknown>;
242 const payloadType = payloadRecord["type"];
243
244 if (record["type"] === "session_meta") {
245 // Only the first meta describes this file's own session. A forked rollout
246 // repeats the ancestors' metas right after it; letting those through would
247 // reassign every subsequent record to an ancestor session.
248 if (state.sawSessionMeta) return null;
249 state.sawSessionMeta = true;
250 const id = payloadRecord["id"] ?? payloadRecord["session_id"];
251 if (typeof id === "string") state.sessionId = id;
252 const metaTimestampMs = parseTimestampMs(record["timestamp"]);
253 if (metaTimestampMs !== null && isForkedSessionMeta(payloadRecord)) {
254 state.suppressingForkCopies = true;
255 state.forkCopyAnchorMs = metaTimestampMs;
256 }
257 return null;
258 }
259
260 if (record["type"] === "turn_context") {
261 if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"];
262 return null;
263 }
264
265 if (payloadType === "thread_settings_applied") {
266 const settings = payloadRecord["thread_settings"];
267 if (typeof settings === "object" && settings !== null) {
268 state.speed = codexSpeed((settings as Record<string, unknown>)["service_tier"]);
269 }
270 return null;
271 }
272
273 if (payloadType !== "token_count") return null;
274
275 const info = payloadRecord["info"];
276 if (typeof info !== "object" || info === null) return null;
277 const last = (info as Record<string, unknown>)["last_token_usage"];
278 if (typeof last !== "object" || last === null) return null;
279 const lastRecord = last as Record<string, unknown>;
280
281 // Only an event that is otherwise eligible may consume the duplicate
282 // signature. A token_count arriving before its turn_context (no model yet)
283 // must not poison it, or the re-emitted copy after the model is known would
284 // be skipped as a duplicate and those tokens never counted.
285 const timestampMs = parseTimestampMs(record["timestamp"]);
286 if (timestampMs === null) return null;
287 if (state.model.length === 0) return null;
288
289 // Codex re-emits an unchanged token_count on some stream boundaries. Summing
290 // those would double count, so identical consecutive payloads are skipped.
291 const signature = JSON.stringify(lastRecord);
292 if (signature === state.lastUsageSignature) return null;
293 state.lastUsageSignature = signature;
294
295 // In a forked rollout the copied parent history was already counted from the
296 // parent's own file. Drop the leading burst; the first usage event separated
297 // from its predecessor by a real turn's worth of time ends it for good.
298 if (state.suppressingForkCopies) {
299 if (timestampMs - state.forkCopyAnchorMs < FORK_COPY_MAX_GAP_MS) {
300 state.forkCopyAnchorMs = timestampMs;
301 return null;
302 }
303 state.suppressingForkCopies = false;
304 }
305
306 const inputTokens = int(lastRecord["input_tokens"]);
307 const cachedInputTokens = int(lastRecord["cached_input_tokens"]);
308 const cacheCreationTokens = int(lastRecord["cache_write_input_tokens"]);
309 const outputTokens = int(lastRecord["output_tokens"]);
310
311 const totals: UsageTokenTotals = {
312 // Codex reports `input_tokens` inclusive of the cached portion.
313 uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens),
314 cachedInputTokens,
315 cacheCreationTokens,
316 outputTokens,
317 // Reported inside output_tokens, surfaced separately for the token mix.
318 reasoningTokens: Math.min(outputTokens, int(lastRecord["reasoning_output_tokens"])),
319 };
320
321 if (totalTokens(totals) === 0) return null;
322
323 return {
324 provider: "codex",
325 timestampMs,
326 model: state.model,
327 sessionId: state.sessionId,
328 totals,
329 // Codex does not report cost in the rollout.
330 reportedCostUsd: null,
331 speed: state.speed,
332 // Events surviving the fork-copy suppression above are unique to this
333 // rollout, so they need no global dedup.
334 dedupeKey: null,
335 };
336}
337
338/**
339 * Maps a Codex `service_tier` to its billing speed. Codex omits the field when
340 * no tier was requested, which bills as standard, as do `default` and
341 * `standard`. `fast` is accepted as an alias of `priority`.
342 */
343function codexSpeed(serviceTier: unknown): UsageSpeed {
344 if (serviceTier === "priority" || serviceTier === "fast") return "fast";
345 if (serviceTier === "ultrafast") return "ultrafast";
346 return "standard";
347}
348
349/* -------------------------------------------------------------------------- */
350/* Grok Build */
351/* -------------------------------------------------------------------------- */
352
353interface GrokUsageTotals {
354 readonly inputTokens: number;
355 readonly outputTokens: number;
356 readonly cachedReadTokens: number;
357 readonly cacheCreationTokens: number;
358 readonly reasoningTokens: number;
359 readonly costUsdTicks: number | null;
360}
361
362function readGrokUsageTotals(value: unknown): GrokUsageTotals | null {
363 if (typeof value !== "object" || value === null) return null;
364 const record = value as Record<string, unknown>;
365 return {
366 inputTokens: int(record["inputTokens"]),
367 outputTokens: int(record["outputTokens"]),
368 cachedReadTokens: int(record["cachedReadTokens"]),
369 cacheCreationTokens: int(record["cacheCreationTokens"]),
370 reasoningTokens: int(record["reasoningTokens"]),
371 costUsdTicks:
372 typeof record["costUsdTicks"] === "number" && Number.isFinite(record["costUsdTicks"])
373 ? record["costUsdTicks"]
374 : null,
375 };
376}
377
378function grokTotalsToUsage(totals: GrokUsageTotals): UsageTokenTotals {
379 const cachedInputTokens = totals.cachedReadTokens;
380 const cacheCreationTokens = totals.cacheCreationTokens;
381 // Grok reports `inputTokens` inclusive of the cached portion, matching Codex.
382 const uncachedInputTokens = Math.max(
383 0,
384 totals.inputTokens - cachedInputTokens - cacheCreationTokens,
385 );
386 const outputTokens = totals.outputTokens;
387 return {
388 uncachedInputTokens,
389 cachedInputTokens,
390 cacheCreationTokens,
391 outputTokens,
392 reasoningTokens: Math.min(outputTokens, totals.reasoningTokens),
393 };
394}
395
396/**
397 * Parses one line of a Grok Build `updates.jsonl` session log.
398 *
399 * Usage lands on `turn_completed` session updates. Per-model breakdowns live
400 * under `usage.modelUsage`; when present each model becomes its own record.
401 *
402 * Returns every record for the line (0 or more). Callers stream line-by-line
403 * and flatten.
404 */
405export function parseGrokLine(line: string): readonly UsageRecord[] {
406 let parsed: unknown;
407 try {
408 parsed = JSON.parse(line);
409 } catch {
410 return [];
411 }
412 return parseGrokRecord(parsed);
413}
414
415export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] {
416 if (typeof parsed !== "object" || parsed === null) return [];
417
418 const record = parsed as Record<string, unknown>;
419 const params = record["params"];
420 if (typeof params !== "object" || params === null) return [];
421 const paramsRecord = params as Record<string, unknown>;
422
423 const update = paramsRecord["update"];
424 if (typeof update !== "object" || update === null) return [];
425 const updateRecord = update as Record<string, unknown>;
426 if (updateRecord["sessionUpdate"] !== "turn_completed") return [];
427
428 const usage = updateRecord["usage"];
429 if (typeof usage !== "object" || usage === null) return [];
430 const usageRecord = usage as Record<string, unknown>;
431
432 const sessionId = typeof paramsRecord["sessionId"] === "string" ? paramsRecord["sessionId"] : "";
433 const promptId = typeof updateRecord["prompt_id"] === "string" ? updateRecord["prompt_id"] : null;
434
435 // Prefer the high-resolution agent clock; fall back to the outer unix seconds.
436 const meta = paramsRecord["_meta"];
437 let timestampMs: number | null = null;
438 if (typeof meta === "object" && meta !== null) {
439 const agentTimestampMs = (meta as Record<string, unknown>)["agentTimestampMs"];
440 if (typeof agentTimestampMs === "number" && Number.isFinite(agentTimestampMs)) {
441 timestampMs = agentTimestampMs;
442 }
443 }
444 if (timestampMs === null) {
445 const timestamp = record["timestamp"];
446 if (typeof timestamp === "number" && Number.isFinite(timestamp)) {
447 timestampMs = timestamp > 1e12 ? timestamp : timestamp * 1000;
448 }
449 }
450 if (timestampMs === null) return [];
451
452 const topLevel = readGrokUsageTotals(usageRecord);
453 if (topLevel === null) return [];
454
455 const modelUsage = usageRecord["modelUsage"];
456 const modelEntries: Array<{ model: string; totals: GrokUsageTotals }> = [];
457 if (typeof modelUsage === "object" && modelUsage !== null) {
458 for (const [model, raw] of Object.entries(modelUsage as Record<string, unknown>)) {
459 if (model.length === 0) continue;
460 const totals = readGrokUsageTotals(raw);
461 if (totals === null) continue;
462 modelEntries.push({ model, totals });
463 }
464 }
465
466 if (modelEntries.length === 0) {
467 if (totalTokens(grokTotalsToUsage(topLevel)) === 0) return [];
468 return [
469 {
470 provider: "grok",
471 timestampMs,
472 model: "grok",
473 sessionId,
474 totals: grokTotalsToUsage(topLevel),
475 reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks),
476 speed: "standard",
477 // No prompt id means we cannot tell two same-second updates apart.
478 dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`,
479 },
480 ];
481 }
482
483 // Cost allocation:
484 // 1. Emitted models with their own costUsdTicks keep those values.
485 // 2. Remaining aggregate cost (top-level minus those per-model ticks,
486 // clamped at 0) is pro-rated across emitted models that lack ticks,
487 // by token share among the unticked models only.
488 // 3. When no model has per-model ticks, remaining equals the full
489 // aggregate and every emitted model gets a token-share slice.
490 // Zero-token rows are never emitted and never count toward used ticks.
491 const topLevelCostUsd = grokCostTicksToUsd(topLevel.costUsdTicks);
492 let usedTickedCostUsd = 0;
493 let untickedTokenDenominator = 0;
494 for (const entry of modelEntries) {
495 const tokens = totalTokens(grokTotalsToUsage(entry.totals));
496 if (tokens === 0) continue;
497 if (entry.totals.costUsdTicks !== null) {
498 usedTickedCostUsd += grokCostTicksToUsd(entry.totals.costUsdTicks) ?? 0;
499 } else {
500 untickedTokenDenominator += tokens;
501 }
502 }
503 const remainingCostUsd =
504 topLevelCostUsd === null ? null : Math.max(0, topLevelCostUsd - usedTickedCostUsd);
505
506 const results: UsageRecord[] = [];
507 for (const entry of modelEntries) {
508 const totals = grokTotalsToUsage(entry.totals);
509 if (totalTokens(totals) === 0) continue;
510
511 let reportedCostUsd = grokCostTicksToUsd(entry.totals.costUsdTicks);
512 if (reportedCostUsd === null && remainingCostUsd !== null && untickedTokenDenominator > 0) {
513 reportedCostUsd = remainingCostUsd * (totalTokens(totals) / untickedTokenDenominator);
514 }
515
516 results.push({
517 provider: "grok",
518 timestampMs,
519 model: entry.model,
520 sessionId,
521 totals,
522 reportedCostUsd,
523 speed: "standard",
524 dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`,
525 });
526 }
527 return results;
528}
529
530export { EMPTY_TOTALS };
packages/provider-core/package.json+4
...@@ -175,6 +175,10 @@...@@ -175,6 +175,10 @@
175 "types": "./src/server/threadTitleContext.ts",175 "types": "./src/server/threadTitleContext.ts",
176 "import": "./src/server/threadTitleContext.ts"176 "import": "./src/server/threadTitleContext.ts"
177 },177 },
178 "./server/usage": {
179 "types": "./src/server/usage.ts",
180 "import": "./src/server/usage.ts"
181 },
178 "./server/usageLimits": {182 "./server/usageLimits": {
179 "types": "./src/server/usageLimits.ts",183 "types": "./src/server/usageLimits.ts",
180 "import": "./src/server/usageLimits.ts"184 "import": "./src/server/usageLimits.ts"
packages/provider-core/src/server/driver.ts+12-2
...@@ -42,6 +42,7 @@ import type { ProviderAuthController } from "./auth.ts";...@@ -42,6 +42,7 @@ import type { ProviderAuthController } from "./auth.ts";
42import type { ProviderDriverError } from "./errors.ts";42import type { ProviderDriverError } from "./errors.ts";
43import type { ManagedServerProvider } from "./snapshot.ts";43import type { ManagedServerProvider } from "./snapshot.ts";
44import type { ProviderTextGeneration } from "./textGeneration.ts";44import type { ProviderTextGeneration } from "./textGeneration.ts";
45import type { ProviderUsageReader } from "./usage.ts";
4546
46/**47/**
47 * Static metadata advertised by a driver. Used for default presentation48 * Static metadata advertised by a driver. Used for default presentation
...@@ -172,7 +173,7 @@ export interface ProviderDriverCreateInput<Config> {...@@ -172,7 +173,7 @@ export interface ProviderDriverCreateInput<Config> {
172 * scope closes. Two calls to `create` with different `instanceId` /173 * scope closes. Two calls to `create` with different `instanceId` /
173 * `config` MUST yield instances with no shared mutable state.174 * `config` MUST yield instances with no shared mutable state.
174 */175 */
175export interface ProviderDriver<Config, R = never> {176export interface ProviderDriver<Config, R = never, UsageR = never> {
176 readonly driverKind: ProviderDriverKind;177 readonly driverKind: ProviderDriverKind;
177 readonly metadata: ProviderDriverMetadata;178 readonly metadata: ProviderDriverMetadata;
178 /**179 /**
...@@ -207,6 +208,11 @@ export interface ProviderDriver<Config, R = never> {...@@ -207,6 +208,11 @@ export interface ProviderDriver<Config, R = never> {
207 readonly create: (208 readonly create: (
208 input: ProviderDriverCreateInput<Config>,209 input: ProviderDriverCreateInput<Config>,
209 ) => Effect.Effect<ProviderInstance, ProviderDriverError, R | Scope.Scope>;210 ) => Effect.Effect<ProviderInstance, ProviderDriverError, R | Scope.Scope>;
211 /**
212 * Where the usage page reads this provider's token history, when it keeps
213 * one. `UsageR` is what the reader needs, usually far less than `R`.
214 */
215 readonly usage?: ProviderUsageReader<Config, UsageR>;
210}216}
211217
212/**218/**
...@@ -219,4 +225,8 @@ export interface ProviderDriver<Config, R = never> {...@@ -219,4 +225,8 @@ export interface ProviderDriver<Config, R = never> {
219// needs the original `Config` type. Using `unknown` instead would force225// needs the original `Config` type. Using `unknown` instead would force
220// `create` callers into casts since `unknown` is not assignable to a226// `create` callers into casts since `unknown` is not assignable to a
221// concrete `Config` from inside the driver body.227// concrete `Config` from inside the driver body.
222export type AnyProviderDriver<R = never> = ProviderDriver<any, R>;228export type AnyProviderDriver<R = never, UsageR = unknown> = ProviderDriver<any, R, UsageR>;
229
230/** The services a driver's usage reader needs. */
231export type ProviderUsageReaderEnv<Driver> =
232 Driver extends ProviderDriver<any, any, infer UsageR> ? UsageR : never;
packages/provider-core/src/server/usage.test.ts created+17
...@@ -0,0 +1,17 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { totalTokens } from "./usage.ts";
4
5describe("totalTokens", () => {
6 it("does not add reasoning on top of output", () => {
7 expect(
8 totalTokens({
9 uncachedInputTokens: 10,
10 cachedInputTokens: 20,
11 cacheCreationTokens: 30,
12 outputTokens: 40,
13 reasoningTokens: 25,
14 }),
15 ).toBe(100);
16 });
17});
packages/provider-core/src/server/usage.ts created+179
...@@ -0,0 +1,179 @@
1/**
2 * The usage reader contract: how a driver tells the server's usage page where
3 * its history lives and how to read it.
4 *
5 * A driver either points at JSONL transcripts that the server's shared engine
6 * streams, caches and resumes (`kind: "transcripts"`), or scans its own
7 * sources and returns their records (`kind: "scan"`). The server owns
8 * pricing, aggregation, de-duplication and the scan cache.
9 *
10 * @module provider-core/server/usage
11 */
12import type {
13 ProviderInstanceId,
14 UsageProviderKind,
15 UsageSource,
16 UsageTokenTotals,
17} from "@t3tools/contracts";
18import type * as Effect from "effect/Effect";
19import type * as Schema from "effect/Schema";
20
21/**
22 * Billing speed of a request. Faster speeds bill at a model-specific premium.
23 * Claude fast mode and Codex `priority` are `fast`; Codex `ultrafast` is its
24 * own, more expensive tier.
25 */
26export type UsageSpeed = "standard" | "fast" | "ultrafast";
27
28export interface UsageRecord {
29 readonly provider: UsageProviderKind;
30 readonly timestampMs: number;
31 readonly model: string;
32 /**
33 * Rate-table key when the provider's display name carries tiers the table
34 * does not know, such as Cursor's `claude-opus-5-5-high`. Defaults to `model`.
35 */
36 readonly rateModel?: string;
37 readonly sessionId: string;
38 readonly totals: UsageTokenTotals;
39 readonly reportedCostUsd: number | null;
40 /** Only Claude Code and Codex record a speed; other providers are `standard`. */
41 readonly speed: UsageSpeed;
42 /**
43 * Key for cross-file de-duplication, or `null` when the record is inherently
44 * unique and needs no dedup.
45 */
46 readonly dedupeKey: string | null;
47}
48
49export const EMPTY_TOTALS: UsageTokenTotals = {
50 uncachedInputTokens: 0,
51 cachedInputTokens: 0,
52 cacheCreationTokens: 0,
53 outputTokens: 0,
54 reasoningTokens: 0,
55};
56
57export function totalTokens(totals: UsageTokenTotals): number {
58 // reasoningTokens is a subset of outputTokens and must not be added again.
59 return (
60 totals.uncachedInputTokens +
61 totals.cachedInputTokens +
62 totals.cacheCreationTokens +
63 totals.outputTokens
64 );
65}
66
67/** A token count from untrusted JSON: a positive finite number, truncated, else 0. */
68export function tokenCount(value: unknown): number {
69 return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
70}
71
72/** Epoch milliseconds of an ISO timestamp string, or `null`. */
73export function parseTimestampMs(value: unknown): number | null {
74 if (typeof value !== "string") return null;
75 const parsed = Date.parse(value);
76 return Number.isNaN(parsed) ? null : parsed;
77}
78
79/** JSON fields a format reads. `true` keeps the whole subtree. */
80export type SelectedFields = { readonly [key: string]: true | SelectedFields };
81
82/**
83 * How the server's JSONL engine reads one provider's transcript lines.
84 *
85 * Lines arrive one at a time. A line too large to parse whole is projected to
86 * `selectFields` and handed to `parseProjected` instead of `parseLine`.
87 */
88export interface TranscriptUsageFormat<State> {
89 readonly selectFields: SelectedFields;
90 /**
91 * Cheap substring gate applied before `JSON.parse`. Transcripts are mostly
92 * tool output; skipping lines that cannot matter is worth an order of
93 * magnitude. Lines that only update `State` must pass too.
94 */
95 mightCarryUsage(line: string): boolean;
96 parseLine(line: string, state: State): readonly UsageRecord[];
97 parseProjected(projected: unknown, state: State): readonly UsageRecord[];
98 /**
99 * Present for formats whose records depend on earlier lines. The state at a
100 * resume point is persisted in the scan cache through `schema`, so a grown
101 * file resumes with it. Absent means every line stands alone.
102 */
103 readonly state?: {
104 readonly initial: () => State;
105 readonly schema: Schema.Codec<State, unknown>;
106 };
107 /**
108 * Whether one session's records may appear in several files, such as a
109 * rollout copied when it moves. The server then keys those sessions'
110 * records for cross-file de-duplication.
111 */
112 readonly sharedSessionsAcrossFiles?: true;
113}
114
115/** A configured instance of the driver, its config already decoded by `driver.configSchema`. */
116export interface ProviderUsageInstance<Config> {
117 readonly instanceId: ProviderInstanceId;
118 /**
119 * `undefined` when the stored config does not decode. The instance still has
120 * history on disk, so readers that need no config read it anyway.
121 */
122 readonly config: Config | undefined;
123 /** The instance's environment merged over the host environment. */
124 readonly environment: NodeJS.ProcessEnv;
125 /** Whether settings list the instance, rather than it being the implicit default. */
126 readonly configured: boolean;
127}
128
129/** One source a `scan` reader read. */
130export interface ProviderUsageScan {
131 readonly dir: string;
132 readonly volumeId: string;
133 /** Overrides the server's host name in the source fingerprint, for account-wide sources. */
134 readonly hostId?: string;
135 readonly status?: UsageSource["status"];
136 readonly message?: string;
137 readonly action?: UsageSource["action"];
138 /** Parsed records per file, or `null` when the source does not exist. */
139 readonly files:
140 | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]
141 | null;
142 /** Answered from a cache while a refresh runs. */
143 readonly refreshing?: true;
144}
145
146export interface ProviderUsageScanInput<Config> {
147 readonly instances: ReadonlyArray<ProviderUsageInstance<Config>>;
148 /** Files last written before this cannot hold records inside the window. */
149 readonly windowStartMs: number;
150 /** The oldest usage the server keeps; account caches cover back to here. */
151 readonly retentionCutoffMs: number;
152 /** Wait for a slow refresh instead of answering from a cache. */
153 readonly awaitRefresh: boolean;
154}
155
156export type ProviderUsageReader<Config, R> =
157 | {
158 readonly kind: "transcripts";
159 readonly provider: UsageProviderKind;
160 readonly format: TranscriptUsageFormat<unknown>;
161 /**
162 * Transcript directories for one instance. The server canonicalizes,
163 * de-duplicates and scans them; `fileName` limits a walk to one basename.
164 */
165 readonly directories: (
166 instance: ProviderUsageInstance<Config>,
167 ) => Effect.Effect<
168 ReadonlyArray<{ readonly dir: string; readonly fileName?: string }>,
169 never,
170 R
171 >;
172 }
173 | {
174 readonly kind: "scan";
175 readonly provider: UsageProviderKind;
176 readonly scan: (
177 input: ProviderUsageScanInput<Config>,
178 ) => Effect.Effect<ReadonlyArray<ProviderUsageScan>, never, R>;
179 };
packages/provider-grok/package.json+4
...@@ -15,6 +15,10 @@...@@ -15,6 +15,10 @@
15 "types": "./src/server.ts",15 "types": "./src/server.ts",
16 "import": "./src/server.ts"16 "import": "./src/server.ts"
17 },17 },
18 "./server/usage": {
19 "types": "./src/server/usage.ts",
20 "import": "./src/server/usage.ts"
21 },
18 "./testing": {22 "./testing": {
19 "types": "./src/testing.ts",23 "types": "./src/testing.ts",
20 "import": "./src/testing.ts"24 "import": "./src/testing.ts"
packages/provider-grok/src/server/driver.ts+3-1
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 checkGrokProviderStatus,19 checkGrokProviderStatus,
20 enrichGrokSnapshot,20 enrichGrokSnapshot,
21} from "./status.ts";21} from "./status.ts";
22import { grokUsageReader } from "./usage.ts";
22import { readGrokAccount } from "./usageLimits.ts";23import { readGrokAccount } from "./usageLimits.ts";
23import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";24import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
24import {25import {
...@@ -82,7 +83,7 @@ export type GrokDriverEnv =...@@ -82,7 +83,7 @@ export type GrokDriverEnv =
82 | Path.Path83 | Path.Path
83 | ProviderEventLoggers.ProviderEventLoggers;84 | ProviderEventLoggers.ProviderEventLoggers;
8485
85export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {86export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv, Path.Path> = {
86 driverKind: DRIVER_KIND,87 driverKind: DRIVER_KIND,
87 metadata: {88 metadata: {
88 displayName: "Grok",89 displayName: "Grok",
...@@ -90,6 +91,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {...@@ -90,6 +91,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
90 },91 },
91 configSchema: GrokSettings,92 configSchema: GrokSettings,
92 defaultConfig: (): GrokSettings => decodeGrokSettings({}),93 defaultConfig: (): GrokSettings => decodeGrokSettings({}),
94 usage: grokUsageReader,
93 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>95 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
94 Effect.gen(function* () {96 Effect.gen(function* () {
95 const crypto = yield* Crypto.Crypto;97 const crypto = yield* Crypto.Crypto;
packages/provider-grok/src/server/usage.test.ts created+316
...@@ -0,0 +1,316 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { GROK_COST_USD_TICKS_PER_DOLLAR, parseGrokLine } from "./usage.ts";
4
5describe("parseGrokLine", () => {
6 /** Shaped after a real Grok Build `turn_completed` session update. */
7 function turnCompleted(overrides?: {
8 sessionId?: string;
9 promptId?: string;
10 timestamp?: number;
11 agentTimestampMs?: number;
12 usage?: Record<string, unknown>;
13 modelUsage?: Record<string, Record<string, unknown>> | null;
14 }): string {
15 const modelUsage =
16 overrides && "modelUsage" in overrides
17 ? overrides.modelUsage
18 : {
19 "grok-4.5-build": {
20 inputTokens: 20_272,
21 outputTokens: 272,
22 totalTokens: 20_544,
23 cachedReadTokens: 11_264,
24 cacheCreationTokens: 0,
25 reasoningTokens: 180,
26 costUsdTicks: 230_272_000,
27 },
28 };
29
30 return JSON.stringify({
31 timestamp: overrides?.timestamp ?? 1_786_372_566,
32 method: "_x.ai/session/update",
33 params: {
34 sessionId: overrides?.sessionId ?? "019fec1a-12f7-72f2-9b1f-7778a00aea3c",
35 update: {
36 sessionUpdate: "turn_completed",
37 prompt_id: overrides?.promptId ?? "prompt-1",
38 stop_reason: "end_turn",
39 usage: {
40 inputTokens: 20_272,
41 outputTokens: 272,
42 totalTokens: 20_544,
43 cachedReadTokens: 11_264,
44 cacheCreationTokens: 0,
45 reasoningTokens: 180,
46 costUsdTicks: 230_272_000,
47 ...(modelUsage === null ? {} : { modelUsage }),
48 ...overrides?.usage,
49 },
50 },
51 _meta: {
52 eventId: "event-1",
53 agentTimestampMs: overrides?.agentTimestampMs ?? 1_786_372_566_485,
54 },
55 },
56 });
57 }
58
59 it("extracts per-model totals and provider-reported cost ticks", () => {
60 const records = parseGrokLine(turnCompleted());
61
62 expect(records).toHaveLength(1);
63 const [record] = records;
64 expect(record?.provider).toBe("grok");
65 expect(record?.model).toBe("grok-4.5-build");
66 expect(record?.sessionId).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c");
67 expect(record?.timestampMs).toBe(1_786_372_566_485);
68 expect(record?.totals).toEqual({
69 uncachedInputTokens: 20_272 - 11_264,
70 cachedInputTokens: 11_264,
71 cacheCreationTokens: 0,
72 outputTokens: 272,
73 reasoningTokens: 180,
74 });
75 expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12);
76 expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok-4.5-build");
77 });
78
79 it("emits one record per model when modelUsage has several entries", () => {
80 const records = parseGrokLine(
81 turnCompleted({
82 modelUsage: {
83 "grok-4.5": {
84 inputTokens: 1000,
85 outputTokens: 50,
86 cachedReadTokens: 400,
87 reasoningTokens: 20,
88 costUsdTicks: 50_000_000,
89 },
90 "grok-composer-2.5-fast": {
91 inputTokens: 200,
92 outputTokens: 30,
93 cachedReadTokens: 100,
94 reasoningTokens: 0,
95 costUsdTicks: 10_000_000,
96 },
97 },
98 }),
99 );
100
101 expect(records.map((record) => record.model).toSorted()).toEqual([
102 "grok-4.5",
103 "grok-composer-2.5-fast",
104 ]);
105 expect(records.every((record) => record.provider === "grok")).toBe(true);
106 expect(records.find((record) => record.model === "grok-4.5")?.reportedCostUsd).toBeCloseTo(
107 0.005,
108 12,
109 );
110 });
111
112 it("inherits top-level cost ticks for a single model without its own ticks", () => {
113 const records = parseGrokLine(
114 turnCompleted({
115 modelUsage: {
116 "grok-4.5-build": {
117 inputTokens: 1000,
118 outputTokens: 10,
119 cachedReadTokens: 0,
120 reasoningTokens: 0,
121 },
122 },
123 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
124 }),
125 );
126
127 expect(records).toHaveLength(1);
128 expect(records[0]?.reportedCostUsd).toBe(1);
129 });
130
131 it("falls back to a generic grok model when modelUsage is absent", () => {
132 const records = parseGrokLine(turnCompleted({ modelUsage: null }));
133
134 expect(records).toHaveLength(1);
135 const [record] = records;
136 expect(record?.provider).toBe("grok");
137 expect(record?.model).toBe("grok");
138 expect(record?.totals).toEqual({
139 uncachedInputTokens: 20_272 - 11_264,
140 cachedInputTokens: 11_264,
141 cacheCreationTokens: 0,
142 outputTokens: 272,
143 reasoningTokens: 180,
144 });
145 expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12);
146 expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok");
147 });
148
149 it("pro-rates top-level cost ticks across multi-model turns without per-model ticks", () => {
150 const records = parseGrokLine(
151 turnCompleted({
152 modelUsage: {
153 "grok-4.5": {
154 inputTokens: 300,
155 outputTokens: 0,
156 cachedReadTokens: 0,
157 reasoningTokens: 0,
158 },
159 "grok-composer-2.5-fast": {
160 inputTokens: 100,
161 outputTokens: 0,
162 cachedReadTokens: 0,
163 reasoningTokens: 0,
164 },
165 },
166 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
167 }),
168 );
169
170 expect(records).toHaveLength(2);
171 const byModel = Object.fromEntries(records.map((record) => [record.model, record]));
172 expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12);
173 expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12);
174 const sum =
175 (byModel["grok-4.5"]?.reportedCostUsd ?? 0) +
176 (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0);
177 expect(sum).toBeCloseTo(1, 12);
178 });
179
180 it("pro-rates aggregate cost when a zero-token sibling carries costUsdTicks: 0", () => {
181 const records = parseGrokLine(
182 turnCompleted({
183 modelUsage: {
184 "grok-4.5": {
185 inputTokens: 300,
186 outputTokens: 0,
187 cachedReadTokens: 0,
188 reasoningTokens: 0,
189 },
190 "grok-composer-2.5-fast": {
191 inputTokens: 100,
192 outputTokens: 0,
193 cachedReadTokens: 0,
194 reasoningTokens: 0,
195 },
196 "empty-sibling": {
197 inputTokens: 0,
198 outputTokens: 0,
199 cachedReadTokens: 0,
200 reasoningTokens: 0,
201 costUsdTicks: 0,
202 },
203 },
204 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
205 }),
206 );
207
208 expect(records).toHaveLength(2);
209 expect(records.every((record) => record.model !== "empty-sibling")).toBe(true);
210 const byModel = Object.fromEntries(records.map((record) => [record.model, record]));
211 expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12);
212 expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12);
213 const sum =
214 (byModel["grok-4.5"]?.reportedCostUsd ?? 0) +
215 (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0);
216 expect(sum).toBeCloseTo(1, 12);
217 });
218
219 it("allocates leftover aggregate ticks to models that omit per-model ticks", () => {
220 const records = parseGrokLine(
221 turnCompleted({
222 modelUsage: {
223 "grok-4.5": {
224 inputTokens: 300,
225 outputTokens: 0,
226 cachedReadTokens: 0,
227 reasoningTokens: 0,
228 costUsdTicks: 0.4 * GROK_COST_USD_TICKS_PER_DOLLAR,
229 },
230 "grok-composer-2.5-fast": {
231 inputTokens: 100,
232 outputTokens: 0,
233 cachedReadTokens: 0,
234 reasoningTokens: 0,
235 },
236 },
237 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
238 }),
239 );
240
241 expect(records).toHaveLength(2);
242 const byModel = Object.fromEntries(records.map((record) => [record.model, record]));
243 expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.4, 12);
244 expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.6, 12);
245 const sum =
246 (byModel["grok-4.5"]?.reportedCostUsd ?? 0) +
247 (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0);
248 expect(sum).toBeCloseTo(1, 12);
249 });
250
251 it("does not invent a colliding dedupe key when prompt_id is missing", () => {
252 const line = JSON.stringify({
253 timestamp: 1_786_372_566,
254 method: "_x.ai/session/update",
255 params: {
256 sessionId: "s1",
257 update: {
258 sessionUpdate: "turn_completed",
259 usage: {
260 inputTokens: 10,
261 outputTokens: 2,
262 modelUsage: {
263 "grok-4.5": { inputTokens: 10, outputTokens: 2 },
264 },
265 },
266 },
267 },
268 });
269
270 expect(parseGrokLine(line)[0]?.dedupeKey).toBeNull();
271 });
272
273 it("ignores non-turn lines and empty usage", () => {
274 expect(parseGrokLine(JSON.stringify({ method: "session/update", params: {} }))).toEqual([]);
275 expect(parseGrokLine("not json")).toEqual([]);
276 expect(
277 parseGrokLine(
278 turnCompleted({
279 modelUsage: {
280 "grok-4.5-build": {
281 inputTokens: 0,
282 outputTokens: 0,
283 cachedReadTokens: 0,
284 reasoningTokens: 0,
285 costUsdTicks: 0,
286 },
287 },
288 }),
289 ),
290 ).toEqual([]);
291 });
292
293 it("falls back to the outer unix-seconds timestamp when agent meta is missing", () => {
294 const line = JSON.stringify({
295 timestamp: 1_786_372_566,
296 method: "_x.ai/session/update",
297 params: {
298 sessionId: "s1",
299 update: {
300 sessionUpdate: "turn_completed",
301 prompt_id: "p1",
302 usage: {
303 inputTokens: 10,
304 outputTokens: 2,
305 modelUsage: {
306 "grok-4.5": { inputTokens: 10, outputTokens: 2 },
307 },
308 },
309 },
310 },
311 });
312
313 const records = parseGrokLine(line);
314 expect(records[0]?.timestampMs).toBe(1_786_372_566_000);
315 });
316});
packages/provider-grok/src/server/usage.ts created+239
...@@ -0,0 +1,239 @@
1/**
2 * Usage history for Grok Build: the format of its per-session
3 * `updates.jsonl` logs and where each instance's `GROK_HOME` keeps them.
4 *
5 * @module provider-grok/server/usage
6 */
7import * as NodeOS from "node:os";
8
9import type { UsageTokenTotals } from "@t3tools/contracts";
10import * as Effect from "effect/Effect";
11import * as Path from "effect/Path";
12
13import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
14import {
15 tokenCount,
16 totalTokens,
17 type ProviderUsageReader,
18 type TranscriptUsageFormat,
19 type UsageRecord,
20} from "@t3tools/provider-core/server/usage";
21
22import type { GrokSettings } from "../settings.ts";
23
24/**
25 * Grok reports cost in integer ticks where `1 USD = 10^10` ticks. See Grok
26 * headless `total_cost_usd_ticks`. Convert to dollars for pricing.
27 */
28export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000;
29
30function grokCostTicksToUsd(ticks: unknown): number | null {
31 if (typeof ticks !== "number" || !Number.isFinite(ticks) || ticks < 0) return null;
32 return ticks / GROK_COST_USD_TICKS_PER_DOLLAR;
33}
34
35interface GrokUsageTotals {
36 readonly inputTokens: number;
37 readonly outputTokens: number;
38 readonly cachedReadTokens: number;
39 readonly cacheCreationTokens: number;
40 readonly reasoningTokens: number;
41 readonly costUsdTicks: number | null;
42}
43
44function readGrokUsageTotals(value: unknown): GrokUsageTotals | null {
45 if (typeof value !== "object" || value === null) return null;
46 const record = value as Record<string, unknown>;
47 return {
48 inputTokens: tokenCount(record["inputTokens"]),
49 outputTokens: tokenCount(record["outputTokens"]),
50 cachedReadTokens: tokenCount(record["cachedReadTokens"]),
51 cacheCreationTokens: tokenCount(record["cacheCreationTokens"]),
52 reasoningTokens: tokenCount(record["reasoningTokens"]),
53 costUsdTicks:
54 typeof record["costUsdTicks"] === "number" && Number.isFinite(record["costUsdTicks"])
55 ? record["costUsdTicks"]
56 : null,
57 };
58}
59
60function grokTotalsToUsage(totals: GrokUsageTotals): UsageTokenTotals {
61 const cachedInputTokens = totals.cachedReadTokens;
62 const cacheCreationTokens = totals.cacheCreationTokens;
63 // Grok reports `inputTokens` inclusive of the cached portion, matching Codex.
64 const uncachedInputTokens = Math.max(
65 0,
66 totals.inputTokens - cachedInputTokens - cacheCreationTokens,
67 );
68 const outputTokens = totals.outputTokens;
69 return {
70 uncachedInputTokens,
71 cachedInputTokens,
72 cacheCreationTokens,
73 outputTokens,
74 reasoningTokens: Math.min(outputTokens, totals.reasoningTokens),
75 };
76}
77
78/**
79 * Parses one line of a Grok Build `updates.jsonl` session log.
80 *
81 * Usage lands on `turn_completed` session updates. Per-model breakdowns live
82 * under `usage.modelUsage`; when present each model becomes its own record.
83 *
84 * Returns every record for the line (0 or more). Callers stream line-by-line
85 * and flatten.
86 */
87export function parseGrokLine(line: string): readonly UsageRecord[] {
88 let parsed: unknown;
89 try {
90 parsed = JSON.parse(line);
91 } catch {
92 return [];
93 }
94 return parseGrokRecord(parsed);
95}
96
97function parseGrokRecord(parsed: unknown): readonly UsageRecord[] {
98 if (typeof parsed !== "object" || parsed === null) return [];
99
100 const record = parsed as Record<string, unknown>;
101 const params = record["params"];
102 if (typeof params !== "object" || params === null) return [];
103 const paramsRecord = params as Record<string, unknown>;
104
105 const update = paramsRecord["update"];
106 if (typeof update !== "object" || update === null) return [];
107 const updateRecord = update as Record<string, unknown>;
108 if (updateRecord["sessionUpdate"] !== "turn_completed") return [];
109
110 const usage = updateRecord["usage"];
111 if (typeof usage !== "object" || usage === null) return [];
112 const usageRecord = usage as Record<string, unknown>;
113
114 const sessionId = typeof paramsRecord["sessionId"] === "string" ? paramsRecord["sessionId"] : "";
115 const promptId = typeof updateRecord["prompt_id"] === "string" ? updateRecord["prompt_id"] : null;
116
117 // Prefer the high-resolution agent clock; fall back to the outer unix seconds.
118 const meta = paramsRecord["_meta"];
119 let timestampMs: number | null = null;
120 if (typeof meta === "object" && meta !== null) {
121 const agentTimestampMs = (meta as Record<string, unknown>)["agentTimestampMs"];
122 if (typeof agentTimestampMs === "number" && Number.isFinite(agentTimestampMs)) {
123 timestampMs = agentTimestampMs;
124 }
125 }
126 if (timestampMs === null) {
127 const timestamp = record["timestamp"];
128 if (typeof timestamp === "number" && Number.isFinite(timestamp)) {
129 timestampMs = timestamp > 1e12 ? timestamp : timestamp * 1000;
130 }
131 }
132 if (timestampMs === null) return [];
133
134 const topLevel = readGrokUsageTotals(usageRecord);
135 if (topLevel === null) return [];
136
137 const modelUsage = usageRecord["modelUsage"];
138 const modelEntries: Array<{ model: string; totals: GrokUsageTotals }> = [];
139 if (typeof modelUsage === "object" && modelUsage !== null) {
140 for (const [model, raw] of Object.entries(modelUsage as Record<string, unknown>)) {
141 if (model.length === 0) continue;
142 const totals = readGrokUsageTotals(raw);
143 if (totals === null) continue;
144 modelEntries.push({ model, totals });
145 }
146 }
147
148 if (modelEntries.length === 0) {
149 if (totalTokens(grokTotalsToUsage(topLevel)) === 0) return [];
150 return [
151 {
152 provider: "grok",
153 timestampMs,
154 model: "grok",
155 sessionId,
156 totals: grokTotalsToUsage(topLevel),
157 reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks),
158 speed: "standard",
159 // No prompt id means we cannot tell two same-second updates apart.
160 dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`,
161 },
162 ];
163 }
164
165 // Cost allocation:
166 // 1. Emitted models with their own costUsdTicks keep those values.
167 // 2. Remaining aggregate cost (top-level minus those per-model ticks,
168 // clamped at 0) is pro-rated across emitted models that lack ticks,
169 // by token share among the unticked models only.
170 // 3. When no model has per-model ticks, remaining equals the full
171 // aggregate and every emitted model gets a token-share slice.
172 // Zero-token rows are never emitted and never count toward used ticks.
173 const topLevelCostUsd = grokCostTicksToUsd(topLevel.costUsdTicks);
174 let usedTickedCostUsd = 0;
175 let untickedTokenDenominator = 0;
176 for (const entry of modelEntries) {
177 const tokens = totalTokens(grokTotalsToUsage(entry.totals));
178 if (tokens === 0) continue;
179 if (entry.totals.costUsdTicks !== null) {
180 usedTickedCostUsd += grokCostTicksToUsd(entry.totals.costUsdTicks) ?? 0;
181 } else {
182 untickedTokenDenominator += tokens;
183 }
184 }
185 const remainingCostUsd =
186 topLevelCostUsd === null ? null : Math.max(0, topLevelCostUsd - usedTickedCostUsd);
187
188 const results: UsageRecord[] = [];
189 for (const entry of modelEntries) {
190 const totals = grokTotalsToUsage(entry.totals);
191 if (totalTokens(totals) === 0) continue;
192
193 let reportedCostUsd = grokCostTicksToUsd(entry.totals.costUsdTicks);
194 if (reportedCostUsd === null && remainingCostUsd !== null && untickedTokenDenominator > 0) {
195 reportedCostUsd = remainingCostUsd * (totalTokens(totals) / untickedTokenDenominator);
196 }
197
198 results.push({
199 provider: "grok",
200 timestampMs,
201 model: entry.model,
202 sessionId,
203 totals,
204 reportedCostUsd,
205 speed: "standard",
206 dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`,
207 });
208 }
209 return results;
210}
211
212export const grokUsageFormat: TranscriptUsageFormat<void> = {
213 selectFields: {
214 timestamp: true,
215 params: {
216 sessionId: true,
217 _meta: { agentTimestampMs: true },
218 update: { sessionUpdate: true, prompt_id: true, usage: true },
219 },
220 },
221 mightCarryUsage: (line) => line.includes('"turn_completed"'),
222 parseLine: (line) => parseGrokLine(line),
223 parseProjected: (projected) => parseGrokRecord(projected),
224};
225
226export const grokUsageReader: ProviderUsageReader<GrokSettings, Path.Path> = {
227 kind: "transcripts",
228 provider: "grok",
229 format: grokUsageFormat,
230 directories: Effect.fn("grokUsageReader.directories")(function* ({ environment }) {
231 const path = yield* Path.Path;
232 const home = expandHomePath(
233 environment.GROK_HOME?.trim() || path.join(NodeOS.homedir(), ".grok"),
234 );
235 // Sessions also ship multi-megabyte `chat_history` and `events` logs that
236 // never carry usage; only `updates.jsonl` does.
237 return [{ dir: path.resolve(home, "sessions"), fileName: "updates.jsonl" }];
238 }),
239};