From 471b27c2679246a2416467de4534173ab2497806 Mon Sep 17 00:00:00 2001 From: AmoonPod Date: Wed, 9 Sep 2026 13:37:49 +0200 Subject: [PATCH 1/3] feat(usage): add OpenCode usage and tokens The Usage page only aggregated Claude Code, Codex, and Grok Build; OpenCode turns were invisible even for users whose main driver is OpenCode. Follows the existing scan pattern: the environment reads OpenCode's own on-disk store rather than T3 Code's projections. OpenCode keeps every message as a row of the 'message' table in /opencode.db, so the scan opens the database read-only (WAL-safe against a running OpenCode), folds rows through a pure parser, and reports a 'failed' source when the store cannot be read. The data dir resolves per OpenCode's own XDG rules. No scan-cache or resume machinery: the window filter runs in SQL and a retried turn updates its row in place, so there is nothing to dedupe. Legacy pre-SQLite storage/** JSON is deliberately not scanned: current OpenCode reads and writes only the DB, and scanning both would double count on migrated machines. USAGE_CONTRACT_VERSION bumps to 6; USAGE_MERGE_COMPATIBLE_SINCE stays at 4 because the change is additive, so older environments keep merging. Model: openrouter/z-ai/glm-5.3-flash. Harness: OpenCode (T3 Code). --- .../src/features/usage/usageProviders.ts | 5 +- apps/server/src/usage/UsageService.test.ts | 99 +++++++++++++++- apps/server/src/usage/UsageService.ts | 52 +++++++-- .../src/usage/opencodeUsageStore.test.ts | 109 ++++++++++++++++++ apps/server/src/usage/opencodeUsageStore.ts | 90 +++++++++++++++ .../server/src/usage/usageTranscripts.test.ts | 96 +++++++++++++++ apps/server/src/usage/usageTranscripts.ts | 77 +++++++++++++ .../usage/UsageProviderChart.test.ts | 1 + .../src/components/usage/usageProviders.ts | 9 +- docs/user/usage.md | 6 +- packages/contracts/src/usage.ts | 17 +-- packages/shared/src/usageMerge.test.ts | 3 +- 12 files changed, 540 insertions(+), 24 deletions(-) create mode 100644 apps/server/src/usage/opencodeUsageStore.test.ts create mode 100644 apps/server/src/usage/opencodeUsageStore.ts diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 2576ac21fb07..c4b558ca50b2 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -5,17 +5,19 @@ 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 = { 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 { const { themeAppearance: scheme } = useAppearancePreferences(); @@ -23,5 +25,6 @@ export function useProviderColors(): Record { claude: "#d97757", codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", grok: scheme === "dark" ? "#a1a1aa" : "#52525b", + opencode: scheme === "dark" ? "#818cf8" : "#4f52b5", }; } diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9d728c88cf42..15d884210bc1 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -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"; @@ -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"), + }), ), ); @@ -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, 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; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0e6b0c1eecd6..69736b5c9872 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -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 @@ -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, @@ -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 }, @@ -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 }, ]; }); @@ -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, @@ -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))); @@ -481,16 +516,17 @@ export const make = Effect.gen(function* () { const livePaths = new Set(); 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; } diff --git a/apps/server/src/usage/opencodeUsageStore.test.ts b/apps/server/src/usage/opencodeUsageStore.test.ts new file mode 100644 index 000000000000..ee3c707c1151 --- /dev/null +++ b/apps/server/src/usage/opencodeUsageStore.test.ts @@ -0,0 +1,109 @@ +// @effect-diagnostics nodeBuiltinImport:off - fixture stores must be real +// SQLite databases so the reader's own deliberate node:sqlite usage is +// exercised end to end. +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 { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; + +import { readOpenCodeUsageRecords } from "./opencodeUsageStore.ts"; + +let dir: string; + +beforeEach(async () => { + dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "opencode-store-test-")); +}); + +afterEach(async () => { + await NodeFSP.rm(dir, { recursive: true, force: true }); +}); + +/** The store's real schema is one JSON payload column keyed by message id. */ +function createMessageStore(dbPath: string): NodeSqlite.DatabaseSync { + const database = new NodeSqlite.DatabaseSync(dbPath); + database.exec( + "CREATE TABLE `message` (`id` text PRIMARY KEY, `session_id` text NOT NULL, `time_created` integer NOT NULL, `data` text NOT NULL)", + ); + return database; +} + +function assistantRow(overrides?: { + id?: string; + sessionId?: string; + createdMs?: number; + input?: number; + output?: number; + cacheRead?: number; + cost?: number; + data?: string; +}): { sessionId: string; createdMs: number; data: string } { + const createdMs = overrides?.createdMs ?? 1_788_951_671_960; + return { + sessionId: overrides?.sessionId ?? "ses_1", + createdMs, + data: + overrides?.data ?? + JSON.stringify({ + role: "assistant", + cost: overrides?.cost ?? 0.001, + tokens: { + input: overrides?.input ?? 375, + output: overrides?.output ?? 123, + reasoning: 0, + cache: { read: overrides?.cacheRead ?? 65_856, write: 0 }, + }, + modelID: "z-ai/glm-5.3-flash", + providerID: "openrouter", + time: { created: createdMs }, + }), + }; +} + +describe("readOpenCodeUsageRecords", () => { + it("reads in-window assistant rows oldest first and skips the rest", async () => { + const dbPath = NodePath.join(dir, "opencode.db"); + const database = createMessageStore(dbPath); + const insert = database.prepare( + "INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)", + ); + const old = assistantRow({ createdMs: 1_000 }); + const user = assistantRow({ createdMs: 2_000, data: JSON.stringify({ role: "user" }) }); + const first = assistantRow({ createdMs: 3_000, sessionId: "ses_a" }); + const second = assistantRow({ createdMs: 4_000, input: 10, output: 1, cacheRead: 0 }); + for (const [id, row] of [ + ["msg_old", old], + ["msg_user", user], + ["msg_a", first], + ["msg_b", second], + ] as const) { + insert.run(id, row.sessionId, row.createdMs, row.data); + } + database.close(); + + const read = await readOpenCodeUsageRecords(dbPath, 2_000); + + expect(read.kind).toBe("ok"); + if (read.kind !== "ok") return; + expect(read.records).toHaveLength(2); + expect(read.records[0]?.sessionId).toBe("ses_a"); + expect(read.records[0]?.timestampMs).toBe(3_000); + expect(read.records[1]?.totals.outputTokens).toBe(1); + }); + + it("reports a store that was never created as missing", async () => { + const read = await readOpenCodeUsageRecords(NodePath.join(dir, "absent.db"), 0); + expect(read.kind).toBe("missing"); + }); + + it("reports a store it cannot read as failed", async () => { + const dbPath = NodePath.join(dir, "opencode.db"); + await NodeFSP.writeFile(dbPath, "this is not a sqlite database"); + const read = await readOpenCodeUsageRecords(dbPath, 0); + + expect(read.kind).toBe("failed"); + if (read.kind !== "failed") return; + expect(read.message).toMatch(/^OpenCode store read failed/); + }); +}); diff --git a/apps/server/src/usage/opencodeUsageStore.ts b/apps/server/src/usage/opencodeUsageStore.ts new file mode 100644 index 000000000000..125f0dbab460 --- /dev/null +++ b/apps/server/src/usage/opencodeUsageStore.ts @@ -0,0 +1,90 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Reads usage records from OpenCode's on-disk message store. + * + * OpenCode keeps every session message as a row of the `message` table in + * `/opencode.db`, each row carrying its payload as a JSON document. + * Unlike the other providers' append-only JSONL transcripts there is nothing to + * resume or memoise: the window filter runs in SQL and a cold read of a + * months-deep store is cheap, so each scan opens the database read-only, folds + * the eligible rows through `parseOpenCodeMessageData`, and closes it. + * + * The read is concurrent-safe with a running OpenCode: WAL mode lets read-only + * connections proceed while OpenCode writes. The result distinguishes "no + * store on this machine" (an ordinary state, like a missing transcript + * directory) from "the store could not be read" (a failure the page should + * surface) because the source status they produce differs. + * + * @module opencodeUsageStore + */ +import * as NodeFSP from "node:fs/promises"; +import * as NodeSqlite from "node:sqlite"; + +import { parseOpenCodeMessageData, type UsageRecord } from "./usageTranscripts.ts"; + +export type OpenCodeStoreRead = + | { readonly kind: "ok"; readonly records: readonly UsageRecord[] } + | { readonly kind: "missing" } + | { readonly kind: "failed"; readonly message: string }; + +/** Node filesystem and SQLite errors carry a stable `code`. */ +function errorCode(error: unknown): string | null { + if (typeof error === "object" && error !== null && "code" in error) { + const code = (error as { code: unknown }).code; + return typeof code === "string" ? code : null; + } + return null; +} + +/** Bounds an error for the wire: `UsageSource.message` is a short user-facing string. */ +function failureMessage(error: unknown): string { + const code = errorCode(error); + const detail = error instanceof Error ? error.message : String(error); + return `OpenCode store read failed${code ? ` (${code})` : ""}: ${detail.slice(0, 160)}`; +} + +/** + * Reads every assistant message row at or after `sinceMs`, oldest first. + * + * A row whose payload no longer parses (an older or newer OpenCode writing an + * unexpected shape) is skipped individually, mirroring how the JSONL parsers + * treat unrecognised lines, so one odd row cannot blank out the provider. + */ +export async function readOpenCodeUsageRecords( + dbPath: string, + sinceMs: number, +): Promise { + try { + await NodeFSP.stat(dbPath); + } catch (error) { + if (errorCode(error) === "ENOENT") { + return { kind: "missing" }; + } + return { kind: "failed", message: failureMessage(error) }; + } + + let database: NodeSqlite.DatabaseSync; + try { + database = new NodeSqlite.DatabaseSync(dbPath, { readOnly: true }); + } catch (error) { + return { kind: "failed", message: failureMessage(error) }; + } + + try { + const rows = database + .prepare("SELECT session_id, data FROM message WHERE time_created >= ? ORDER BY time_created") + .all(sinceMs); + const records: UsageRecord[] = []; + for (const row of rows) { + const { session_id: sessionId, data } = row as { session_id: unknown; data: unknown }; + if (typeof data !== "string") continue; + const record = parseOpenCodeMessageData(data, typeof sessionId === "string" ? sessionId : ""); + if (record !== null) records.push(record); + } + return { kind: "ok", records }; + } catch (error) { + return { kind: "failed", message: failureMessage(error) }; + } finally { + database.close(); + } +} diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index b09db613ed85..9cd16bfe7691 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -6,6 +6,7 @@ import { parseClaudeLine, parseCodexLine, parseGrokLine, + parseOpenCodeMessageData, totalTokens, } from "./usageTranscripts.ts"; @@ -238,6 +239,101 @@ describe("parseCodexLine", () => { }); }); +describe("parseOpenCodeMessageData", () => { + /** Shaped after a real OpenCode `message.data` assistant row. */ + function messageData(overrides?: { + sessionId?: string; + role?: string; + modelID?: string; + cost?: number; + tokens?: Record; + createdMs?: number; + }): string { + return JSON.stringify({ + parentID: "msg_parent", + role: overrides?.role ?? "assistant", + mode: "build", + agent: "build", + path: { cwd: "/repo", root: "/repo" }, + cost: overrides?.cost ?? 0.001063965, + tokens: overrides?.tokens ?? { + total: 66_423, + input: 375, + output: 123, + reasoning: 69, + cache: { read: 65_856, write: 0 }, + }, + modelID: overrides?.modelID ?? "z-ai/glm-5.3-flash", + providerID: "openrouter", + time: { + created: overrides?.createdMs ?? 1_788_951_671_960, + completed: 1_788_951_683_087, + }, + }); + } + + it("extracts disjoint token totals and the session id passed by the caller", () => { + const record = parseOpenCodeMessageData(messageData(), "ses_1"); + + expect(record?.provider).toBe("opencode"); + expect(record?.model).toBe("z-ai/glm-5.3-flash"); + expect(record?.sessionId).toBe("ses_1"); + expect(record?.timestampMs).toBe(1_788_951_671_960); + expect(record?.totals).toEqual({ + // OpenCode reports input exclusive of the cached portion. + uncachedInputTokens: 375, + cachedInputTokens: 65_856, + cacheCreationTokens: 0, + outputTokens: 123, + reasoningTokens: 69, + }); + // tokens.total re-adds reasoning on top and is ignored. + expect(totalTokens(record!.totals)).toBe(66_354); + expect(record?.dedupeKey).toBeNull(); + }); + + it("keeps a positive provider-reported cost and treats zero as unknown", () => { + expect(parseOpenCodeMessageData(messageData({ cost: 0.5 }), "ses_1")?.reportedCostUsd).toBe( + 0.5, + ); + // OpenCode writes 0 when it has no rate for the model; the rate table + // should get a chance to price the tokens instead of a confident $0. + expect(parseOpenCodeMessageData(messageData({ cost: 0 }), "ses_1")?.reportedCostUsd).toBeNull(); + }); + + it("skips zero-token, non-assistant, and shapeless rows", () => { + expect( + parseOpenCodeMessageData( + messageData({ + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }), + "ses_1", + ), + ).toBeNull(); + expect(parseOpenCodeMessageData(messageData({ role: "user" }), "ses_1")).toBeNull(); + expect(parseOpenCodeMessageData("not json", "ses_1")).toBeNull(); + expect(parseOpenCodeMessageData(messageData({ modelID: "" }), "ses_1")).toBeNull(); + expect(parseOpenCodeMessageData(messageData({ createdMs: 0 }), "ses_1")).toBeNull(); + }); + + it("clamps reasoning into output and defaults missing cache counters", () => { + const record = parseOpenCodeMessageData( + messageData({ + tokens: { input: 10, output: 5, reasoning: 9 }, + }), + "ses_1", + ); + + expect(record?.totals).toEqual({ + uncachedInputTokens: 10, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 5, + reasoningTokens: 5, + }); + }); +}); + 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 5d909379eb10..a2727dc48043 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -485,4 +485,81 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { return results; } +/* -------------------------------------------------------------------------- */ +/* OpenCode */ +/* -------------------------------------------------------------------------- */ + +/** + * Parses the `data` JSON document of one row of OpenCode's `message` table. + * + * OpenCode stores each message as a row in `/opencode.db`; assistant + * rows carry the turn's token counts and a provider-reported cost. The session + * id lives only in the table's `session_id` column, so the caller passes it in. + * `tokens.input` is exclusive of the cache counters, matching Claude's + * accounting, and `tokens.total` re-adds reasoning on top, so `total` is + * ignored in favour of the disjoint fields. + * + * A zero-token row (errored or synthetic turns) is skipped, matching Codex and + * Grok. A cost of exactly 0 is treated as unknown rather than free: OpenCode + * writes 0 whenever it has no rate for the model, and falling through to the + * rate table yields a better estimate than a confident $0. + * + * Rows are the unit of record — a retried turn updates its row in place — so + * no dedupe key is needed. + */ +export function parseOpenCodeMessageData(raw: string, sessionId: string): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + if (record["role"] !== "assistant") return null; + + const tokens = record["tokens"]; + if (typeof tokens !== "object" || tokens === null) return null; + const tokensRecord = tokens as Record; + + const cache = tokensRecord["cache"]; + const cacheRecord = + typeof cache === "object" && cache !== null ? (cache as Record) : {}; + + const outputTokens = int(tokensRecord["output"]); + const totals: UsageTokenTotals = { + // OpenCode reports `input` exclusive of the cached portion. + uncachedInputTokens: int(tokensRecord["input"]), + cachedInputTokens: int(cacheRecord["read"]), + cacheCreationTokens: int(cacheRecord["write"]), + outputTokens, + // Reported inside output_tokens, surfaced separately for the token mix. + reasoningTokens: Math.min(outputTokens, int(tokensRecord["reasoning"])), + }; + if (totalTokens(totals) === 0) return null; + + const model = typeof record["modelID"] === "string" ? record["modelID"] : ""; + if (model.length === 0) return null; + + const time = record["time"]; + const timestampMs = + typeof time === "object" && time !== null + ? int((time as Record)["created"]) + : 0; + if (timestampMs === 0) return null; + + const cost = record["cost"]; + + return { + provider: "opencode", + timestampMs, + model, + sessionId, + totals, + reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null, + dedupeKey: null, + }; +} + export { EMPTY_TOTALS }; diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 622d73d13844..236d936f4b99 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -89,6 +89,7 @@ describe("buildPeriodColumns", () => { { provider: "codex", value: 10 }, { provider: "claude", value: 20 }, { provider: "grok", value: 0 }, + { provider: "opencode", value: 0 }, ]); }); diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index efad95e531ad..14bc0b52d5d9 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, GrokIcon, type Icon, OpenAI } from "../Icons"; +import { ClaudeAI, GrokIcon, OpenCodeIcon, type Icon, OpenAI } from "../Icons"; type UsageProviderPresentation = { readonly label: string; @@ -30,6 +30,13 @@ export const PROVIDER_PRESENTATION = { color: "color-mix(in oklab, var(--contrast-foreground) 72%, var(--background))", mark: GrokIcon, }, + opencode: { + label: "OpenCode", + // A cool accent: the only hue in a palette that is otherwise white, warm + // orange, and neutral gray, so OpenCode rows stay distinguishable at a glance. + color: "#818cf8", + mark: OpenCodeIcon, + }, } satisfies Record; /** Stable provider reading order across charts, summaries, tables, and hover rows. */ diff --git a/docs/user/usage.md b/docs/user/usage.md index fba493156dc2..0ec949401e5b 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -2,9 +2,9 @@ ## Understand your usage -**Usage** combines Codex, Claude Code, and Grok Build session history from your connected -environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent -cost. These estimates are not your subscription bill. +**Usage** combines Codex, Claude Code, Grok Build, and OpenCode session history from your +connected environments. It shows token use, cache savings, model breakdowns, and estimated +API-equivalent cost. These estimates are not your subscription bill. Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 29fb75bf949c..f5e3c20134fb 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -3,9 +3,10 @@ * * Each environment scans the provider CLIs' own on-disk session transcripts * (`~/.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. + * `~/.grok/sessions/**\/updates.jsonl`, OpenCode's `opencode.db` message + * table) 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. * * Environments return pre-aggregated `(day, hourStart?, provider, model)` * buckets. Raw transcript records never cross the wire. @@ -21,18 +22,18 @@ 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 = 5 as const; +export const USAGE_CONTRACT_VERSION = 6 as const; /** * Oldest {@link UsageSummary} version a current client will still merge. * - * v5 only adds `grok` to {@link UsageProviderKind}; v4 Claude/Codex buckets - * remain valid, so mixed-version environments keep those totals instead of - * treating every older server as stale. + * v5 only adds `grok` and v6 only adds `opencode` to {@link UsageProviderKind}; + * v4 Claude/Codex buckets remain valid, so mixed-version environments keep + * those totals instead of treating every older server as stale. */ export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; -export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); +export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok", "opencode"]); export type UsageProviderKind = typeof UsageProviderKind.Type; /** diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 6c706395c6ff..3342d773b8f1 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -1,5 +1,6 @@ import { USAGE_CONTRACT_VERSION, + USAGE_MERGE_COMPATIBLE_SINCE, type EnvironmentId, type UsageBucket, type UsageDay, @@ -158,7 +159,7 @@ describe("mergeUsage", () => { summary( [bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b" }], - USAGE_CONTRACT_VERSION - 2, + USAGE_MERGE_COMPATIBLE_SINCE - 1, ), ), ], From d88472a6e6ba6c5f1d87219ecb4936d34599695b Mon Sep 17 00:00:00 2001 From: AmoonPod Date: Wed, 9 Sep 2026 13:55:33 +0200 Subject: [PATCH 2/3] fix(server): page OpenCode store reads so scans cannot stall the event loop CodeRabbit flagged that readOpenCodeUsageRecords ran the synchronous node:sqlite query and every row's JSON.parse in one blocking slice; Effect.promise offloads nothing, so a years-deep store would freeze the server loop behind a usage page load. Reads now walk rowid pages (direct B-tree seeks, no OFFSET rescan) of 1000 rows and yield to the macrotask queue between pages, the same interleaving the streaming JSONL reader gets from its I/O awaits. time_created carries no index, so it is filtered per row in the page loop. Model: openrouter/z-ai/glm-5.3-flash. Harness: OpenCode (T3 Code). --- .../src/usage/opencodeUsageStore.test.ts | 34 ++++++++++ apps/server/src/usage/opencodeUsageStore.ts | 62 ++++++++++++++----- 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/apps/server/src/usage/opencodeUsageStore.test.ts b/apps/server/src/usage/opencodeUsageStore.test.ts index ee3c707c1151..822255b76324 100644 --- a/apps/server/src/usage/opencodeUsageStore.test.ts +++ b/apps/server/src/usage/opencodeUsageStore.test.ts @@ -92,6 +92,40 @@ describe("readOpenCodeUsageRecords", () => { expect(read.records[1]?.totals.outputTokens).toBe(1); }); + it("reads past a page boundary without losing or reordering records", async () => { + const dbPath = NodePath.join(dir, "opencode.db"); + const database = createMessageStore(dbPath); + const insert = database.prepare( + "INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)", + ); + const base = 1_788_951_671_960; + for (let index = 0; index < 1201; index += 1) { + const createdMs = base + index; + insert.run( + `msg_${index}`, + "ses_1", + createdMs, + JSON.stringify({ + role: "assistant", + cost: 0.001, + tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "m", + providerID: "p", + time: { created: createdMs }, + }), + ); + } + database.close(); + + const read = await readOpenCodeUsageRecords(dbPath, base); + + expect(read.kind).toBe("ok"); + if (read.kind !== "ok") return; + expect(read.records).toHaveLength(1201); + expect(read.records[0]?.timestampMs).toBe(base); + expect(read.records.at(-1)?.timestampMs).toBe(base + 1200); + }); + it("reports a store that was never created as missing", async () => { const read = await readOpenCodeUsageRecords(NodePath.join(dir, "absent.db"), 0); expect(read.kind).toBe("missing"); diff --git a/apps/server/src/usage/opencodeUsageStore.ts b/apps/server/src/usage/opencodeUsageStore.ts index 125f0dbab460..225439bba41e 100644 --- a/apps/server/src/usage/opencodeUsageStore.ts +++ b/apps/server/src/usage/opencodeUsageStore.ts @@ -5,9 +5,9 @@ * OpenCode keeps every session message as a row of the `message` table in * `/opencode.db`, each row carrying its payload as a JSON document. * Unlike the other providers' append-only JSONL transcripts there is nothing to - * resume or memoise: the window filter runs in SQL and a cold read of a - * months-deep store is cheap, so each scan opens the database read-only, folds - * the eligible rows through `parseOpenCodeMessageData`, and closes it. + * resume or memoise: a cold read of a months-deep store is cheap, so each scan + * opens the database read-only, folds the eligible rows through + * `parseOpenCodeMessageData`, and closes it. * * The read is concurrent-safe with a running OpenCode: WAL mode lets read-only * connections proceed while OpenCode writes. The result distinguishes "no @@ -27,6 +27,16 @@ export type OpenCodeStoreRead = | { readonly kind: "missing" } | { readonly kind: "failed"; readonly message: string }; +/** + * Rows parsed per page. `node:sqlite` is synchronous and `Effect.promise` + * offloads nothing, so a single `.all()` over a years-deep store would block + * the server's event loop for the whole parse. Paging bounds each blocking + * slice while a macrotask yield between pages lets concurrent connections + * through, which is the same interleaving the streaming JSONL reader gets + * from its I/O awaits. + */ +const PAGE_SIZE = 1000; + /** Node filesystem and SQLite errors carry a stable `code`. */ function errorCode(error: unknown): string | null { if (typeof error === "object" && error !== null && "code" in error) { @@ -43,12 +53,20 @@ function failureMessage(error: unknown): string { return `OpenCode store read failed${code ? ` (${code})` : ""}: ${detail.slice(0, 160)}`; } +/** Yields to the macrotask queue so pending I/O and socket work can run. */ +function yieldToEventLoop(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + /** * Reads every assistant message row at or after `sinceMs`, oldest first. * - * A row whose payload no longer parses (an older or newer OpenCode writing an - * unexpected shape) is skipped individually, mirroring how the JSONL parsers - * treat unrecognised lines, so one odd row cannot blank out the provider. + * Pages walk `rowid`, which is the table's B-tree key, so each page is a + * direct seek rather than a rescan; `time_created` carries no index and is + * filtered per row. A row whose payload no longer parses (an older or newer + * OpenCode writing an unexpected shape) is skipped individually, mirroring how + * the JSONL parsers treat unrecognised lines, so one odd row cannot blank out + * the provider. */ export async function readOpenCodeUsageRecords( dbPath: string, @@ -71,15 +89,31 @@ export async function readOpenCodeUsageRecords( } try { - const rows = database - .prepare("SELECT session_id, data FROM message WHERE time_created >= ? ORDER BY time_created") - .all(sinceMs); + const selectPage = database.prepare( + "SELECT rowid, session_id, data, time_created FROM message WHERE rowid > ? ORDER BY rowid LIMIT ?", + ); const records: UsageRecord[] = []; - for (const row of rows) { - const { session_id: sessionId, data } = row as { session_id: unknown; data: unknown }; - if (typeof data !== "string") continue; - const record = parseOpenCodeMessageData(data, typeof sessionId === "string" ? sessionId : ""); - if (record !== null) records.push(record); + let lastRowid = 0; + for (;;) { + const rows = selectPage.all(lastRowid, PAGE_SIZE) as unknown as readonly { + rowid: number; + session_id: unknown; + data: unknown; + time_created: unknown; + }[]; + if (rows.length === 0) break; + for (const row of rows) { + lastRowid = row.rowid; + if (typeof row.time_created !== "number" || row.time_created < sinceMs) continue; + if (typeof row.data !== "string") continue; + const record = parseOpenCodeMessageData( + row.data, + typeof row.session_id === "string" ? row.session_id : "", + ); + if (record !== null) records.push(record); + } + if (rows.length < PAGE_SIZE) break; + await yieldToEventLoop(); } return { kind: "ok", records }; } catch (error) { From 3919a2cb4a4323c70d4b00bf89c9f018eadd4c0e Mon Sep 17 00:00:00 2001 From: AmoonPod Date: Wed, 9 Sep 2026 14:12:17 +0200 Subject: [PATCH 3/3] fix(server): return OpenCode records in message-time order CodeRabbit flagged that rowid paging returned insertion order, and a backfilled or clock-adjusted message can hold a later rowid with an earlier time_created. Records are now sorted by time_created with rowid as the tie breaker before returning, keeping the paging cursor and its loop yields. Model: openrouter/z-ai/glm-5.3-flash. Harness: OpenCode (T3 Code). --- .../src/usage/opencodeUsageStore.test.ts | 20 +++++++++++++++++++ apps/server/src/usage/opencodeUsageStore.ts | 18 +++++++++++------ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/server/src/usage/opencodeUsageStore.test.ts b/apps/server/src/usage/opencodeUsageStore.test.ts index 822255b76324..32d8238ad3df 100644 --- a/apps/server/src/usage/opencodeUsageStore.test.ts +++ b/apps/server/src/usage/opencodeUsageStore.test.ts @@ -126,6 +126,26 @@ describe("readOpenCodeUsageRecords", () => { expect(read.records.at(-1)?.timestampMs).toBe(base + 1200); }); + it("orders records by message time, not insertion order", async () => { + const dbPath = NodePath.join(dir, "opencode.db"); + const database = createMessageStore(dbPath); + const insert = database.prepare( + "INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)", + ); + // A backfilled message lands later in the table than an older instant. + const late = assistantRow({ createdMs: 5_000 }); + const backfilled = assistantRow({ createdMs: 2_000, input: 10 }); + insert.run("msg_late", late.sessionId, late.createdMs, late.data); + insert.run("msg_backfill", backfilled.sessionId, backfilled.createdMs, backfilled.data); + database.close(); + + const read = await readOpenCodeUsageRecords(dbPath, 0); + + expect(read.kind).toBe("ok"); + if (read.kind !== "ok") return; + expect(read.records.map((record) => record.timestampMs)).toEqual([2_000, 5_000]); + }); + it("reports a store that was never created as missing", async () => { const read = await readOpenCodeUsageRecords(NodePath.join(dir, "absent.db"), 0); expect(read.kind).toBe("missing"); diff --git a/apps/server/src/usage/opencodeUsageStore.ts b/apps/server/src/usage/opencodeUsageStore.ts index 225439bba41e..47b7c6cd5ed6 100644 --- a/apps/server/src/usage/opencodeUsageStore.ts +++ b/apps/server/src/usage/opencodeUsageStore.ts @@ -63,10 +63,13 @@ function yieldToEventLoop(): Promise { * * Pages walk `rowid`, which is the table's B-tree key, so each page is a * direct seek rather than a rescan; `time_created` carries no index and is - * filtered per row. A row whose payload no longer parses (an older or newer - * OpenCode writing an unexpected shape) is skipped individually, mirroring how - * the JSONL parsers treat unrecognised lines, so one odd row cannot blank out - * the provider. + * filtered per row. Insertion order is not chronological — a backfilled or + * clock-adjusted message can hold a later `rowid` with an earlier + * `time_created` — so the returned records are ordered by `time_created` with + * `rowid` as the tie breaker. A row whose payload no longer parses (an older + * or newer OpenCode writing an unexpected shape) is skipped individually, + * mirroring how the JSONL parsers treat unrecognised lines, so one odd row + * cannot blank out the provider. */ export async function readOpenCodeUsageRecords( dbPath: string, @@ -92,7 +95,7 @@ export async function readOpenCodeUsageRecords( const selectPage = database.prepare( "SELECT rowid, session_id, data, time_created FROM message WHERE rowid > ? ORDER BY rowid LIMIT ?", ); - const records: UsageRecord[] = []; + const parsed: { readonly rowid: number; readonly record: UsageRecord }[] = []; let lastRowid = 0; for (;;) { const rows = selectPage.all(lastRowid, PAGE_SIZE) as unknown as readonly { @@ -110,11 +113,14 @@ export async function readOpenCodeUsageRecords( row.data, typeof row.session_id === "string" ? row.session_id : "", ); - if (record !== null) records.push(record); + if (record !== null) parsed.push({ rowid: row.rowid, record }); } if (rows.length < PAGE_SIZE) break; await yieldToEventLoop(); } + const records = parsed + .toSorted((a, b) => a.record.timestampMs - b.record.timestampMs || a.rowid - b.rowid) + .map((entry) => entry.record); return { kind: "ok", records }; } catch (error) { return { kind: "failed", message: failureMessage(error) };