diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 9a9ec5f2282d..8a0660c1c878 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -5,11 +5,12 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe * Series and table order. The chart stacks providers from the bottom in this * order, so it also fixes which band sits on top of the bars. */ -export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"]; export const PROVIDER_LABEL: Record = { claude: "Claude Code", codex: "Codex", + grok: "Grok", }; /** @@ -21,5 +22,6 @@ export function useProviderColors(): Record { return { claude: "#d97757", codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", + grok: scheme === "dark" ? "#8b8b8b" : "#636366", }; } diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts new file mode 100644 index 000000000000..30908e2e8ada --- /dev/null +++ b/apps/server/src/usage/UsageService.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { readGrokHomeOverride } from "./UsageService.ts"; + +describe("readGrokHomeOverride", () => { + it.each([undefined, "", " ", "\t\n"])("treats a blank GROK_HOME as unset (%s)", (value) => { + expect(readGrokHomeOverride({ GROK_HOME: value })).toBeUndefined(); + }); + + it("trims a configured GROK_HOME", () => { + expect(readGrokHomeOverride({ GROK_HOME: " ~/.grok-work " })).toBe("~/.grok-work"); + }); +}); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0bf131ac973b..9c5032cea7bd 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -21,6 +21,7 @@ import { type UsageSummaryInput, UsageReadError, } from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -34,6 +35,7 @@ import * as Schema from "effect/Schema"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; +import { expandHomePath } from "../pathExpansion.ts"; import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; @@ -86,6 +88,11 @@ const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema. const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); +export function readGrokHomeOverride(environment: NodeJS.ProcessEnv): string | undefined { + const value = environment["GROK_HOME"]?.trim(); + return value === "" ? undefined : value; +} + export class UsageService extends Context.Service< UsageService, { @@ -121,6 +128,7 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* ServerConfig; + const hostEnvironment = yield* HostProcessEnvironment; const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; @@ -218,10 +226,14 @@ export const make = Effect.gen(function* () { const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); + const grokHome = path.resolve( + expandHomePath(readGrokHomeOverride(hostEnvironment) ?? path.join(NodeOS.homedir(), ".grok")), + ); return [ { provider: "claude" as const, dir: claudeDir }, { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, + { provider: "grok" as const, dir: path.join(grokHome, "sessions") }, ]; }); @@ -373,7 +385,7 @@ export const make = Effect.gen(function* () { } walkedRoots.push(dir); - const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); + const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs, provider)); let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 8da4e920ac06..0476d8f94f33 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -30,6 +30,7 @@ function record(overrides: Partial = {}): UsageRecord { outputTokens: 50, reasoningTokens: 0, }, + recordCount: 1, reportedCostUsd: null, dedupeKey: null, ...overrides, @@ -172,6 +173,12 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.costSource).toBe("providerReported"); }); + it("counts every model call represented by an aggregate record", () => { + const result = aggregate([record({ recordCount: 3 })]); + + expect(result.buckets[0]?.records).toBe(3); + }); + it("drops records outside the window", () => { const result = aggregate([record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") })]); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index e100be76e979..2a8183f1552a 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -170,9 +170,10 @@ export class UsageAggregator { bucket.totals = addTotals(bucket.totals, record.totals); bucket.costUsd += priced.costUsd; bucket.cacheSavingsUsd += cacheSavingsUsd(this.#options.rates, record.model, record.totals); - bucket.records += 1; - if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; - if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; + bucket.records += record.recordCount; + if (priced.costSource === "unpriced") bucket.unpricedRecords += record.recordCount; + if (priced.costSource === "providerReported") + bucket.providerReportedRecords += record.recordCount; if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId); return true; } diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 64673e96c090..b437d7142e2d 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -22,6 +22,7 @@ function record(overrides: Partial = {}): UsageRecord { outputTokens: 50, reasoningTokens: 0, }, + recordCount: 1, reportedCostUsd: null, dedupeKey: "msg_1:", ...overrides, @@ -106,6 +107,22 @@ describe("scan cache round trip", () => { const restored = decodeScanCache(JSON.parse(JSON.stringify(poisoned))); expect(restored.has("/a.jsonl")).toBe(false); }); + + it.each([-1, 0, 1.5])("rejects invalid record counts (%s)", (recordCount) => { + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const row = encoded.files["/a.jsonl"]!.r[0]!; + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { + ...encoded.files["/a.jsonl"]!, + r: [[...row.slice(0, 10), recordCount]], + }, + }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); }); describe("pruneScanCache", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index cc15ee9cee62..ea0a3e194b06 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -18,9 +18,9 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import type { UsageRecord } from "./usageTranscripts.ts"; -// v2: Codex fork-copy suppression changed what a file parses to, so v1 -// entries would keep serving double-counted records forever. -export const USAGE_SCAN_CACHE_VERSION = 2 as const; +// v3: aggregate records gained `recordCount`, changing the serialized row +// shape. Older entries must be rescanned instead of decoded without it. +export const USAGE_SCAN_CACHE_VERSION = 4 as const; export interface CachedFile { readonly size: number; @@ -47,6 +47,7 @@ type SerializedRecord = readonly [ reasoningTokens: number, dedupeKey: string | null, reportedCostUsd: number | null, + recordCount: number, ]; interface SerializedFile { @@ -96,6 +97,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { record.totals.reasoningTokens, record.dedupeKey, record.reportedCostUsd, + record.recordCount, ]), }; } @@ -134,7 +136,7 @@ export function decodeScanCache(document: unknown): ScanCache { if (typeof raw !== "object" || raw === null) continue; const entry = raw as Partial; if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; - if (entry.p !== "claude" && entry.p !== "codex") continue; + if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; if (!isRecordArray(entry.r)) continue; const provider: UsageProviderKind = entry.p; @@ -144,7 +146,7 @@ export function decodeScanCache(document: unknown): ScanCache { // file would never be re-parsed, silently losing the dropped rows' usage. let corrupt = false; for (const row of entry.r) { - if (!isRecordArray(row) || row.length < 10) { + if (!isRecordArray(row) || row.length < 11) { corrupt = true; break; } @@ -159,6 +161,7 @@ export function decodeScanCache(document: unknown): ScanCache { reasoning, dedupeKey, reportedCostUsd, + recordCount, ] = row as SerializedRecord; const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; @@ -170,7 +173,10 @@ export function decodeScanCache(document: unknown): ScanCache { !Number.isFinite(cached) || !Number.isFinite(cacheCreation) || !Number.isFinite(output) || - !Number.isFinite(reasoning) + !Number.isFinite(reasoning) || + !Number.isFinite(recordCount) || + !Number.isInteger(recordCount) || + recordCount <= 0 ) { corrupt = true; break; @@ -189,6 +195,7 @@ export function decodeScanCache(document: unknown): ScanCache { reasoningTokens: reasoning, }, reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null, + recordCount, dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, }); } diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts new file mode 100644 index 000000000000..ba21751b96db --- /dev/null +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -0,0 +1,34 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; + +import { listTranscriptFiles } from "./usageTranscriptReader.ts"; + +describe("listTranscriptFiles", () => { + it("excludes Grok subagent ledgers already included by their parent", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-grok-usage-")); + const parent = NodePath.join(root, "workspace", "parent-session"); + const child = NodePath.join(root, "workspace", "child-session"); + + try { + await NodeFSP.mkdir(NodePath.join(parent, "subagents", "child-session"), { + recursive: true, + }); + await NodeFSP.mkdir(child, { recursive: true }); + await Promise.all([ + NodeFSP.writeFile(NodePath.join(parent, "updates.jsonl"), "parent\n"), + NodeFSP.writeFile(NodePath.join(child, "updates.jsonl"), "child\n"), + NodeFSP.writeFile(NodePath.join(parent, "subagents", "child-session", "meta.json"), "{}"), + ]); + + const files = await listTranscriptFiles(root, 0, "grok"); + + expect(files.map((file) => file.path)).toEqual([NodePath.join(parent, "updates.jsonl")]); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index c72f0c24db65..2d6f25ab9e13 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -22,6 +22,7 @@ import { mightCarryUsage, parseClaudeLine, parseCodexLine, + parseGrokLine, type UsageRecord, } from "./usageTranscripts.ts"; @@ -41,8 +42,10 @@ export interface TranscriptFile { export async function listTranscriptFiles( root: string, sinceMs: number, + provider?: UsageProviderKind, ): Promise { const found: TranscriptFile[] = []; + const grokSubagentSessionIds = new Set(); const walk = async (dir: string): Promise => { let entries; @@ -54,10 +57,22 @@ export async function listTranscriptFiles( for (const entry of entries) { const child = NodePath.join(dir, entry.name); if (entry.isDirectory()) { + if (provider === "grok" && entry.name === "subagents") { + try { + const subagents = await NodeFSP.readdir(child, { withFileTypes: true }); + for (const subagent of subagents) { + if (subagent.isDirectory()) grokSubagentSessionIds.add(subagent.name); + } + } catch { + // The directory may disappear while a session is being cleaned up. + } + continue; + } await walk(child); continue; } if (!entry.name.endsWith(".jsonl")) continue; + if (provider === "grok" && entry.name !== "updates.jsonl") continue; try { const stats = await NodeFSP.stat(child); if (stats.mtimeMs >= sinceMs) { @@ -70,7 +85,14 @@ export async function listTranscriptFiles( }; await walk(root); - return found; + if (provider !== "grok" || grokSubagentSessionIds.size === 0) return found; + + // Grok writes a subagent's ledger beside the parent session as well as + // linking it from `/subagents/`. The parent's completed-turn + // usage already includes the child, so scanning both ledgers double-counts. + return found.filter( + (file) => !grokSubagentSessionIds.has(NodePath.basename(NodePath.dirname(file.path))), + ); } /** @@ -129,6 +151,12 @@ export async function readTranscriptRecords( continue; } + if (provider === "grok") { + if (!mightCarryUsage(line, provider)) continue; + records.push(...parseGrokLine(line)); + continue; + } + if (!mightCarryUsage(line, provider)) continue; const record = parseClaudeLine(line); if (record !== null) records.push(record); diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 8f86a3d836bd..0ba3827cf5ea 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -4,6 +4,7 @@ import { initialCodexScanState, parseClaudeLine, parseCodexLine, + parseGrokLine, totalTokens, } from "./usageTranscripts.ts"; @@ -236,6 +237,157 @@ describe("parseCodexLine", () => { }); }); +describe("parseGrokLine", () => { + function grokLine( + overrides: { + costIsPartial?: boolean; + costUsdTicks?: number; + modelUsage?: Record>; + } = {}, + ): string { + return JSON.stringify({ + timestamp: 1_784_068_256, + method: "_x.ai/session/update", + params: { + sessionId: "019f62c1-26b5-7c51-85a5-526cbc9c4449", + _meta: { + eventId: "019f62c1-26b5-7c51-85a5-526cbc9c4449-46", + agentTimestampMs: 1_784_068_256_563, + }, + update: { + prompt_id: "t3-xai-prompt-1", + usage: { + costIsPartial: overrides.costIsPartial, + costUsdTicks: overrides.costUsdTicks, + modelUsage: overrides.modelUsage ?? { + "grok-4.5": { + inputTokens: 13_887, + cachedReadTokens: 11_264, + cacheCreationTokens: 64, + outputTokens: 49, + reasoningTokens: 33, + modelCalls: 2, + costUsdTicks: 111_720_000, + }, + }, + }, + }, + }, + }); + } + + it("extracts inclusive input, exact cost ticks, and prompt identity", () => { + const [record] = parseGrokLine(grokLine()); + + expect(record?.provider).toBe("grok"); + expect(record?.model).toBe("grok-4.5"); + expect(record?.sessionId).toBe("019f62c1-26b5-7c51-85a5-526cbc9c4449"); + expect(record?.timestampMs).toBe(1_784_068_256_563); + expect(record?.totals).toEqual({ + uncachedInputTokens: 2_559, + cachedInputTokens: 11_264, + cacheCreationTokens: 64, + outputTokens: 49, + reasoningTokens: 33, + }); + expect(record?.recordCount).toBe(2); + expect(record?.reportedCostUsd).toBeCloseTo(0.011172, 9); + expect(record?.dedupeKey).toBe("t3-xai-prompt-1:1784068256563:grok-4.5"); + }); + + it("deduplicates fork copies without collapsing reused synthetic prompt ids", () => { + const original = JSON.parse(grokLine()) as { + timestamp: number; + params: { + sessionId: string; + _meta: { eventId: string; agentTimestampMs: number }; + update: { usage: Record }; + }; + }; + const fork = structuredClone(original); + fork.params.sessionId = "forked-session"; + fork.params._meta.eventId = "forked-session-46"; + fork.params.update.usage["costIsPartial"] = false; + + const [originalRecord] = parseGrokLine(JSON.stringify(original)); + const [forkRecord] = parseGrokLine(JSON.stringify(fork)); + expect(forkRecord?.dedupeKey).toBe(originalRecord?.dedupeKey); + + fork.params._meta.agentTimestampMs += 1; + const [unrelatedRecord] = parseGrokLine(JSON.stringify(fork)); + expect(unrelatedRecord?.dedupeKey).not.toBe(originalRecord?.dedupeKey); + }); + + it("expands a multi-model prompt into independently priced rows", () => { + const records = parseGrokLine( + grokLine({ + modelUsage: { + "grok-build": { + inputTokens: 100, + cachedReadTokens: 20, + outputTokens: 10, + modelCalls: 1, + costUsdTicks: 10_000_000, + }, + "grok-build-fast": { + inputTokens: 200, + cachedReadTokens: 50, + outputTokens: 20, + modelCalls: 3, + costUsdTicks: 20_000_000, + }, + }, + }), + ); + + expect(records.map((record) => record.model)).toEqual(["grok-build", "grok-build-fast"]); + expect(records.map((record) => record.reportedCostUsd)).toEqual([0.001, 0.002]); + expect(records.map((record) => record.recordCount)).toEqual([1, 3]); + }); + + it("uses aggregate cost when only one model row has billable usage", () => { + const [record] = parseGrokLine( + grokLine({ + costUsdTicks: 10_000_000, + modelUsage: { + "grok-build": { inputTokens: 100, outputTokens: 10 }, + "empty-stub": { inputTokens: 0, outputTokens: 0 }, + }, + }), + ); + + expect(record?.model).toBe("grok-build"); + expect(record?.reportedCostUsd).toBe(0.001); + }); + + it("does not present a partial provider cost as complete", () => { + const [record] = parseGrokLine(grokLine({ costIsPartial: true })); + expect(record?.reportedCostUsd).toBeNull(); + }); + + it("does not deduplicate identity-less updates by their payload", () => { + const parsed = JSON.parse(grokLine()) as { + params: { + sessionId?: string; + _meta?: { eventId?: string }; + update: { prompt_id?: string }; + }; + }; + delete parsed.params.sessionId; + delete parsed.params._meta; + delete parsed.params.update.prompt_id; + + const [record] = parseGrokLine(JSON.stringify(parsed)); + + expect(record?.dedupeKey).toBeNull(); + }); + + it("ignores malformed and non-usage updates", () => { + expect(parseGrokLine("not json")).toEqual([]); + expect(parseGrokLine(JSON.stringify({ method: "session/update", params: {} }))).toEqual([]); + }); +}); + describe("totalTokens", () => { it("does not add reasoning on top of output", () => { expect( diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 49f9a1935ccc..7bc6f201009e 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -14,6 +14,8 @@ export interface UsageRecord { readonly model: string; readonly sessionId: string; readonly totals: UsageTokenTotals; + /** Distinct model responses represented by this aggregate record. */ + readonly recordCount: number; readonly reportedCostUsd: number | null; /** * Key for cross-file de-duplication, or `null` when the record is inherently @@ -68,7 +70,9 @@ export function totalTokens(totals: UsageTokenTotals): number { * an order of magnitude. */ export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { - return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"'); + if (provider === "claude") return line.includes('"usage"'); + if (provider === "codex") return line.includes('"token_count"'); + return line.includes('"modelUsage"'); } /* -------------------------------------------------------------------------- */ @@ -131,6 +135,7 @@ export function parseClaudeLine(line: string): UsageRecord | null { // Anthropic folds thinking tokens into output and does not break them out. reasoningTokens: 0, }, + recordCount: 1, reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, dedupeKey, }; @@ -289,6 +294,7 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord model: state.model, sessionId: state.sessionId, totals, + recordCount: 1, // Codex does not report cost in the rollout. reportedCostUsd: null, // Events surviving the fork-copy suppression above are unique to this @@ -297,4 +303,132 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord }; } +/* -------------------------------------------------------------------------- */ +/* Grok Build */ +/* -------------------------------------------------------------------------- */ + +const USD_TICKS_PER_DOLLAR = 10_000_000_000; + +function grokTimestampMs( + record: Record, + params: Record, +): number | null { + const meta = params["_meta"]; + if (typeof meta === "object" && meta !== null) { + const agentTimestampMs = (meta as Record)["agentTimestampMs"]; + if (typeof agentTimestampMs === "number" && Number.isFinite(agentTimestampMs)) { + return Math.trunc(agentTimestampMs); + } + } + + const timestamp = record["timestamp"]; + if (typeof timestamp === "number" && Number.isFinite(timestamp)) { + // Grok's persisted ACP updates use Unix seconds. Accept milliseconds too + // so a format precision bump does not move records thousands of years out. + return Math.trunc(timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp); + } + return parseTimestampMs(timestamp); +} + +function grokCostUsd( + usage: Record, + aggregateUsage: Record, + onlyModel: boolean, +): number | null { + if (usage["costIsPartial"] === true || aggregateUsage["costIsPartial"] === true) return null; + + const ticks = usage["costUsdTicks"] ?? (onlyModel ? aggregateUsage["costUsdTicks"] : undefined); + if (typeof ticks === "number" && Number.isFinite(ticks) && ticks >= 0) { + return ticks / USD_TICKS_PER_DOLLAR; + } + + const dollars = usage["costUSD"] ?? usage["costUsd"]; + return typeof dollars === "number" && Number.isFinite(dollars) && dollars >= 0 ? dollars : null; +} + +/** + * Parses a completed Grok ACP prompt update. + * + * One update can include several model rows when subagents participated, so it + * expands to one record per model. Grok reports input inclusive of cache reads + * and writes, matching Codex rather than the headless CLI's projected shape. + */ +export function parseGrokLine(line: string): readonly UsageRecord[] { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return []; + } + if (typeof parsed !== "object" || parsed === null) return []; + + const record = parsed as Record; + const paramsValue = record["params"]; + if (typeof paramsValue !== "object" || paramsValue === null) return []; + const params = paramsValue as Record; + const updateValue = params["update"]; + if (typeof updateValue !== "object" || updateValue === null) return []; + const update = updateValue as Record; + const aggregateUsageValue = update["usage"]; + if (typeof aggregateUsageValue !== "object" || aggregateUsageValue === null) return []; + const aggregateUsage = aggregateUsageValue as Record; + const modelUsageValue = aggregateUsage["modelUsage"]; + if (typeof modelUsageValue !== "object" || modelUsageValue === null) return []; + + const timestampMs = grokTimestampMs(record, params); + if (timestampMs === null) return []; + + const sessionId = typeof params["sessionId"] === "string" ? params["sessionId"] : ""; + const promptId = typeof update["prompt_id"] === "string" ? update["prompt_id"] : ""; + const meta = + typeof params["_meta"] === "object" && params["_meta"] !== null + ? (params["_meta"] as Record) + : null; + const eventId = typeof meta?.["eventId"] === "string" ? meta["eventId"] : null; + const rows: Array<{ + readonly model: string; + readonly usage: Record; + readonly totals: UsageTokenTotals; + }> = []; + + for (const [model, usageValue] of Object.entries(modelUsageValue as Record)) { + if (model.length === 0 || typeof usageValue !== "object" || usageValue === null) continue; + const usage = usageValue as Record; + const inputTokens = int(usage["inputTokens"]); + const cachedInputTokens = int(usage["cachedReadTokens"] ?? usage["cacheReadInputTokens"]); + const cacheCreationTokens = int(usage["cacheCreationTokens"]); + const outputTokens = int(usage["outputTokens"]); + const totals: UsageTokenTotals = { + uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens), + cachedInputTokens, + cacheCreationTokens, + outputTokens, + reasoningTokens: Math.min(outputTokens, int(usage["reasoningTokens"])), + }; + if (totalTokens(totals) === 0) continue; + rows.push({ model, usage, totals }); + } + + const records: UsageRecord[] = []; + for (const { model, usage, totals } of rows) { + records.push({ + provider: "grok", + timestampMs, + model, + sessionId, + totals, + recordCount: Math.max(1, int(usage["modelCalls"])), + reportedCostUsd: grokCostUsd(usage, aggregateUsage, rows.length === 1), + dedupeKey: + promptId.length > 0 + ? `${promptId}:${timestampMs}:${model}` + : sessionId.length > 0 || eventId !== null + ? `${sessionId}:${eventId ?? timestampMs}:${model}` + : null, + }); + } + + return records; +} + export { EMPTY_TOTALS }; diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 8e86b521e890..0ebe36eb575d 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -64,6 +64,7 @@ vi.mock("./usageProviders", async (importOriginal) => { PROVIDER_PRESENTATION: { codex: { color: "white", label: "Codex", mark: "span" }, claude: { color: "orange", label: "Claude Code", mark: "span" }, + grok: { color: "gray", label: "Grok", mark: "span" }, }, }; }); diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 09961213b0c4..d11d2e8a67be 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -48,11 +48,12 @@ describe("buildDayColumns", () => { "2026-08-01", { day: "2026-08-01", - costUsd: 30, - totalTokens: 300, + costUsd: 37, + totalTokens: 370, byProvider: new Map([ ["codex" as const, { costUsd: 10, totalTokens: 100 }], ["claude" as const, { costUsd: 20, totalTokens: 200 }], + ["grok" as const, { costUsd: 7, totalTokens: 70 }], ]), }, ], @@ -69,12 +70,12 @@ describe("buildDayColumns", () => { ]); it("plots each day on its own", () => { - expect(buildDayColumns(days, byDay, "cost").map((column) => column.total)).toEqual([30, 0, 5]); + expect(buildDayColumns(days, byDay, "cost").map((column) => column.total)).toEqual([37, 0, 5]); }); it("reads the requested metric", () => { expect(buildDayColumns(days, byDay, "tokens").map((column) => column.total)).toEqual([ - 300, 0, 50, + 370, 0, 50, ]); }); @@ -86,6 +87,7 @@ describe("buildDayColumns", () => { expect(first?.bands).toEqual([ { provider: "codex", value: 10 }, { provider: "claude", value: 20 }, + { provider: "grok", value: 7 }, ]); }); diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 79f62a65a811..b245a224904d 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -1,6 +1,6 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { ClaudeAI, type Icon, OpenAI } from "../Icons"; +import { ClaudeAI, GrokIcon, type Icon, OpenAI } from "../Icons"; type UsageProviderPresentation = { readonly label: string; @@ -24,6 +24,11 @@ export const PROVIDER_PRESENTATION = { color: "#d97757", mark: ClaudeAI, }, + grok: { + label: "Grok", + color: "var(--contrast-muted-foreground)", + mark: GrokIcon, + }, } satisfies Record; /** Stable provider reading order across charts, summaries, tables, and hover rows. */ diff --git a/docs/user/assets/usage-report-grok-simulated.jpeg b/docs/user/assets/usage-report-grok-simulated.jpeg new file mode 100644 index 000000000000..49a75d1a57e2 Binary files /dev/null and b/docs/user/assets/usage-report-grok-simulated.jpeg differ diff --git a/docs/user/usage.md b/docs/user/usage.md index 72d19ba77f37..737ed5c4b51e 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -1,10 +1,20 @@ # Review usage -The Usage page combines Codex and Claude Code activity from your connected environments. It reads +The Usage page combines Codex, Claude Code, and Grok activity from your connected environments. It reads the providers' local session history and shows API-equivalent token cost, processed tokens, cache savings, provider shares, and model breakdowns. Subscription billing is separate from the raw token cost shown here. +![Usage report with simulated Grok activity](./assets/usage-report-grok-simulated.jpeg) + +_Example report with randomized simulated Grok data._ + Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the headline and chart, and refreshing rescans every connected environment. + +Each environment sends only aggregated time buckets to the client. If several environments point at +the same transcript directory, T3 Code counts that directory once. + +Some providers include exact costs in their transcripts. For records without one, T3 Code estimates +cost from known model rates; unknown models remain visible in token totals and are marked unpriced. diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index cde888a6153e..c35b138dc902 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -2,7 +2,8 @@ * Usage reporting contract. * * Each environment scans the provider CLIs' own on-disk session transcripts - * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather than + * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`, + * `~/.grok/sessions/**\/updates.jsonl`) rather than * relying on T3 Code's own orchestration projections, so usage stays complete * even for turns that were never driven through T3 Code. This mirrors the * approach `ccusage` takes. @@ -21,9 +22,9 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 4 as const; +export const USAGE_CONTRACT_VERSION = 5 as const; -export const UsageProviderKind = Schema.Literals(["claude", "codex"]); +export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); export type UsageProviderKind = typeof UsageProviderKind.Type; /**