Skip to content
Closed
31 changes: 21 additions & 10 deletions apps/mobile/src/features/usage/usageProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,36 @@ 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.
* order, so it also fixes which band sits on top of the bars. Declaration order
* here is the single source of that order, and `satisfies` keeps it exhaustive:
* a new provider in the contract fails to compile until it is listed.
*/
export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"];

export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {
claude: "Claude Code",
export const PROVIDER_LABEL = {
codex: "Codex",
claude: "Claude Code",
grok: "Grok Build",
};
opencode: "OpenCode",
} satisfies Record<UsageProviderKind, string>;

export const PROVIDER_ORDER = Object.keys(PROVIDER_LABEL) as readonly UsageProviderKind[];

/**
* Claude's brand orange holds in both themes; Codex and Grok are neutrals and
* must flip with the theme or their bars vanish against the matching background.
* Claude's brand orange holds in both themes; the rest are neutrals and must
* flip with the theme or their bars vanish against the matching background.
* Codex, Grok and OpenCode all have monochrome marks, so they share one zinc
* ramp and step away from the foreground in declaration order rather than
* borrowing hues they do not own — the same reasoning the web usage screen
* encodes as 100% / 72% / 46% of `--contrast-foreground`.
*/
export function useProviderColors(): Record<UsageProviderKind, string> {
const { themeAppearance: scheme } = useAppearancePreferences();
const dark = scheme === "dark";
return {
claude: "#d97757",
codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",
grok: scheme === "dark" ? "#a1a1aa" : "#52525b",
codex: dark ? "#e6e6e6" : "#3c3c43",
grok: dark ? "#a1a1aa" : "#52525b",
// zinc-500: one stop past Grok in both directions, so the ramp converges
// to a mid grey that reads on the dark card and on white alike.
opencode: "#71717a",
};
}
156 changes: 155 additions & 1 deletion apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,55 @@ function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"):
})}\n`;
}

/** Epoch ms inside the test window, so OpenCode rows land on a known day. */
const IN_WINDOW_MS = Date.parse("2026-08-01T10:00:00Z");

/** Shaped after a real OpenCode assistant `message` row's `data` payload. */
function opencodePayload(
overrides: { role?: string; cost?: number; tokens?: Record<string, unknown> } = {},
): string {
return JSON.stringify({
role: overrides.role ?? "assistant",
time: { created: IN_WINDOW_MS, completed: IN_WINDOW_MS + 4_000 },
modelID: "gpt-5.1-codex-max",
providerID: "openai",
cost: overrides.cost ?? 0.005,
tokens: overrides.tokens ?? {
input: 1000,
output: 200,
reasoning: 50,
cache: { read: 400, write: 20 },
},
finish: "tool-calls",
});
}

/** Builds a temporary OpenCode database — never the developer's real one. */
async function seedOpenCodeDatabase(
dbPath: string,
rows: readonly { id: string; sessionId: string; timeCreated: number; data: string }[],
): Promise<void> {
const { DatabaseSync } = await import("node:sqlite");
await NodeFSP.mkdir(NodePath.dirname(dbPath), { recursive: true });
const db = new DatabaseSync(dbPath);
try {
db.exec(`CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
)`);
for (const row of rows) {
db.prepare(
"INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)",
).run(row.id, row.sessionId, row.timeCreated, row.timeCreated, row.data);
}
} finally {
db.close();
}
}

const WINDOW: UsageSummaryInput = {
timeZone: "UTC",
sinceDay: UsageDay.make("2026-07-31"),
Expand Down Expand Up @@ -89,7 +138,10 @@ const serviceLayers = (input: {
),
),
Layer.provideMerge(
Layer.succeed(HostProcessEnvironment, { GROK_HOME: NodePath.join(input.home, "grok") }),
Layer.succeed(HostProcessEnvironment, {
GROK_HOME: NodePath.join(input.home, "grok"),
XDG_DATA_HOME: NodePath.join(input.home, "xdg"),
}),
),
);

Expand Down Expand Up @@ -159,6 +211,108 @@ describe("UsageService", () => {
}).pipe(Effect.scoped),
);

it.live("reports OpenCode usage from its SQLite database beside the file sources", () =>
Effect.gen(function* () {
const { transcript, settings, home } = yield* setup;
yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5)));

const dbPath = NodePath.join(home, "xdg", "opencode", "opencode.db");
yield* Effect.promise(() =>
seedOpenCodeDatabase(dbPath, [
{
id: "msg_oc_1",
sessionId: "oc-session-1",
timeCreated: IN_WINDOW_MS,
data: opencodePayload(),
},
{
id: "msg_oc_2",
sessionId: "oc-session-2",
timeCreated: IN_WINDOW_MS + 60_000,
data: opencodePayload(),
},
// Non-assistant and zero-usage rows must not become records.
{
id: "msg_oc_3",
sessionId: "oc-session-3",
timeCreated: IN_WINDOW_MS + 120_000,
data: opencodePayload({ role: "user" }),
},
{
id: "msg_oc_4",
sessionId: "oc-session-4",
timeCreated: IN_WINDOW_MS + 180_000,
data: opencodePayload({ cost: 0, tokens: {} }),
},
]),
);

const service = yield* UsageService.make.pipe(
Effect.provide(serviceLayers({ prefix: "usage-service-opencode-test", home, settings })),
);
const summary = yield* service.readSummary(WINDOW);

const opencodeBuckets = summary.buckets.filter(
(bucket) => bucket.provider === "opencode",
);
assert.lengthOf(opencodeBuckets, 1);
const opencode = opencodeBuckets[0]!;
// outputTokens = 200 visible + 50 reasoning per row; OpenCode reports
// the two separately and the subset invariant must hold.
assert.deepStrictEqual(opencode.totals, {
uncachedInputTokens: 2000,
cachedInputTokens: 800,
cacheCreationTokens: 40,
outputTokens: 500,
reasoningTokens: 100,
});
assert.closeTo(opencode.costUsd, 0.01, 1e-12);
assert.strictEqual(opencode.costSource, "providerReported");
assert.strictEqual(opencode.sessions, 2);

const opencodeSource = summary.sources.find(
(source) => source.fingerprint.provider === "opencode",
);
assert.strictEqual(opencodeSource?.status, "ok");
assert.strictEqual(opencodeSource?.distinctSessions, 2);
assert.strictEqual(opencodeSource?.scannedFiles, 1);
assert.strictEqual(opencodeSource?.fingerprint.resolvedHomePath, dbPath);

// The file sources still report alongside it.
assert.exists(summary.buckets.find((bucket) => bucket.provider === "claude"));
}).pipe(Effect.scoped),
);

it.live("keeps the other providers when the OpenCode database is unreadable", () =>
Effect.gen(function* () {
const { transcript, settings, home } = yield* setup;
yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5)));

const dbPath = NodePath.join(home, "xdg", "opencode", "opencode.db");
yield* Effect.promise(() =>
NodeFSP.mkdir(NodePath.dirname(dbPath), { recursive: true }),
);
yield* Effect.promise(() => NodeFSP.writeFile(dbPath, "this is not a database"));

const service = yield* UsageService.make.pipe(
Effect.provide(serviceLayers({ prefix: "usage-service-opencode-failed-test", home, settings })),
);
const summary = yield* service.readSummary(WINDOW);

const opencodeSource = summary.sources.find(
(source) => source.fingerprint.provider === "opencode",
);
assert.strictEqual(opencodeSource?.status, "failed");
assert.isNotNull(opencodeSource?.message);
assert.strictEqual(
summary.buckets.find((bucket) => bucket.provider === "opencode"),
undefined,
);
// The whole read still succeeded and Claude still reports.
assert.exists(summary.buckets.find((bucket) => bucket.provider === "claude"));
}).pipe(Effect.scoped),
);

it.live("does not share an in-flight scan after custom prices change", () =>
Effect.gen(function* () {
const { transcript, settings, home } = yield* setup;
Expand Down
105 changes: 101 additions & 4 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
* The scan reads the provider CLIs' own session files (Claude Code, Codex, and
* Grok Build) rather than T3 Code's orchestration projections, so usage covers
* turns driven outside T3 Code too. This is the approach `ccusage` takes.
* OpenCode is the one provider without transcript files: its usage lives in a
* SQLite database, read by a sibling source in `usageOpenCodeDatabase.ts` that
* folds into the same aggregation.
*
* Transcripts are append-only, so parsed records are memoised per file by
* `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm
Expand Down Expand Up @@ -45,6 +48,11 @@ import * as ServerSettings from "../serverSettings.ts";
import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts";
import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
import { UsageAggregator } from "./usageAggregation.ts";
import {
createOpenCodeScanState,
resolveOpenCodeDatabasePath,
scanOpenCodeDatabase,
} from "./usageOpenCodeDatabase.ts";
import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
import {
listTranscriptFiles,
Expand Down Expand Up @@ -143,6 +151,10 @@ export const make = Effect.gen(function* () {
const fileCache: ScanCache = new Map();
let cacheDirty = false;

// OpenCode's incremental state sits beside the file scan cache: same shape
// of problem (append-only source, cheap change gate), different medium.
const openCodeState = createOpenCodeScanState();

const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json");
const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json");
let rates: RateTable = new Map();
Expand Down Expand Up @@ -410,6 +422,40 @@ export const make = Effect.gen(function* () {
return scanned;
});

/**
* OpenCode publishes usage through its own SQLite database rather than JSONL
* transcripts, so it is a sibling source to the directory scan rather than an
* entry in it: byte-offset resume is meaningless for a database, and the
* scan cache is keyed on transcript files. Incrementality comes from the
* high-water mark the scan state tracks instead.
*/
const collectOpenCodeSource = Effect.fn("UsageService.collectOpenCodeSource")(function* (
retentionCutoffMs: number,
) {
const dbPath = resolveOpenCodeDatabasePath({
xdgDataHome: hostEnvironment["XDG_DATA_HOME"],
homedir: NodeOS.homedir(),
});
const outcome = yield* Effect.promise(() =>
scanOpenCodeDatabase(dbPath, openCodeState, { retentionCutoffMs }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium usage/UsageService.ts:440

Successive usage reports omit newly committed OpenCode tokens and cost while the process is running. scanOpenCodeDatabase's unchanged-database fast path checks only opencode.db, but SQLite WAL-mode commits update opencode.db-wal without changing the main file, so the persistent openCodeState reuses stale records until a checkpoint modifies opencode.db; include the WAL sidecar in the change gate (or otherwise invalidate the cache when WAL content changes).

Also found in 1 other location(s)

apps/server/src/usage/usageOpenCodeDatabase.ts:265

The unchanged-file fast path watches only opencode.db, but the reader explicitly supports OpenCode writing through SQLite WAL. In WAL mode, SQLite commits by appending to opencode.db-wal and can leave the original database file unchanged until a checkpoint. Consequently a normal new OpenCode message can change only the -wal sidecar, satisfy this size/mtime comparison, and return the stale cache; usage remains absent until a checkpoint eventually modifies the main file.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/usage/UsageService.ts around line 440:

Successive usage reports omit newly committed OpenCode tokens and cost while the process is running. `scanOpenCodeDatabase`'s unchanged-database fast path checks only `opencode.db`, but SQLite WAL-mode commits update `opencode.db-wal` without changing the main file, so the persistent `openCodeState` reuses stale records until a checkpoint modifies `opencode.db`; include the WAL sidecar in the change gate (or otherwise invalidate the cache when WAL content changes).

Also found in 1 other location(s):
- apps/server/src/usage/usageOpenCodeDatabase.ts:265 -- The unchanged-file fast path watches only `opencode.db`, but the reader explicitly supports OpenCode writing through SQLite WAL. In WAL mode, SQLite commits by appending to `opencode.db-wal` and can leave the original database file unchanged until a checkpoint. Consequently a normal new OpenCode message can change only the `-wal` sidecar, satisfy this size/mtime comparison, and return the stale cache; usage remains absent until a checkpoint eventually modifies the main file.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, and reproduced first: with a writer connection open, a commit leaves opencode.db byte-identical and only grows the -wal sidecar, so the gate really did serve stale records until a checkpoint. The sidecar's size and mtime are now part of the change check, and a missing sidecar is recorded as a valid state rather than an error, since not every database is in WAL mode. A test asserts the main file did not move before checking that the cache was invalidated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

).pipe(
// One source must never fail the scan: a defective OpenCode read
// resolves to a failed outcome and is reported as a source, the way a
// missing transcript directory is, instead of taking Claude and Codex
// reporting down with it.
Effect.catchCause((cause) =>
Effect.logWarning("OpenCode usage scan failed unexpectedly", Cause.squash(cause)).pipe(
Effect.as({
status: "failed" as const,
volumeId: "",
detail: "OpenCode database could not be read.",
}),
),
),
);
return { dbPath, outcome };
});

const scanSummary = Effect.fn("UsageService.scanSummary")(function* (
input: UsageSummaryInput,
settings: ServerSettingsValue,
Expand Down Expand Up @@ -462,9 +508,14 @@ export const make = Effect.gen(function* () {
// Pricing only matters once records are aggregated, so the rate table
// loads while transcripts stream instead of gating them: a cold rates
// fetch on a slow network no longer delays the scan by its own timeout.
const [, scannedDirs] = yield* Effect.all(
[ensureRates(false), collectDirs(windowStartMs, settings)],
{ concurrency: 2 },
const retentionCutoffMs = startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000;
const [, scannedDirs, openCode] = yield* Effect.all(
[
ensureRates(false),
collectDirs(windowStartMs, settings),
collectOpenCodeSource(retentionCutoffMs),
],
{ concurrency: 3 },
);

const aggregator = new UsageAggregator({
Expand Down Expand Up @@ -529,11 +580,57 @@ export const make = Effect.gen(function* () {
});
}

// The OpenCode source folds into the same aggregator, so dedupe, day
// bucketing, pricing and session counting stay shared with the file
// sources; no aggregation rule knows it exists.
const openCodeSessionIds = new Set<string>();
if (openCode.outcome.status === "ok") {
for (const record of openCode.outcome.records) {
// Only sessions that contributed in-window count, matching the
// directory sources above.
if (aggregator.add(record) && record.sessionId.length > 0) {
openCodeSessionIds.add(record.sessionId);
}
}
sources.push({
fingerprint: {
hostId,
provider: "opencode",
resolvedHomePath: openCode.dbPath,
volumeId: openCode.outcome.volumeId,
},
status: "ok",
scannedFiles: 1,
skippedFiles: 0,
malformedRecords: 0,
distinctSessions: openCodeSessionIds.size,
message: null,
});
} else {
sources.push({
fingerprint: {
hostId,
provider: "opencode",
resolvedHomePath: openCode.dbPath,
volumeId: openCode.outcome.volumeId,
},
status: openCode.outcome.status,
scannedFiles: 0,
skippedFiles: 0,
malformedRecords: 0,
distinctSessions: 0,
message:
openCode.outcome.status === "failed"
? openCode.outcome.detail
: "No OpenCode database on this environment.",
});
}

const pruned = pruneScanCache(fileCache, {
livePaths,
walkedRoots,
windowStartMs,
retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000,
retentionCutoffMs,
});
if (pruned > 0) cacheDirty = true;
yield* persistScanCache();
Expand Down
Loading
Loading