diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 2ad2a729ecb5..781681e26661 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -38,6 +38,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 { readOpenCodeUsage, resolveOpenCodeDataDir } from "./usageOpenCodeReader.ts"; import { parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, @@ -128,6 +129,7 @@ export const make = Effect.gen(function* () { const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); + const openCodeDataDir = resolveOpenCodeDataDir(NodeOS.homedir(), process.env["XDG_DATA_HOME"]); let rates: RateTable = new Map(); let ratesFetchedAtMs: number | null = null; let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable"; @@ -380,6 +382,82 @@ export const make = Effect.gen(function* () { }); } + const openCodeVolumeId = yield* Effect.promise(() => readDirectoryVolumeId(openCodeDataDir)); + const openCodeExists = yield* fileSystem + .exists(openCodeDataDir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + + if (!openCodeExists) { + sources.push({ + fingerprint: { + hostId, + provider: "opencode", + resolvedHomePath: openCodeDataDir, + volumeId: openCodeVolumeId, + }, + status: "missing", + scannedFiles: 0, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 0, + message: "No OpenCode data directory on this environment.", + }); + } else { + const result = yield* Effect.promise(() => readOpenCodeUsage(openCodeDataDir, windowStartMs)); + if (result === null) { + sources.push({ + fingerprint: { + hostId, + provider: "opencode", + resolvedHomePath: openCodeDataDir, + volumeId: openCodeVolumeId, + }, + status: "failed", + scannedFiles: 0, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 0, + message: "OpenCode usage storage could not be read.", + }); + } else if (result.storageKind === "missing") { + sources.push({ + fingerprint: { + hostId, + provider: "opencode", + resolvedHomePath: openCodeDataDir, + volumeId: openCodeVolumeId, + }, + status: "missing", + scannedFiles: 0, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 0, + message: "No OpenCode message store on this environment.", + }); + } else { + const sessionIds = new Set(); + for (const record of result.records) { + if (aggregator.add(record) && record.sessionId.length > 0) { + sessionIds.add(record.sessionId); + } + } + sources.push({ + fingerprint: { + hostId, + provider: "opencode", + resolvedHomePath: openCodeDataDir, + volumeId: openCodeVolumeId, + }, + status: result.malformedRecords > 0 ? "partial" : "ok", + scannedFiles: result.scannedFiles, + skippedFiles: result.skippedFiles, + malformedRecords: result.malformedRecords, + distinctSessions: sessionIds.size, + message: result.malformedRecords > 0 ? "Some OpenCode messages could not be read." : null, + }); + } + } + const pruned = pruneScanCache(fileCache, { livePaths, walkedRoots, diff --git a/apps/server/src/usage/usageOpenCodeReader.test.ts b/apps/server/src/usage/usageOpenCodeReader.test.ts new file mode 100644 index 000000000000..505b61240384 --- /dev/null +++ b/apps/server/src/usage/usageOpenCodeReader.test.ts @@ -0,0 +1,155 @@ +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalDate:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; + +import { describe, expect, it } from "@effect/vitest"; + +import { + parseOpenCodeMessageRow, + readOpenCodeUsage, + resolveOpenCodeDataDir, +} from "./usageOpenCodeReader.ts"; + +function assistantMessage(overrides: Record = {}) { + return { + id: "msg_1", + sessionID: "ses_1", + role: "assistant", + modelID: "deepseek-v4-flash-free", + providerID: "opencode", + time: { created: 1_786_211_758_317, completed: 1_786_211_767_010 }, + cost: 0, + tokens: { + input: 137, + output: 851, + reasoning: 230, + cache: { read: 99_200, write: 12 }, + }, + ...overrides, + }; +} + +describe("parseOpenCodeMessageRow", () => { + it("maps OpenCode assistant usage without counting cache or reasoning twice", () => { + const record = parseOpenCodeMessageRow({ + id: "msg_1", + sessionId: "ses_1", + timeCreated: 1_786_211_758_317, + data: JSON.stringify(assistantMessage()), + }); + + expect(record).toEqual({ + provider: "opencode", + timestampMs: 1_786_211_767_010, + model: "deepseek-v4-flash-free", + sessionId: "ses_1", + totals: { + uncachedInputTokens: 137, + cachedInputTokens: 99_200, + cacheCreationTokens: 12, + outputTokens: 851, + reasoningTokens: 230, + }, + reportedCostUsd: 0, + dedupeKey: "opencode:msg_1", + }); + }); + + it("ignores non-assistant, malformed, zero-token, and model-less rows", () => { + const row = (data: unknown) => ({ + id: "msg_1", + sessionId: "ses_1", + timeCreated: 1_786_211_758_317, + data, + }); + + expect(parseOpenCodeMessageRow(row("not json"))).toBeNull(); + expect( + parseOpenCodeMessageRow(row(JSON.stringify(assistantMessage({ role: "user" })))), + ).toBeNull(); + expect( + parseOpenCodeMessageRow( + row(JSON.stringify(assistantMessage({ tokens: { input: 0, output: 0 } }))), + ), + ).toBeNull(); + expect( + parseOpenCodeMessageRow(row(JSON.stringify(assistantMessage({ modelID: "" })))), + ).toBeNull(); + }); +}); + +describe("resolveOpenCodeDataDir", () => { + it("uses XDG_DATA_HOME when configured and the documented default otherwise", () => { + expect(resolveOpenCodeDataDir("/home/dev", "/mnt/data")).toBe( + NodePath.join("/mnt/data", "opencode"), + ); + expect(resolveOpenCodeDataDir("/home/dev", undefined)).toBe( + NodePath.join("/home/dev", ".local", "share", "opencode"), + ); + }); +}); + +describe("readOpenCodeUsage", () => { + it("reads current OpenCode SQLite messages and excludes older rows", async () => { + const dataDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-usage-")); + const databasePath = NodePath.join(dataDir, "opencode.db"); + const database = new NodeSqlite.DatabaseSync(databasePath); + database.exec( + "CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT)", + ); + const insert = database.prepare( + "INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)", + ); + insert.run("msg_old", "ses_old", 100, JSON.stringify(assistantMessage())); + insert.run( + "msg_new", + "ses_new", + 2_000, + JSON.stringify( + assistantMessage({ + id: "msg_new", + sessionID: "ses_new", + time: { created: 2_000, completed: 2_500 }, + }), + ), + ); + database.close(); + + try { + const result = await readOpenCodeUsage(dataDir, 1_000); + expect(result?.storageKind).toBe("sqlite"); + expect(result?.scannedFiles).toBe(1); + expect(result?.records.map((record) => record.sessionId)).toEqual(["ses_new"]); + } finally { + NodeFS.rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it("falls back to the documented JSON message store when no database exists", async () => { + const dataDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-usage-")); + const messageDir = NodePath.join(dataDir, "storage", "message", "ses_legacy"); + const now = Date.now(); + NodeFS.mkdirSync(messageDir, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(messageDir, "msg_legacy.json"), + JSON.stringify( + assistantMessage({ + id: "msg_legacy", + sessionID: "ses_legacy", + time: { created: now, completed: now }, + }), + ), + ); + + try { + const result = await readOpenCodeUsage(dataDir, now - 60_000); + expect(result?.storageKind).toBe("json"); + expect(result?.records.map((record) => record.sessionId)).toEqual(["ses_legacy"]); + } finally { + NodeFS.rmSync(dataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/server/src/usage/usageOpenCodeReader.ts b/apps/server/src/usage/usageOpenCodeReader.ts new file mode 100644 index 000000000000..f129e07e129f --- /dev/null +++ b/apps/server/src/usage/usageOpenCodeReader.ts @@ -0,0 +1,227 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Reads usage from OpenCode's native data store. + * + * Current OpenCode releases persist messages in `opencode.db`. Older releases + * used `storage/message//.json`; that layout remains a + * fallback only when the database is absent so migrated messages are never + * counted twice. + * + * @module usageOpenCodeReader + */ +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; + +import { totalTokens, type UsageRecord } from "./usageTranscripts.ts"; + +export interface OpenCodeMessageRow { + readonly id: unknown; + readonly sessionId: unknown; + readonly timeCreated: unknown; + readonly data: unknown; +} + +export interface OpenCodeUsageReadResult { + readonly storageKind: "sqlite" | "json" | "missing"; + readonly records: readonly UsageRecord[]; + readonly scannedFiles: number; + readonly skippedFiles: number; + readonly malformedRecords: number; +} + +function object(value: unknown): Record | null { + return typeof value === "object" && value !== null ? (value as Record) : null; +} + +function positiveInt(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; +} + +function timestamp(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.trunc(value) + : null; +} + +function decodeMessageData(data: unknown): Record | null { + if (typeof data !== "string") return object(data); + try { + return object(JSON.parse(data)); + } catch { + return null; + } +} + +/** Maps one OpenCode assistant message to the shared usage representation. */ +export function parseOpenCodeMessageRow(row: OpenCodeMessageRow): UsageRecord | null { + const message = decodeMessageData(row.data); + if (message === null || message["role"] !== "assistant") return null; + + const model = typeof message["modelID"] === "string" ? message["modelID"].trim() : ""; + if (model.length === 0) return null; + + const tokens = object(message["tokens"]); + if (tokens === null) return null; + const cache = object(tokens["cache"]); + const outputTokens = positiveInt(tokens["output"]); + const totals = { + // OpenCode stores uncached input and cache reads/writes as disjoint fields. + uncachedInputTokens: positiveInt(tokens["input"]), + cachedInputTokens: positiveInt(cache?.["read"]), + cacheCreationTokens: positiveInt(cache?.["write"]), + outputTokens, + // Reasoning is included in output, matching the shared usage contract. + reasoningTokens: Math.min(outputTokens, positiveInt(tokens["reasoning"])), + }; + if (totalTokens(totals) === 0) return null; + + const time = object(message["time"]); + const timestampMs = timestamp(time?.["completed"] ?? time?.["created"] ?? row.timeCreated); + if (timestampMs === null) return null; + + const messageId = + typeof message["id"] === "string" ? message["id"] : typeof row.id === "string" ? row.id : ""; + const sessionId = + typeof message["sessionID"] === "string" + ? message["sessionID"] + : typeof row.sessionId === "string" + ? row.sessionId + : ""; + const cost = message["cost"]; + + return { + provider: "opencode", + timestampMs, + model, + sessionId, + totals, + reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) && cost >= 0 ? cost : null, + dedupeKey: messageId.length > 0 ? `opencode:${messageId}` : null, + }; +} + +/** OpenCode follows XDG_DATA_HOME and otherwise uses ~/.local/share/opencode. */ +export function resolveOpenCodeDataDir(homePath: string, xdgDataHome: string | undefined): string { + const dataHome = xdgDataHome?.trim(); + return dataHome + ? NodePath.join(dataHome, "opencode") + : NodePath.join(homePath, ".local", "share", "opencode"); +} + +function readSqliteUsage(databasePath: string, sinceMs: number): OpenCodeUsageReadResult | null { + let database: NodeSqlite.DatabaseSync | undefined; + try { + database = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true, timeout: 5_000 }); + const rows = database + .prepare( + `SELECT id, session_id AS sessionId, time_created AS timeCreated, data + FROM message + WHERE time_created >= ? + AND json_valid(data) = 1 + AND json_extract(data, '$.role') = 'assistant'`, + ) + .all(sinceMs) as unknown as OpenCodeMessageRow[]; + const records: UsageRecord[] = []; + let malformedRecords = 0; + for (const row of rows) { + const record = parseOpenCodeMessageRow(row); + if (record === null) malformedRecords += 1; + else records.push(record); + } + return { + storageKind: "sqlite", + records, + scannedFiles: 1, + skippedFiles: 0, + malformedRecords, + }; + } catch { + return null; + } finally { + database?.close(); + } +} + +async function listLegacyMessages(root: string, sinceMs: number): Promise { + const files: string[] = []; + const walk = async (directory: string): Promise => { + let entries; + try { + entries = await NodeFSP.readdir(directory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const child = NodePath.join(directory, entry.name); + if (entry.isDirectory()) { + await walk(child); + continue; + } + if (!entry.name.endsWith(".json")) continue; + try { + const stats = await NodeFSP.stat(child); + if (stats.mtimeMs >= sinceMs) files.push(child); + } catch { + // The message rotated while the directory walk was in flight. + } + } + }; + await walk(root); + return files; +} + +async function readLegacyUsage( + messageRoot: string, + sinceMs: number, +): Promise { + const files = await listLegacyMessages(messageRoot, sinceMs); + const records: UsageRecord[] = []; + let skippedFiles = 0; + let malformedRecords = 0; + + for (const filePath of files) { + try { + const data = await NodeFSP.readFile(filePath, "utf8"); + const record = parseOpenCodeMessageRow({ + id: NodePath.basename(filePath, ".json"), + sessionId: NodePath.basename(NodePath.dirname(filePath)), + timeCreated: 0, + data, + }); + if (record === null) skippedFiles += 1; + else records.push(record); + } catch { + malformedRecords += 1; + } + } + + return { + storageKind: "json", + records, + scannedFiles: files.length, + skippedFiles, + malformedRecords, + }; +} + +/** Reads OpenCode usage without opening auth/config files or message parts. */ +export async function readOpenCodeUsage( + dataDir: string, + sinceMs: number, +): Promise { + const databasePath = NodePath.join(dataDir, "opencode.db"); + if (NodeFS.existsSync(databasePath)) return readSqliteUsage(databasePath, sinceMs); + + const messageRoot = NodePath.join(dataDir, "storage", "message"); + if (NodeFS.existsSync(messageRoot)) return readLegacyUsage(messageRoot, sinceMs); + + return { + storageKind: "missing", + records: [], + scannedFiles: 0, + skippedFiles: 0, + malformedRecords: 0, + }; +} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 2f3ab4b574c0..96264f191099 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -305,7 +305,10 @@ export function UsagePage() { {recentDays.length === 0 ? ( - + No activity in this window. diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index a36cd1e88330..fe5e00f60042 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -47,11 +47,12 @@ describe("buildDayColumns", () => { "2026-08-01", { day: "2026-08-01", - costUsd: 30, - totalTokens: 300, + costUsd: 33, + totalTokens: 330, byProvider: new Map([ ["codex" as const, { costUsd: 10, totalTokens: 100 }], ["claude" as const, { costUsd: 20, totalTokens: 200 }], + ["opencode" as const, { costUsd: 3, totalTokens: 30 }], ]), }, ], @@ -68,12 +69,12 @@ describe("buildDayColumns", () => { ]); it("plots each day on its own", () => { - expect(buildDayColumns(days, byDay, "cost").map((column) => column.total)).toEqual([30, 0, 5]); + expect(buildDayColumns(days, byDay, "cost").map((column) => column.total)).toEqual([33, 0, 5]); }); it("reads the requested metric", () => { expect(buildDayColumns(days, byDay, "tokens").map((column) => column.total)).toEqual([ - 300, 0, 50, + 330, 0, 50, ]); }); @@ -85,6 +86,7 @@ describe("buildDayColumns", () => { expect(first?.bands).toEqual([ { provider: "codex", value: 10 }, { provider: "claude", value: 20 }, + { provider: "opencode", value: 3 }, ]); }); diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index f8b65877dcf4..f8881f37bcfc 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -1,23 +1,25 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { ClaudeAI, type Icon, OpenAI } from "../Icons"; +import { ClaudeAI, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; /** - * Series and table order. The chart layers both providers from a shared zero + * Series and table order. The chart layers all providers from a shared zero * baseline, so this only fixes the reading order of legends, tables and hover * rows; it does not decide which series sits above the other. */ -export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "opencode"]; export const PROVIDER_LABEL: Record = { claude: "Claude Code", codex: "Codex", + opencode: "OpenCode", }; -/** Claude's brand orange against a neutral white for Codex. */ +/** Distinct provider colours used for chart bands and compact share bars. */ export const PROVIDER_COLOR: Record = { claude: "#d97757", codex: "#e6e6e6", + opencode: "#22c55e", }; /** @@ -30,4 +32,5 @@ export const PROVIDER_COLOR: Record = { export const PROVIDER_MARK: Record = { claude: ClaudeAI, codex: OpenAI, + opencode: OpenCodeIcon, }; diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 1aa639fe4a00..7f57f3a54286 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -2,7 +2,8 @@ * Usage reporting contract. * * Each environment scans the provider CLIs' own on-disk session transcripts - * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather than + * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`, and + * `~/.local/share/opencode/opencode.db`) 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. @@ -23,7 +24,7 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; */ export const USAGE_CONTRACT_VERSION = 3 as const; -export const UsageProviderKind = Schema.Literals(["claude", "codex"]); +export const UsageProviderKind = Schema.Literals(["claude", "codex", "opencode"]); export type UsageProviderKind = typeof UsageProviderKind.Type; /**