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 {
3434 type ClaudeAdapterV2DriverEnv,
3535} from "../../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
3636import { ProviderDriverError } from "../Errors.ts";
37import { claudeUsageReader } from "./claudeUsage.ts";
3738import { makeClaudeScopedLimitNames } from "../claudeUsageLimits.ts";
3839import * as ClaudeResetCredits from "../claudeResetCredits.ts";
3940import * as ResetCreditCoordinator from "../resetCreditCoordinator.ts";
......@@ -107,7 +108,7 @@ export type ClaudeDriverEnv =
107108 | ProviderEventLoggers.ProviderEventLoggers
108109 | ServerConfig.ServerConfig;
109110
110export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
111export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.Path> = {
111112 driverKind: DRIVER_KIND,
112113 metadata: {
113114 displayName: "Claude",
......@@ -115,6 +116,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
115116 },
116117 configSchema: ClaudeSettings,
117118 defaultConfig: (): ClaudeSettings => decodeClaudeSettings({}),
119 usage: claudeUsageReader,
118120 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
119121 Effect.gen(function* () {
120122 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
apps/server/src/provider/Drivers/CodexDriver.ts+3-1
......@@ -73,6 +73,7 @@ import {
7373 resolveCodexHomeLayout,
7474} from "./CodexHomeLayout.ts";
7575import { makeManagedCodexProvider } from "./CodexManagedProvider.ts";
76import { codexUsageReader } from "./codexUsage.ts";
7677import * as CodexInstallation from "../CodexInstallation.ts";
7778import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
7879import * as ServerEnvironment from "../../environment/ServerEnvironment.ts";
......@@ -125,7 +126,7 @@ export type CodexDriverEnv =
125126 | ServerEnvironment.ServerEnvironmentIdentity
126127 | CodexInstallation.CodexInstallation;
127128
128export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
129export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv, Path.Path> = {
129130 driverKind: DRIVER_KIND,
130131 metadata: {
131132 displayName: "Codex",
......@@ -133,6 +134,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
133134 },
134135 configSchema: CodexSettings,
135136 defaultConfig: (): CodexSettings => decodeCodexSettings({}),
137 usage: codexUsageReader,
136138 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
137139 Effect.gen(function* () {
138140 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";
3232import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server";
3333import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server";
3434import { 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
3740/**
3841 * Union of infrastructure services required to construct any built-in
......@@ -66,3 +69,18 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv
6669 MuseDriver,
6770 AcpRegistryDriver,
6871];
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";
3333import * as ServerConfig from "../config.ts";
3434import * as ServerSettings from "../serverSettings.ts";
3535import * as CursorUsageReader from "./cursorUsageReader.ts";
36import type { UsageRecord } from "./usageTranscripts.ts";
36import type { UsageRecord } from "@t3tools/provider-core/server/usage";
3737import * as UsageService from "./UsageService.ts";
3838
3939const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
......@@ -805,6 +805,8 @@ describe("UsageService", () => {
805805 },
806806 [ProviderInstanceId.make("grok-work")]: {
807807 driver: ProviderDriverKind.make("grok"),
808 // An undecodable config must not hide history Grok reads by home alone.
809 config: { customModels: "not-a-list" },
808810 environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }],
809811 },
810812 },
apps/server/src/usage/UsageService.ts+97-82
......@@ -21,8 +21,6 @@
2121import * as NodeOS from "node:os";
2222
2323import {
24 ClaudeSettings,
25 CodexSettings,
2624 type ProviderInstanceConfig,
2725 ProviderInstanceId,
2826 USAGE_CONTRACT_VERSION,
......@@ -56,9 +54,15 @@ import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
5654import * as ServerConfig from "../config.ts";
5755import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
5856import * as ServerSettings from "../serverSettings.ts";
59import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
6057import { 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";
6160import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
61import type {
62 ProviderUsageInstance,
63 TranscriptUsageFormat,
64 UsageRecord,
65} from "@t3tools/provider-core/server/usage";
6266import { readOpenCodeUsage } from "./opencodeUsageReader.ts";
6367import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts";
6468import {
......@@ -90,7 +94,6 @@ import {
9094 type CachedFile,
9195 type ScanCache,
9296} from "./usageScanCache.ts";
93import type { UsageRecord } from "./usageTranscripts.ts";
9497
9598const LITELLM_RATES_URL =
9699 "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.";
119122/** Transcripts parsed at once. More gains little once the disk stays busy. */
120123const TRANSCRIPT_READ_CONCURRENCY = 4;
121124
122const decodeCodexSettings = Schema.decodeOption(CodexSettings);
123const decodeClaudeSettings = Schema.decodeOption(ClaudeSettings);
125/** The transcript readers, in driver order. */
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
125144/** On-disk shape of the rate snapshot. */
126145const RatesCacheFile = Schema.Struct({
......@@ -145,13 +164,21 @@ function isLaterRead(a: CachedFile, b: CachedFile): boolean {
145164 return a.mtimeMs > b.mtimeMs || (a.mtimeMs === b.mtimeMs && a.size > b.size);
146165}
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
148174/**
149 * Codex sessions with records in more than one file, such as a rollout that
150 * moved after it was read. Only these need cross-file dedupe keys: within one
151 * file the occurrence count already keeps every key unique, so keying the rest
152 * would only build and hash a string for each of their records.
175 * Sessions of `sharedSessionProviders` with records in more than one file,
176 * such as a rollout that moved after it was read. Only these need cross-file
177 * dedupe keys: within one file the occurrence count already keeps every key
178 * unique, so keying the rest would only build and hash a string for each of
179 * their records.
153180 */
154function sharedCodexSessions(
181function sharedSessions(
155182 files: readonly { readonly records: readonly UsageRecord[] }[],
156183): ReadonlySet<string> {
157184 const firstFile = new Map<string, number>();
......@@ -159,7 +186,8 @@ function sharedCodexSessions(
159186 for (const [index, file] of files.entries()) {
160187 let previous = "";
161188 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;
163191 previous = sessionId;
164192 const first = firstFile.get(sessionId);
165193 if (first === undefined) firstFile.set(sessionId, index);
......@@ -218,6 +246,8 @@ export const make = Effect.gen(function* () {
218246 const hostEnvironment = yield* HostProcessEnvironment;
219247 const platform = yield* HostProcessPlatform;
220248 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
222252 const fileCache: ScanCache = new Map();
223253 const antigravityCache = makeAntigravityUsageCache();
......@@ -338,60 +368,45 @@ export const make = Effect.gen(function* () {
338368 ),
339369 );
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. */
342398 const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* (
343399 settings: ServerSettingsValue,
344400 retentionCutoffMs: number,
345401 ) {
346 const dirs: Array<{
347 provider: UsageProviderKind;
348 dir: string;
349 volumeId: string;
350 fileName?: string;
351 }> = [];
402 const dirs: Array<TranscriptSource> = [];
352403 const seen = new Set<string>();
353 for (const driver of ["claudeAgent", "codex", "grok"] as const) {
354 // Disabled accounts still have history. An unconfigured default slot
355 // runs with default config, just as it does in the provider registry.
356 const instances: Array<
357 Pick<ProviderInstanceConfig, "config" | "environment"> & { instanceId: ProviderInstanceId }
358 > = Object.entries(settings.providerInstances)
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");
404 for (const { driver, reader } of transcriptReaders) {
405 const { provider, format } = reader;
406 const directories = yield* Effect.forEach(usageInstances(driver, settings), (instance) =>
407 reader.directories(instance),
408 );
409 for (const { dir: directory, fileName } of directories.flat()) {
395410 const sourceKey = provider + "\0" + directory;
396411 const previous = sourceCache.get(sourceKey);
397412 // Keep canonical paths and source fingerprints stable after root cleanup,
......@@ -423,9 +438,10 @@ export const make = Effect.gen(function* () {
423438 seen.add(key);
424439 dirs.push({
425440 provider,
441 format,
426442 dir,
427443 volumeId,
428 ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}),
444 ...(fileName === undefined ? {} : { fileName }),
429445 });
430446 }
431447 }
......@@ -456,7 +472,9 @@ export const make = Effect.gen(function* () {
456472 cacheDirty = document !== null;
457473 }
458474 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 }
460478 const sources = decodeCachedSources(document);
461479 if (Option.isSome(sources)) {
462480 for (const [key, source] of Object.entries(sources.value.sources))
......@@ -536,6 +554,7 @@ export const make = Effect.gen(function* () {
536554 size: number,
537555 mtimeMs: number,
538556 provider: UsageProviderKind,
557 format: TranscriptUsageFormat<unknown>,
539558 ): Effect.Effect<{
540559 readonly records: readonly UsageRecord[];
541560 readonly update?: { readonly entry: CachedFile; readonly replaces: CachedFile | undefined };
......@@ -566,7 +585,7 @@ export const make = Effect.gen(function* () {
566585 : undefined;
567586
568587 const parsed = yield* Effect.promise(() =>
569 readTranscriptRecords(filePath, provider, resumeFrom),
588 readTranscriptRecords(filePath, format, resumeFrom),
570589 );
571590 // A read failure is not an empty transcript: caching it under this
572591 // (size, mtime) would silently drop the file's usage until it changes.
......@@ -611,15 +630,10 @@ export const make = Effect.gen(function* () {
611630 }
612631
613632 const scanTranscriptDir = Effect.fn("UsageService.scanTranscriptDir")(function* (
614 source: {
615 readonly provider: UsageProviderKind;
616 readonly dir: string;
617 readonly volumeId: string;
618 readonly fileName?: string;
619 },
633 source: TranscriptSource,
620634 windowStartMs: number,
621635 ) {
622 const { provider, dir, volumeId, fileName } = source;
636 const { provider, format, dir, volumeId, fileName } = source;
623637 const exists = yield* fileSystem
624638 .exists(dir)
625639 .pipe(Effect.catchCause(() => Effect.succeed(false)));
......@@ -632,7 +646,7 @@ export const make = Effect.gen(function* () {
632646 const read = yield* Effect.forEach(
633647 files,
634648 (file) =>
635 readFileRecords(file.path, file.size, file.mtimeMs, provider).pipe(
649 readFileRecords(file.path, file.size, file.mtimeMs, provider, format).pipe(
636650 Effect.map((result) => ({ path: file.path, ...result })),
637651 ),
638652 { concurrency: TRANSCRIPT_READ_CONCURRENCY },
......@@ -818,10 +832,8 @@ export const make = Effect.gen(function* () {
818832 retentionCutoffMs: number,
819833 awaitRefresh: boolean,
820834 ) {
821 // The home resolvers ask for `Path` themselves; satisfy them from the
822 // instance we already hold so the scan stays context-free.
823835 const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe(
824 Effect.provideService(Path.Path, path),
836 Effect.provideContext(readerContext),
825837 );
826838
827839 const home = NodeOS.homedir();
......@@ -1100,7 +1112,7 @@ export const make = Effect.gen(function* () {
11001112 }
11011113 return retainedFiles;
11021114 });
1103 const sharedSessions = sharedCodexSessions(filesByDir.flat());
1115 const crossFileSessions = sharedSessions(filesByDir.flat());
11041116
11051117 for (const [
11061118 index,
......@@ -1118,12 +1130,15 @@ export const make = Effect.gen(function* () {
11181130 continue;
11191131 }
11201132 scannedFiles += 1;
1121 const codexEventOccurrences = new Map<string, number>();
1133 const eventOccurrences = new Map<string, number>();
11221134 for (const record of file.records) {
11231135 let usageRecord = record;
1124 if (record.provider === "codex" && sharedSessions.has(record.sessionId)) {
1125 // Match moved rollout copies without collapsing repeated equal events
1126 // within one rollout (timestamps can have only second precision).
1136 if (
1137 sharedSessionProviders.has(record.provider) &&
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).
11271142 // Only sessions seen in several files can have a copy to match.
11281143 const key = encodeUsageRecordKey([
11291144 record.provider,
......@@ -1132,8 +1147,8 @@ export const make = Effect.gen(function* () {
11321147 record.model,
11331148 record.totals,
11341149 ]);
1135 const occurrence = (codexEventOccurrences.get(key) ?? 0) + 1;
1136 codexEventOccurrences.set(key, occurrence);
1150 const occurrence = (eventOccurrences.get(key) ?? 0) + 1;
1151 eventOccurrences.set(key, occurrence);
11371152 usageRecord = { ...record, dedupeKey: key + ":" + occurrence };
11381153 }
11391154 // 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";
55import * as NodeSqlite from "node:sqlite";
66import * as NodeTimersPromises from "node:timers/promises";
77
8import type { UsageRecord } from "./usageTranscripts.ts";
8import type { UsageRecord } from "@t3tools/provider-core/server/usage";
99
1010type FieldValue = number | bigint | Uint8Array;
1111type Fields = Map<number, FieldValue[]>;
apps/server/src/usage/cursorAccountCache.test.ts+1-1
......@@ -1,7 +1,7 @@
11import { assert, describe, it } from "@effect/vitest";
22
33import { cursorFetchRange, mergeCursorFetch } from "./cursorAccountCache.ts";
4import type { UsageRecord } from "./usageTranscripts.ts";
4import type { UsageRecord } from "@t3tools/provider-core/server/usage";
55
66const DAY_MS = 24 * 60 * 60 * 1000;
77
apps/server/src/usage/cursorAccountCache.ts+1-1
......@@ -13,7 +13,7 @@
1313 * @module cursorAccountCache
1414 */
1515import { cursorRateModel } from "./cursorUsageReader.ts";
16import type { UsageRecord } from "./usageTranscripts.ts";
16import type { UsageRecord } from "@t3tools/provider-core/server/usage";
1717
1818export 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";
88import * as Effect from "effect/Effect";
99import * as Layer from "effect/Layer";
1010
11import type { UsageRecord } from "./usageTranscripts.ts";
11import type { UsageRecord } from "@t3tools/provider-core/server/usage";
1212import {
1313 CursorKeychainTimeoutError,
1414 readMacCursorAccessToken,
apps/server/src/usage/opencodeUsageReader.ts+1-1
......@@ -5,7 +5,7 @@ import * as NodePath from "node:path";
55import * as NodeSqlite from "node:sqlite";
66import * 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
1010function object(value: unknown): Record<string, unknown> {
1111 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";
22
33import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts";
44import type { RateTable } from "./usagePricing.ts";
5import type { UsageRecord } from "./usageTranscripts.ts";
5import type { UsageRecord } from "@t3tools/provider-core/server/usage";
66
77const rates: RateTable = new Map([
88 [
apps/server/src/usage/usageAggregation.ts+1-1
......@@ -20,7 +20,7 @@ import type {
2020 UsageTokenTotals,
2121} from "@t3tools/contracts";
2222
23import { EMPTY_TOTALS, type UsageRecord } from "./usageTranscripts.ts";
23import { EMPTY_TOTALS, type UsageRecord } from "@t3tools/provider-core/server/usage";
2424import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts";
2525
2626/**
apps/server/src/usage/usagePricing.test.ts+1-1
......@@ -1,7 +1,7 @@
11import { describe, expect, it } from "@effect/vitest";
22
33import { cursorRateModel } from "./cursorUsageReader.ts";
4import type { UsageSpeed } from "./usageTranscripts.ts";
4import type { UsageSpeed } from "@t3tools/provider-core/server/usage";
55import {
66 cacheSavingsUsd,
77 createOverrideRateTable,
apps/server/src/usage/usagePricing.ts+1-1
......@@ -14,7 +14,7 @@ import type {
1414 UsageTokenTotals,
1515} from "@t3tools/contracts";
1616
17import type { UsageRecord, UsageSpeed } from "./usageTranscripts.ts";
17import type { UsageRecord, UsageSpeed } from "@t3tools/provider-core/server/usage";
1818
1919/** Token rates for one billing speed. All values are USD per token. */
2020export interface TokenRates {
apps/server/src/usage/usageScanCache.test.ts+29-16
......@@ -9,7 +9,9 @@ import {
99 type CachedFile,
1010 type ScanCache,
1111} 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
1416function record(overrides: Partial<UsageRecord> = {}): UsageRecord {
1517 return {
......@@ -36,7 +38,7 @@ function position(overrides: Partial<CachedFile["position"]> = {}): CachedFile["
3638 resumeOffset: 120,
3739 guardLength: 64,
3840 guardHash: 0xdeadbeef,
39 codexState: null,
41 state: null,
4042 ...overrides,
4143 };
4244}
......@@ -85,7 +87,7 @@ describe("scan cache round trip", () => {
8587 ],
8688 tailRecords: [],
8789 position: position({
88 codexState: {
90 state: {
8991 model: "gpt-6-astra",
9092 speed: "ultrafast",
9193 sessionId: "session-c",
......@@ -97,7 +99,10 @@ describe("scan cache round trip", () => {
9799 }),
98100 });
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
102107 expect(restored.size).toBe(4);
103108 expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl"));
......@@ -117,7 +122,9 @@ describe("scan cache round trip", () => {
117122 },
118123 };
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);
121128 });
122129
123130 it("drops an entry whose guard length is outside the supported range", () => {
......@@ -129,7 +136,9 @@ describe("scan cache round trip", () => {
129136 files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, gl: 1e20 } },
130137 };
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);
133142 });
134143
135144 it("drops an entry whose speed is not a known index", () => {
......@@ -140,14 +149,16 @@ describe("scan cache round trip", () => {
140149 files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, r: [[...row.slice(0, 10), true]] } },
141150 };
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);
144155 });
145156
146157 it("rejects a document from before records carried a speed", () => {
147158 const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]]));
148159 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);
151162 });
152163
153164 it("rewrites only changed entries and still restores the whole cache", () => {
......@@ -157,7 +168,7 @@ describe("scan cache round trip", () => {
157168 ["/b.jsonl", 200, [record({ sessionId: "session-b" })]],
158169 ]);
159170 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
162173 // The replacement adds intern entries; /a's memoised indexes must hold.
163174 cache.set("/b.jsonl", {
......@@ -166,7 +177,7 @@ describe("scan cache round trip", () => {
166177 records: [record({ sessionId: "session-c", model: "claude-opus-5-5", dedupeKey: "msg_3:" })],
167178 });
168179 const document = JSON.parse(write(cache, { sources }));
169 expect(decodeScanCache(document)).toEqual(cache);
180 expect(decodeScanCache(document, TEST_FORMAT_MAP)).toEqual(cache);
170181 expect(document.sources).toEqual(sources);
171182 });
172183
......@@ -181,9 +192,11 @@ describe("scan cache round trip", () => {
181192
182193 it("treats a corrupt or foreign document as an empty cache", () => {
183194 // A bad cache should cost one cold scan, never a broken page.
184 expect(decodeScanCache(null).size).toBe(0);
185 expect(decodeScanCache("nonsense").size).toBe(0);
186 expect(decodeScanCache({ version: 999, models: [], sessions: [], files: {} }).size).toBe(0);
195 expect(decodeScanCache(null, TEST_FORMAT_MAP).size).toBe(0);
196 expect(decodeScanCache("nonsense", TEST_FORMAT_MAP).size).toBe(0);
197 expect(
198 decodeScanCache({ version: 999, models: [], sessions: [], files: {} }, TEST_FORMAT_MAP).size,
199 ).toBe(0);
187200 });
188201
189202 it("skips malformed file entries but keeps good ones", () => {
......@@ -193,7 +206,7 @@ describe("scan cache round trip", () => {
193206 files: { ...encoded.files, "/bad.jsonl": { s: "nope", m: 1, p: "claude", r: [] } },
194207 };
195208
196 const restored = decodeScanCache(JSON.parse(JSON.stringify(withJunk)));
209 const restored = decodeScanCache(JSON.parse(JSON.stringify(withJunk)), TEST_FORMAT_MAP);
197210 expect([...restored.keys()]).toEqual(["/good.jsonl"]);
198211 });
199212
......@@ -203,7 +216,7 @@ describe("scan cache round trip", () => {
203216 const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]]));
204217 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);
207220 });
208221
209222 it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => {
......@@ -223,7 +236,7 @@ describe("scan cache round trip", () => {
223236 },
224237 };
225238
226 const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned)));
239 const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP);
227240 expect(restored.has("/a.jsonl")).toBe(false);
228241 });
229242});
apps/server/src/usage/usageScanCache.ts+52-51
......@@ -15,9 +15,14 @@
1515 * @module usageScanCache
1616 */
1717import 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
1925import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts";
20import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts.ts";
2126
2227// v2: Codex fork-copy suppression changed what a file parses to, so v1
2328// entries would keep serving double-counted records forever.
......@@ -25,7 +30,7 @@ import type { CodexScanState, UsageRecord, UsageSpeed } from "./usageTranscripts
2530// re-parses only its appended bytes instead of starting over.
2631// v4: records carry Claude fast mode, which v3 rows never captured.
2732// 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.
2934const USAGE_SCAN_CACHE_VERSION = 5 as const;
3035const SPEED_COMPATIBLE_SINCE_VERSION = 4;
3136
......@@ -41,10 +46,6 @@ export const LEGACY_SCAN_CACHE_FILE_NAME = "usage-scan-cache.json";
4146/** Serialised as the index into this list. */
4247const SPEEDS: readonly UsageSpeed[] = ["standard", "fast", "ultrafast"];
4348
44function isSpeed(value: unknown): value is UsageSpeed {
45 return SPEEDS.some((speed) => speed === value);
46}
47
4849export interface CachedFile {
4950 readonly size: number;
5051 readonly mtimeMs: number;
......@@ -92,8 +93,8 @@ interface SerializedFile {
9293 readonly o: number;
9394 readonly gl: number;
9495 readonly gh: number;
95 /** Codex reducer state at `o`; `null` for stateless providers. */
96 readonly cs: CodexScanState | null;
96 /** The format's encoded reducer state at `o`; `null` for stateless formats. */
97 readonly cs: unknown;
9798}
9899
99100interface SerializedCache {
......@@ -147,7 +148,7 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile
147148 o: entry.position.resumeOffset,
148149 gl: entry.position.guardLength,
149150 gh: entry.position.guardHash,
150 cs: entry.position.codexState,
151 cs: entry.position.state,
151152 };
152153}
153154
......@@ -210,10 +211,15 @@ function isRecordArray(value: unknown): value is readonly unknown[] {
210211 * Rebuilds the cache from a parsed document.
211212 *
212213 * 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.
214216 */
215export function decodeScanCache(document: unknown): ScanCache {
217export function decodeScanCache(
218 document: unknown,
219 formats: ReadonlyMap<UsageProviderKind, TranscriptUsageFormat<unknown>>,
220): ScanCache {
216221 const cache: ScanCache = new Map();
222 const isValidState = makeStateValidators(formats);
217223 if (typeof document !== "object" || document === null) return cache;
218224
219225 const root = document as Partial<SerializedCache>;
......@@ -300,7 +306,9 @@ export function decodeScanCache(document: unknown): ScanCache {
300306 if (typeof raw !== "object" || raw === null) continue;
301307 const entry = raw as Partial<SerializedFile>;
302308 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;
304312 if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue;
305313 // Position fields feed byte offsets and a Buffer allocation in the reader,
306314 // so anything outside their real ranges must reject the entry: a bogus
......@@ -320,27 +328,34 @@ export function decodeScanCache(document: unknown): ScanCache {
320328 ) {
321329 continue;
322330 }
323 // v4 Codex records predate service tiers, so they all priced as standard.
324 // Keep them, because the rollout may be gone, but make a live rollout
325 // re-parse whole: no file has size -1, and a zero position cannot resume.
326 const legacyCodex = entry.p === "codex" && version < USAGE_SCAN_CACHE_VERSION;
327 const codexState = legacyCodex ? null : decodeCodexState(entry.cs);
328 if (codexState === undefined) continue;
331 // v4 records of stateful formats (Codex) predate service tiers, so they
332 // all priced as standard. Keep them, because the rollout may be gone, but
333 // make a live rollout re-parse whole: no file has size -1, and a zero
334 // position cannot resume.
335 const legacy = format.state !== undefined && version < USAGE_SCAN_CACHE_VERSION;
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;
331341 const records = decodeRecords(entry.r, provider);
332342 const tailRecords = decodeRecords(entry.t, provider);
333343 if (records === null || tailRecords === null) continue;
334344
335345 cache.set(path, {
336 size: legacyCodex ? -1 : entry.s,
346 size: legacy ? -1 : entry.s,
337347 mtimeMs: entry.m,
338348 provider,
339349 records,
340350 tailRecords,
341 position: legacyCodex
342 ? { resumeOffset: 0, guardLength: 0, guardHash: 0, codexState: null }
343 : { resumeOffset: entry.o, guardLength: entry.gl, guardHash: entry.gh, codexState },
351 position: legacy
352 ? { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }
353 : {
354 resumeOffset: entry.o,
355 guardLength: entry.gl,
356 guardHash: entry.gh,
357 state: entry.cs,
358 },
344359 });
345360 }
346361
......@@ -348,35 +363,21 @@ export function decodeScanCache(document: unknown): ScanCache {
348363}
349364
350365/**
351 * Validates a persisted Codex reducer state. Returns `undefined` for a corrupt
352 * value, which disqualifies the entry: resuming with a bad state would attach
353 * appended usage to the wrong model or replay fork-copied history.
366 * Per provider, whether a persisted state is valid: `null`, or one the
367 * format's schema accepts.
354368 */
355function decodeCodexState(value: unknown): CodexScanState | null | undefined {
356 if (value === null) return null;
357 if (typeof value !== "object") return undefined;
358 const state = value as Partial<CodexScanState>;
359 if (
360 typeof state.model !== "string" ||
361 !isSpeed(state.speed) ||
362 typeof state.sessionId !== "string" ||
363 (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") ||
364 typeof state.sawSessionMeta !== "boolean" ||
365 typeof state.suppressingForkCopies !== "boolean" ||
366 typeof state.forkCopyAnchorMs !== "number" ||
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 };
369function makeStateValidators(
370 formats: ReadonlyMap<UsageProviderKind, TranscriptUsageFormat<unknown>>,
371): ReadonlyMap<UsageProviderKind, (value: unknown) => boolean> {
372 return new Map(
373 [...formats].map(([provider, format]) => {
374 const isState = format.state === undefined ? undefined : Schema.is(format.state.schema);
375 return [
376 provider,
377 (value: unknown) => value === null || (isState !== undefined && isState(value)),
378 ] as const;
379 }),
380 );
380381}
381382
382383/** 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";
88
99import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest";
1010
11import { TEST_FORMATS } from "./usageTestFormats.ts";
1112import { readTranscriptRecords } from "./usageTranscriptReader.ts";
1213import { readOpenCodeUsage } from "./opencodeUsageReader.ts";
1314import { readCursorAccountUsage } from "./cursorUsageReader.ts";
......@@ -99,20 +100,20 @@ describe("readTranscriptRecords resume", () => {
99100 it("parses only appended lines when resuming a grown file", async () => {
100101 const path = NodePath.join(dir, "claude.jsonl");
101102 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);
103104 assert.isNotNull(first);
104105 assert.strictEqual(first.records.length, 2);
105106 assert.isFalse(first.resumed);
106107
107108 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);
109110 assert.isNotNull(second);
110111 assert.isTrue(second.resumed);
111112 assert.strictEqual(second.records.length, 1);
112113 assert.strictEqual(second.records[0]?.totals.outputTokens, 11);
113114
114115 // 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);
116117 assert.isNotNull(full);
117118 assert.deepStrictEqual([...first.records, ...second.records], [...full.records]);
118119 });
......@@ -123,7 +124,7 @@ describe("readTranscriptRecords resume", () => {
123124 path,
124125 codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexTierLine("ultrafast"),
125126 );
126 const first = await readTranscriptRecords(path, "codex");
127 const first = await readTranscriptRecords(path, TEST_FORMATS.codex);
127128 assert.isNotNull(first);
128129 assert.strictEqual(first.records.length, 0);
129130
......@@ -131,7 +132,7 @@ describe("readTranscriptRecords resume", () => {
131132 // session_meta of its own; model, tier, and session must come from the
132133 // state captured before the boundary.
133134 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);
135136 assert.isNotNull(second);
136137 assert.isTrue(second.resumed);
137138 assert.strictEqual(second.records.length, 1);
......@@ -146,14 +147,14 @@ describe("readTranscriptRecords resume", () => {
146147 path,
147148 codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexUsageLine(9, 5),
148149 );
149 const first = await readTranscriptRecords(path, "codex");
150 const first = await readTranscriptRecords(path, TEST_FORMATS.codex);
150151 assert.isNotNull(first);
151152 assert.strictEqual(first.records.length, 1);
152153
153154 // Codex re-emits an unchanged token_count on stream boundaries; the copy
154155 // lands after the resume point and must still be dropped.
155156 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);
157158 assert.isNotNull(second);
158159 assert.isTrue(second.resumed);
159160 assert.deepStrictEqual(
......@@ -166,7 +167,7 @@ describe("readTranscriptRecords resume", () => {
166167 const path = NodePath.join(dir, "claude.jsonl");
167168 const unterminated = claudeLine(2, 7).trimEnd();
168169 await NodeFSP.writeFile(path, claudeLine(1, 5) + unterminated);
169 const first = await readTranscriptRecords(path, "claude");
170 const first = await readTranscriptRecords(path, TEST_FORMATS.claude);
170171 assert.isNotNull(first);
171172 assert.strictEqual(first.records.length, 1);
172173 assert.strictEqual(first.tailRecords.length, 1);
......@@ -175,7 +176,7 @@ describe("readTranscriptRecords resume", () => {
175176 // Completing the line and appending another re-reads from the resume
176177 // point, so the once-tail record arrives exactly once as a line record.
177178 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);
179180 assert.isNotNull(second);
180181 assert.isTrue(second.resumed);
181182 assert.deepStrictEqual(
......@@ -188,12 +189,12 @@ describe("readTranscriptRecords resume", () => {
188189 it("re-parses from the start when the guard bytes no longer match", async () => {
189190 const path = NodePath.join(dir, "claude.jsonl");
190191 await NodeFSP.writeFile(path, claudeLine(1, 5));
191 const first = await readTranscriptRecords(path, "claude");
192 const first = await readTranscriptRecords(path, TEST_FORMATS.claude);
192193 assert.isNotNull(first);
193194
194195 // Same path, larger size, different content: a replaced file, not growth.
195196 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);
197198 assert.isNotNull(second);
198199 assert.isFalse(second.resumed);
199200 assert.deepStrictEqual(
......@@ -205,11 +206,11 @@ describe("readTranscriptRecords resume", () => {
205206 it("re-parses from the start when the file shrank below the resume point", async () => {
206207 const path = NodePath.join(dir, "claude.jsonl");
207208 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);
209210 assert.isNotNull(first);
210211
211212 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);
213214 assert.isNotNull(second);
214215 assert.isFalse(second.resumed);
215216 assert.deepStrictEqual(
......@@ -236,7 +237,7 @@ describe("readTranscriptRecords resume", () => {
236237 })}\n`;
237238 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);
240241 assert.isNotNull(parsed);
241242 assert.deepStrictEqual(
242243 parsed.records.map((record) => record.totals.outputTokens),
......@@ -245,7 +246,9 @@ describe("readTranscriptRecords resume", () => {
245246 });
246247
247248 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 );
249252 });
250253});
251254
apps/server/src/usage/usageTranscriptReader.ts+45-101
......@@ -19,23 +19,16 @@ import * as NodeFSP from "node:fs/promises";
1919import * as NodePath from "node:path";
2020import * 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
2430import { 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
3932export interface TranscriptFile {
4033 readonly path: string;
4134 readonly size: number;
......@@ -60,8 +53,11 @@ export interface TranscriptParsePosition {
6053 readonly guardLength: number;
6154 /** FNV-1a hash of that window. */
6255 readonly guardHash: number;
63 /** Codex reducer state as of `resumeOffset`; `null` for stateless providers. */
64 readonly codexState: CodexScanState | null;
56 /**
57 * The format's reducer state as of `resumeOffset`, encoded by its schema;
58 * `null` for stateless formats.
59 */
60 readonly state: unknown;
6561}
6662
6763export interface TranscriptParseResult {
......@@ -89,45 +85,7 @@ const NEWLINE = 0x0a;
8985const STAT_CONCURRENCY = 32;
9086const CARRIAGE_RETURN = 0x0d;
9187
92type SelectedFields = { readonly [key: string]: true | 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"];
88function selectUsageFields(fields: SelectedFields) {
13189 return (path: ReadonlyArray<string | number | null>): boolean => {
13290 let selected: true | SelectedFields = fields;
13391 for (const key of path) {
......@@ -257,14 +215,12 @@ async function guardMatches(
257215 * still match, so only appended lines are read; otherwise the whole file is
258216 * re-parsed from the start and `resumed` reports `false`.
259217 *
260 * Codex carries the active model on `turn_context` lines and the service tier
261 * on `thread_settings_applied` lines. Neither holds usage of its own, but both
262 * still have to pass through the reducer to keep attribution and pricing
263 * correct.
218 * A stateful format resumes with the state saved at that position; a saved
219 * state its schema rejects re-parses the file whole.
264220 */
265export async function readTranscriptRecords(
221export async function readTranscriptRecords<State>(
266222 filePath: string,
267 provider: UsageProviderKind,
223 format: TranscriptUsageFormat<State>,
268224 resumeFrom?: TranscriptParsePosition,
269225 options?: { readonly streamingThresholdBytes?: number },
270226): Promise<TranscriptParseResult | null> {
......@@ -277,41 +233,31 @@ export async function readTranscriptRecords(
277233 }
278234
279235 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();
281241 let resumed = false;
282242 let start = 0;
243 const savedState =
244 resumeFrom === undefined || decodeState === undefined || resumeFrom.state === null
245 ? Option.none()
246 : decodeState(resumeFrom.state);
283247 if (
284248 resumeFrom !== undefined &&
285249 resumeFrom.resumeOffset > 0 &&
286 (provider !== "codex" || resumeFrom.codexState !== null) &&
250 (codec === undefined || Option.isSome(savedState)) &&
287251 (await guardMatches(handle, resumeFrom))
288252 ) {
289 if (resumeFrom.codexState !== null) codexState = { ...resumeFrom.codexState };
253 if (Option.isSome(savedState)) state = savedState.value;
290254 start = resumeFrom.resumeOffset;
291255 resumed = true;
292256 }
293257
294 const parseLine = (line: string, state: CodexScanState, out: UsageRecord[]): void => {
295 if (provider === "codex") {
296 if (
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);
258 const parseLine = (line: string, lineState: State, out: UsageRecord[]): void => {
259 if (!format.mightCarryUsage(line)) return;
260 for (const record of format.parseLine(line, lineState)) out.push(record);
315261 };
316262
317263 const toLineString = (lineBuffer: Buffer): string => {
......@@ -331,7 +277,7 @@ export async function readTranscriptRecords(
331277 let pendingBytes = 0;
332278 let streaming: ReturnType<typeof createTranscriptJsonReader> | undefined;
333279 let decoder: NodeStringDecoder.StringDecoder | undefined;
334 const selectPath = selectUsageFields(provider);
280 const selectPath = selectUsageFields(format.selectFields);
335281
336282 const append = (segment: Buffer) => {
337283 if (!streaming && pendingBytes + segment.length <= streamingThresholdBytes) {
......@@ -350,25 +296,17 @@ export async function readTranscriptRecords(
350296 }
351297 streaming.write(decoder!.write(segment));
352298 };
353 const finish = (state: CodexScanState, out: UsageRecord[]) => {
299 const finish = (lineState: State, out: UsageRecord[]) => {
354300 if (streaming) {
355301 streaming.write(decoder!.end());
356302 const projected = streaming.finish();
357 if (provider === "grok") {
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 }
303 for (const record of format.parseProjected(projected, lineState)) out.push(record);
366304 } else if (pendingBytes > 0) {
367305 const line =
368306 pendingChunks.length === 1
369307 ? pendingChunks[0]!
370308 : Buffer.concat(pendingChunks, pendingBytes);
371 parseLine(toLineString(line), state, out);
309 parseLine(toLineString(line), lineState, out);
372310 }
373311 pendingChunks = [];
374312 pendingBytes = 0;
......@@ -391,10 +329,10 @@ export async function readTranscriptRecords(
391329 // Most lines fit in the current chunk. Avoid buffering/streaming
392330 // machinery on this hot path.
393331 if (!streaming && pendingBytes === 0) {
394 parseLine(toLineString(chunk.subarray(lineStart, newlineIndex)), codexState, records);
332 parseLine(toLineString(chunk.subarray(lineStart, newlineIndex)), state, records);
395333 } else {
396334 append(chunk.subarray(lineStart, newlineIndex));
397 finish(codexState, records);
335 finish(state, records);
398336 }
399337 lineStart = newlineIndex + 1;
400338 resumeOffset = scanOffset + lineStart;
......@@ -402,8 +340,14 @@ export async function readTranscriptRecords(
402340 scanOffset += chunk.length;
403341 }
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);
405346 const tailRecords: UsageRecord[] = [];
406 finish({ ...codexState }, tailRecords);
347 finish(
348 decodeState === undefined ? state : Option.getOrThrow(decodeState(encodedState)),
349 tailRecords,
350 );
407351
408352 const guardLength = Math.min(GUARD_LENGTH, resumeOffset);
409353 let guardHash = 0;
......@@ -420,7 +364,7 @@ export async function readTranscriptRecords(
420364 resumeOffset,
421365 guardLength,
422366 guardHash,
423 codexState: provider === "codex" ? codexState : null,
367 state: encodedState,
424368 },
425369 resumed,
426370 };
apps/server/src/usage/usageTranscriptStreaming.test.ts+6-2
......@@ -5,6 +5,7 @@ import * as NodePath from "node:path";
55
66import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
77
8import { TEST_FORMATS } from "./usageTestFormats.ts";
89import {
910 readTranscriptRecords as readWithDefaultThreshold,
1011 type TranscriptParsePosition,
......@@ -16,7 +17,10 @@ const readTranscriptRecords = (
1617 path: string,
1718 provider: "claude" | "codex" | "grok",
1819 position?: TranscriptParsePosition,
19) => readWithDefaultThreshold(path, provider, position, { streamingThresholdBytes: 256 * 1024 });
20) =>
21 readWithDefaultThreshold(path, TEST_FORMATS[provider], position, {
22 streamingThresholdBytes: 256 * 1024,
23 });
2024
2125let dir: string;
2226beforeEach(async () => {
......@@ -144,7 +148,7 @@ describe("large usage records", () => {
144148 );
145149 const actual = await scan(large, provider);
146150 expect(actual.records).toEqual(expected.records);
147 expect(actual.position.codexState).toEqual(expected.position.codexState);
151 expect(actual.position.state).toEqual(expected.position.state);
148152 }
149153 },
150154 );
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 @@
175175 "types": "./src/server/threadTitleContext.ts",
176176 "import": "./src/server/threadTitleContext.ts"
177177 },
178 "./server/usage": {
179 "types": "./src/server/usage.ts",
180 "import": "./src/server/usage.ts"
181 },
178182 "./server/usageLimits": {
179183 "types": "./src/server/usageLimits.ts",
180184 "import": "./src/server/usageLimits.ts"
packages/provider-core/src/server/driver.ts+12-2
......@@ -42,6 +42,7 @@ import type { ProviderAuthController } from "./auth.ts";
4242import type { ProviderDriverError } from "./errors.ts";
4343import type { ManagedServerProvider } from "./snapshot.ts";
4444import type { ProviderTextGeneration } from "./textGeneration.ts";
45import type { ProviderUsageReader } from "./usage.ts";
4546
4647/**
4748 * Static metadata advertised by a driver. Used for default presentation
......@@ -172,7 +173,7 @@ export interface ProviderDriverCreateInput<Config> {
172173 * scope closes. Two calls to `create` with different `instanceId` /
173174 * `config` MUST yield instances with no shared mutable state.
174175 */
175export interface ProviderDriver<Config, R = never> {
176export interface ProviderDriver<Config, R = never, UsageR = never> {
176177 readonly driverKind: ProviderDriverKind;
177178 readonly metadata: ProviderDriverMetadata;
178179 /**
......@@ -207,6 +208,11 @@ export interface ProviderDriver<Config, R = never> {
207208 readonly create: (
208209 input: ProviderDriverCreateInput<Config>,
209210 ) => 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>;
210216}
211217
212218/**
......@@ -219,4 +225,8 @@ export interface ProviderDriver<Config, R = never> {
219225// needs the original `Config` type. Using `unknown` instead would force
220226// `create` callers into casts since `unknown` is not assignable to a
221227// 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 @@
1515 "types": "./src/server.ts",
1616 "import": "./src/server.ts"
1717 },
18 "./server/usage": {
19 "types": "./src/server/usage.ts",
20 "import": "./src/server/usage.ts"
21 },
1822 "./testing": {
1923 "types": "./src/testing.ts",
2024 "import": "./src/testing.ts"
packages/provider-grok/src/server/driver.ts+3-1
......@@ -19,6 +19,7 @@ import {
1919 checkGrokProviderStatus,
2020 enrichGrokSnapshot,
2121} from "./status.ts";
22import { grokUsageReader } from "./usage.ts";
2223import { readGrokAccount } from "./usageLimits.ts";
2324import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
2425import {
......@@ -82,7 +83,7 @@ export type GrokDriverEnv =
8283 | Path.Path
8384 | ProviderEventLoggers.ProviderEventLoggers;
8485
85export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
86export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv, Path.Path> = {
8687 driverKind: DRIVER_KIND,
8788 metadata: {
8889 displayName: "Grok",
......@@ -90,6 +91,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
9091 },
9192 configSchema: GrokSettings,
9293 defaultConfig: (): GrokSettings => decodeGrokSettings({}),
94 usage: grokUsageReader,
9395 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
9496 Effect.gen(function* () {
9597 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};