Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/mobile/src/features/usage/usageProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UsageProviderKind, string> = {
claude: "Claude Code",
codex: "Codex",
grok: "Grok",
};

/**
Expand All @@ -21,5 +22,6 @@ export function useProviderColors(): Record<UsageProviderKind, string> {
return {
claude: "#d97757",
codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",
grok: scheme === "dark" ? "#8b8b8b" : "#636366",
};
}
13 changes: 13 additions & 0 deletions apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
14 changes: 13 additions & 1 deletion apps/server/src/usage/UsageService.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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,
{
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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")),
);
Comment thread
cursor[bot] marked this conversation as resolved.

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") },
];
});

Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/usage/usageAggregation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function record(overrides: Partial<UsageRecord> = {}): UsageRecord {
outputTokens: 50,
reasoningTokens: 0,
},
recordCount: 1,
reportedCostUsd: null,
dedupeKey: null,
...overrides,
Expand Down Expand Up @@ -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") })]);

Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/usage/usageAggregation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/usage/usageScanCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function record(overrides: Partial<UsageRecord> = {}): UsageRecord {
outputTokens: 50,
reasoningTokens: 0,
},
recordCount: 1,
reportedCostUsd: null,
dedupeKey: "msg_1:",
...overrides,
Expand Down Expand Up @@ -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", () => {
Expand Down
19 changes: 13 additions & 6 deletions apps/server/src/usage/usageScanCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -47,6 +47,7 @@ type SerializedRecord = readonly [
reasoningTokens: number,
dedupeKey: string | null,
reportedCostUsd: number | null,
recordCount: number,
];

interface SerializedFile {
Expand Down Expand Up @@ -96,6 +97,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache {
record.totals.reasoningTokens,
record.dedupeKey,
record.reportedCostUsd,
record.recordCount,
]),
};
}
Expand Down Expand Up @@ -134,7 +136,7 @@ export function decodeScanCache(document: unknown): ScanCache {
if (typeof raw !== "object" || raw === null) continue;
const entry = raw as Partial<SerializedFile>;
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;
Expand All @@ -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;
}
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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,
});
}
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/usage/usageTranscriptReader.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
30 changes: 29 additions & 1 deletion apps/server/src/usage/usageTranscriptReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
mightCarryUsage,
parseClaudeLine,
parseCodexLine,
parseGrokLine,
type UsageRecord,
} from "./usageTranscripts.ts";

Expand All @@ -41,8 +42,10 @@ export interface TranscriptFile {
export async function listTranscriptFiles(
root: string,
sinceMs: number,
provider?: UsageProviderKind,
): Promise<readonly TranscriptFile[]> {
const found: TranscriptFile[] = [];
const grokSubagentSessionIds = new Set<string>();

const walk = async (dir: string): Promise<void> => {
let entries;
Expand All @@ -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;
Comment thread
jakeleventhal marked this conversation as resolved.
try {
const stats = await NodeFSP.stat(child);
if (stats.mtimeMs >= sinceMs) {
Expand All @@ -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 `<parent>/subagents/<child>`. 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))),
);
}

/**
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading