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
5 changes: 4 additions & 1 deletion apps/mobile/src/features/usage/usageProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,26 @@ 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", "grok"];
export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok", "opencode"];

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

/**
* 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.
* OpenCode's indigo is darkened for light mode so it keeps contrast on white.
*/
export function useProviderColors(): Record<UsageProviderKind, string> {
const { themeAppearance: scheme } = useAppearancePreferences();
return {
claude: "#d97757",
codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",
grok: scheme === "dark" ? "#a1a1aa" : "#52525b",
opencode: scheme === "dark" ? "#818cf8" : "#4f52b5",
};
}
99 changes: 97 additions & 2 deletions apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
// @effect-diagnostics nodeBuiltinImport:off - the suite seeds and grows real
// transcript trees on disk, outside the service's Effect FileSystem.
// transcript trees on disk, outside the service's Effect FileSystem, and seeds
// a real OpenCode SQLite store.
// @effect-diagnostics preferSchemaOverJson:off - fixtures stringify payload
// shapes to mirror the exact on-disk transcript documents the parsers see.
import * as NodeFSP from "node:fs/promises";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeSqlite from "node:sqlite";

import { assert, describe, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
Expand Down Expand Up @@ -89,7 +93,11 @@ 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"),
// Keeps the OpenCode source away from the developer's real store.
XDG_DATA_HOME: NodePath.join(input.home, "xdg-data"),
}),
),
);

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

it.live("folds OpenCode's message store into the same summary", () =>
Effect.gen(function* () {
const { settings, home } = yield* setup;

const dataDir = NodePath.join(home, "xdg-data", "opencode");
const dbPath = NodePath.join(dataDir, "opencode.db");
yield* Effect.promise(() => NodeFSP.mkdir(dataDir, { recursive: true }));
const messageStore = new NodeSqlite.DatabaseSync(dbPath);
messageStore.exec(
"CREATE TABLE `message` (`id` text PRIMARY KEY, `session_id` text NOT NULL, `time_created` integer NOT NULL, `data` text NOT NULL)",
);
const insert = messageStore.prepare(
"INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)",
);
const messageData = (tokens: Record<string, unknown>, cost: number, createdMs: number) =>
JSON.stringify({
role: "assistant",
cost,
tokens,
modelID: "example-opencode-model",
providerID: "openrouter",
time: { created: createdMs },
});
const august1 = Date.parse("2026-08-01T10:00:00Z");
insert.run(
"msg_1",
"ses_1",
august1,
messageData(
{ input: 100, output: 5, reasoning: 0, cache: { read: 50, write: 0 } },
0.001,
august1,
),
);
insert.run(
"msg_2",
"ses_2",
Date.parse("2026-08-01T11:00:00Z"),
messageData(
{ input: 10, output: 2, reasoning: 0, cache: { read: 0, write: 0 } },
0.002,
Date.parse("2026-08-01T11:00:00Z"),
),
);
insert.run(
"msg_3",
"ses_1",
Date.parse("2026-08-01T12:00:00Z"),
JSON.stringify({ role: "user" }),
);
insert.run(
"msg_4",
"ses_1",
Date.parse("2026-08-03T10:00:00Z"),
messageData(
{ input: 999, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
0.5,
Date.parse("2026-08-03T10:00:00Z"),
),
);
messageStore.close();

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

const bucket = summary.buckets.find((entry) => entry.provider === "opencode");
assert.strictEqual(bucket?.model, "example-opencode-model");
assert.deepStrictEqual(bucket?.totals, {
uncachedInputTokens: 110,
cachedInputTokens: 50,
cacheCreationTokens: 0,
outputTokens: 7,
reasoningTokens: 0,
});
// A row outside the window and a non-assistant row contribute nothing.
assert.strictEqual(bucket?.records, 2);
assert.strictEqual(bucket?.costSource, "providerReported");

const source = summary.sources.find((entry) => entry.fingerprint.provider === "opencode");
assert.strictEqual(source?.status, "ok");
assert.strictEqual(source?.fingerprint.resolvedHomePath, dataDir);
assert.strictEqual(source?.distinctSessions, 2);
}).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
52 changes: 44 additions & 8 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* UsageService - scans provider transcripts and returns priced usage buckets.
*
* 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.
* The scan reads the provider CLIs' own session files (Claude Code, Codex,
* Grok Build, and OpenCode's message store) rather than T3 Code's orchestration
* projections, so usage covers turns driven outside T3 Code too. This is the
* approach `ccusage` takes.
*
* 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 +46,7 @@ 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 { readOpenCodeUsageRecords } from "./opencodeUsageStore.ts";
import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
import {
listTranscriptFiles,
Expand Down Expand Up @@ -259,6 +261,14 @@ export const make = Effect.gen(function* () {
grokHomeEnv.length > 0
? path.resolve(expandHomePath(grokHomeEnv))
: path.join(NodeOS.homedir(), ".grok");
// OpenCode resolves its data dir per the XDG rules in its own `Global.Path`:
// `$XDG_DATA_HOME/opencode` when set, else `~/.local/share/opencode` on
// every platform. Empty/whitespace XDG_DATA_HOME must fall back.
const openCodeDataHome = hostEnvironment["XDG_DATA_HOME"]?.trim() ?? "";
const openCodeDataDir =
openCodeDataHome.length > 0
? path.resolve(expandHomePath(openCodeDataHome), "opencode")
: path.join(NodeOS.homedir(), ".local", "share", "opencode");

return [
{ provider: "claude" as const, dir: claudeDir },
Expand All @@ -268,6 +278,7 @@ export const make = Effect.gen(function* () {
dir: path.join(grokHome, "sessions"),
fileName: "updates.jsonl",
},
{ provider: "opencode" as const, dir: openCodeDataDir, kind: "opencode-db" as const },
];
});

Expand Down Expand Up @@ -372,12 +383,30 @@ export const make = Effect.gen(function* () {
readonly provider: UsageProviderKind;
readonly dir: string;
readonly volumeId: string;
/** Parsed records per file, or `null` when the directory does not exist. */
/** Parsed records per file, or `null` when the source is not readable. */
readonly files:
| readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]
| null;
/** Set when a readable-looking source could not be read at all. */
readonly readError?: string;
}

/** Reads one OpenCode data dir's message store into the shared source shape. */
const collectOpenCodeDir = Effect.fn("UsageService.collectOpenCodeDir")(function* (
provider: UsageProviderKind,
dir: string,
volumeId: string,
windowStartMs: number,
) {
const dbPath = path.join(dir, "opencode.db");
const read = yield* Effect.promise(() => readOpenCodeUsageRecords(dbPath, windowStartMs));
if (read.kind === "missing") return { provider, dir, volumeId, files: null };
if (read.kind === "failed") {
return { provider, dir, volumeId, files: null, readError: read.message };
}
return { provider, dir, volumeId, files: [{ path: dbPath, records: read.records }] };
});

const collectDirs = Effect.fn("UsageService.collectDirs")(function* (
windowStartMs: number,
settings: ServerSettingsValue,
Expand All @@ -388,8 +417,14 @@ export const make = Effect.gen(function* () {
Effect.provideService(Path.Path, path),
);
const scanned: ScannedDir[] = [];
for (const { provider, dir, fileName } of dirs) {
for (const { provider, dir, fileName, kind } of dirs) {
const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir));
// OpenCode keeps one SQLite store per data dir rather than append-only
// transcripts, so it bypasses the file walk and its per-file cache.
if (kind === "opencode-db") {
scanned.push(yield* collectOpenCodeDir(provider, dir, volumeId, windowStartMs));
continue;
}
const exists = yield* fileSystem
.exists(dir)
.pipe(Effect.catchCause(() => Effect.succeed(false)));
Expand Down Expand Up @@ -481,16 +516,17 @@ export const make = Effect.gen(function* () {
const livePaths = new Set<string>();
const walkedRoots: string[] = [];

for (const { provider, dir, volumeId, files } of scannedDirs) {
for (const { provider, dir, volumeId, files, readError } of scannedDirs) {
if (files === null) {
sources.push({
fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId },
status: "missing",
status: readError === undefined ? "missing" : "failed",
scannedFiles: 0,
skippedFiles: 0,
malformedRecords: 0,
distinctSessions: 0,
message: "No transcript directory on this environment.",
message:
readError === undefined ? "No transcript directory on this environment." : readError,
});
continue;
}
Expand Down
Loading
Loading