Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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 = 3 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
9 changes: 9 additions & 0 deletions 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,6 +42,7 @@ export interface TranscriptFile {
export async function listTranscriptFiles(
root: string,
sinceMs: number,
provider?: UsageProviderKind,
): Promise<readonly TranscriptFile[]> {
const found: TranscriptFile[] = [];

Expand All @@ -58,6 +60,7 @@ export async function listTranscriptFiles(
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 Down Expand Up @@ -129,6 +132,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