diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index c2de64302f..c969bbc5a8 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -10,6 +10,7 @@ export const PROVIDER_ORDER: readonly UsageProviderKind[] = [ "claude", "grok", "antigravity", + "opencode", ]; export const PROVIDER_LABEL: Record = { @@ -17,6 +18,7 @@ export const PROVIDER_LABEL: Record = { codex: "Codex", grok: "Grok Build", antigravity: "Antigravity", + opencode: "OpenCode", }; /** @@ -30,5 +32,6 @@ export function useProviderColors(): Record { codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", grok: scheme === "dark" ? "#a1a1aa" : "#52525b", antigravity: "#4285f4", + opencode: "#8b5cf6", }; } diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 22787f25dc..d95772d175 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -70,6 +70,25 @@ function seedAntigravityDb( db.close(); } +function seedOpenCodeDb(dbPath: string, id: string, outputTokens: number) { + const db = new NodeSqlite.DatabaseSync(dbPath); + db.exec("CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER)"); + db.prepare("INSERT INTO message VALUES (?, ?, ?, ?)").run( + id, + `session-${id}`, + JSON.stringify({ + id, + role: "assistant", + sessionID: `session-${id}`, + modelID: "openai/gpt-6", + time: { created: Date.parse("2026-08-01T10:00:00Z") }, + tokens: { input: 2, output: outputTokens, cache: { read: 0, write: 0 } }, + }), + Date.parse("2026-08-01T10:00:00Z"), + ); + db.close(); +} + const WINDOW: UsageSummaryInput = { timeZone: "UTC", sinceDay: UsageDay.make("2026-07-31"), @@ -901,6 +920,119 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("attributes distinct local OpenCode stores once and skips external servers", () => + Effect.flatMap(setup, ({ home }) => { + const firstData = NodePath.join(home, "first-data"); + const secondData = NodePath.join(home, "second-data"); + const externalData = NodePath.join(home, "external-data"); + const firstRoot = NodePath.join(firstData, "opencode"); + const secondRoot = NodePath.join(secondData, "opencode"); + const externalRoot = NodePath.join(externalData, "opencode"); + const alias = NodePath.join(home, "first-alias"); + return Effect.gen(function* () { + yield* Effect.promise(async () => { + await Promise.all( + [firstRoot, secondRoot, externalRoot].map((root) => + NodeFSP.mkdir(root, { recursive: true }), + ), + ); + seedOpenCodeDb(NodePath.join(firstRoot, "opencode.db"), "first", 7); + const legacy = NodePath.join(firstRoot, "storage", "message", "session-first"); + await NodeFSP.mkdir(legacy, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(legacy, "first.json"), + encodeUnknownJsonString({ + id: "first", + role: "assistant", + sessionID: "session-first", + modelID: "openai/gpt-6", + time: { created: Date.parse("2026-08-01T10:00:00Z") }, + tokens: { input: 2, output: 7, cache: { read: 0, write: 0 } }, + }), + ); + seedOpenCodeDb(NodePath.join(secondRoot, "opencode.db"), "second", 11); + seedOpenCodeDb(NodePath.join(externalRoot, "opencode.db"), "external", 100); + await NodeFSP.symlink(firstRoot, alias, "junction"); + }); + const service = yield* UsageService.make; + const first = yield* service.readSummary(WINDOW); + const openCodeSources = first.sources.filter( + (source) => source.fingerprint.provider === "opencode", + ); + assert.strictEqual(openCodeSources.length, 2); + const expectedRoots = yield* Effect.promise(() => + Promise.all([firstRoot, secondRoot].map((root) => NodeFSP.realpath(root))), + ); + assert.deepStrictEqual( + openCodeSources.map((source) => source.fingerprint.resolvedHomePath).sort(), + expectedRoots.sort(), + ); + assert.strictEqual( + first.buckets + .filter((bucket) => bucket.provider === "opencode") + .reduce((sum, bucket) => sum + bucket.totals.outputTokens, 0), + 18, + ); + assert.deepStrictEqual( + openCodeSources.map((source) => source.status), + ["ok", "ok"], + ); + const warm = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(warm.buckets, first.buckets); + const restarted = yield* UsageService.make; + const retained = yield* restarted.readSummary(WINDOW); + assert.deepStrictEqual(retained.buckets, first.buckets); + yield* Effect.promise(async () => { + const legacy = NodePath.join(secondRoot, "storage", "message", "session-second"); + await NodeFSP.mkdir(legacy, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(legacy, "oversize.json"), + "x".repeat(2 * 1024 * 1024 + 1), + ); + }); + const partial = yield* restarted.readSummary(WINDOW); + assert.deepStrictEqual(partial.buckets, first.buckets); + const partialSource = partial.sources.find( + (source) => source.fingerprint.resolvedHomePath === expectedRoots[1], + ); + assert.strictEqual(partialSource?.status, "partial"); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-opencode-test", + home: "", + settings: { + providers: {}, + providerInstances: { + [ProviderInstanceId.make("opencode")]: { + driver: "opencode", + enabled: true, + environment: [{ name: "XDG_DATA_HOME", value: firstData, sensitive: false }], + }, + [ProviderInstanceId.make("opencode-alias")]: { + driver: "opencode", + enabled: true, + environment: [{ name: "OPENCODE_DATA_DIR", value: alias, sensitive: false }], + }, + [ProviderInstanceId.make("opencode-second")]: { + driver: "opencode", + enabled: true, + environment: [{ name: "XDG_DATA_HOME", value: secondData, sensitive: false }], + }, + [ProviderInstanceId.make("opencode-external")]: { + driver: "opencode", + enabled: true, + config: { serverUrl: "https://external.example" }, + environment: [{ name: "XDG_DATA_HOME", value: externalData, sensitive: false }], + }, + }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live( "aggregates usage from configured Antigravity instances and supports warm scanning and WAL updates", () => diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0581f4024c..cada5bc573 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -20,6 +20,7 @@ import * as NodePath from "node:path"; import { ClaudeSettings, CodexSettings, + OpenCodeSettings, type ProviderInstanceConfig, USAGE_CONTRACT_VERSION, type ServerSettings as ServerSettingsValue, @@ -53,6 +54,7 @@ import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; import { resolveAntigravityProfileDirectory } from "../provider/antigravityAuthSupport.ts"; import { readAntigravityDatabase } from "./antigravityUsageReader.ts"; +import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; import { UsageAggregator } from "./usageAggregation.ts"; import { countKnownModels, @@ -107,6 +109,7 @@ export const StandaloneAntigravityConversations = Context.Reference( const decodeCodexSettings = Schema.decodeOption(CodexSettings); const decodeClaudeSettings = Schema.decodeOption(ClaudeSettings); +const decodeOpenCodeSettings = Schema.decodeOption(OpenCodeSettings); /** On-disk shape of the rate snapshot. */ const RatesCacheFile = Schema.Struct({ @@ -429,6 +432,65 @@ export const make = Effect.gen(function* () { dirs.push({ provider: "antigravity", dir, volumeId }); } + // Each local OpenCode instance may use a different data home. External + // servers own their history, so a local home must not stand in for them. + const openCodeInstances: Array> = + Object.values(settings.providerInstances).filter( + (instance) => instance.driver === "opencode", + ); + if (!Object.hasOwn(settings.providerInstances, "opencode")) { + openCodeInstances.push({ config: settings.providers.opencode }); + } + for (const instance of openCodeInstances) { + const decoded = decodeOpenCodeSettings(instance.config ?? {}); + if (Option.isNone(decoded) || decoded.value.serverUrl.trim()) continue; + const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment); + const home = environment.HOME || environment.USERPROFILE || NodeOS.homedir(); + const dataHome = environment.XDG_DATA_HOME?.trim(); + const fallback = path.join( + dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(home, ".local", "share"), + "opencode", + ); + const configured = environment.OPENCODE_DATA_DIR?.trim(); + const roots = configured + ? configured + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + : [fallback]; + for (const root of roots) { + const directory = resolveProviderHomePath(root); + const sourceKey = `opencode\0${directory}`; + const previous = sourceCache.get(sourceKey); + const dir = yield* fileSystem + .realPath(directory) + .pipe(Effect.orElseSucceed(() => previous?.dir ?? directory)); + const key = `opencode\0${dir}`; + if (seen.has(key)) continue; + seen.add(key); + const currentVolumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const hasRetainedHistory = fileCache + .entries() + .some( + ([filePath, entry]) => + entry.provider === "opencode" && + entry.mtimeMs >= retentionCutoffMs && + entry.records.length + entry.tailRecords.length > 0 && + isWithinDirectory(filePath, dir), + ); + const volumeId = + previous?.dir === dir && (hasRetainedHistory || !currentVolumeId) + ? previous.volumeId || currentVolumeId + : currentVolumeId; + if (previous?.dir !== dir || previous.volumeId !== volumeId) { + sourceCache.set(sourceKey, { dir, volumeId }); + cacheDirty = true; + } + activeSourceKeys.add(sourceKey); + dirs.push({ provider: "opencode", dir, volumeId }); + } + } + // Old configured homes can disappear from settings. Keep their identity // only while an in-retention cached transcript could use it again. for (const [sourceKey, source] of sourceCache) { @@ -607,6 +669,43 @@ export const make = Effect.gen(function* () { continue; } + if (provider === "opencode") { + const result = yield* Effect.promise(() => + readOpenCodeUsage(dir, retentionCutoffMs, { + get: (filePath, stamp) => { + const cached = fileCache.get(filePath); + return cached?.provider === "opencode" && + cached.size === stamp.size && + cached.mtimeMs === stamp.mtimeMs + ? cached.records + : undefined; + }, + set: (filePath, stamp, records) => { + fileCache.set(filePath, { + size: stamp.size, + mtimeMs: stamp.mtimeMs, + provider: "opencode", + records, + tailRecords: [], + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, codexState: null }, + }); + cacheDirty = true; + }, + }), + ); + scanned.push({ + provider, + dir, + volumeId, + files: result.missing ? null : result.files, + status: result.error || result.truncated ? "partial" : result.missing ? "missing" : "ok", + ...(result.error || result.truncated + ? { message: "Some local OpenCode history could not be read or exceeded scan limits." } + : {}), + }); + continue; + } + if (provider === "antigravity") { // Native SQLite limits apply to each profile directory independently. let nativeFiles = 0; diff --git a/apps/server/src/usage/opencodeUsageReader.test.ts b/apps/server/src/usage/opencodeUsageReader.test.ts new file mode 100644 index 0000000000..08e97fc5d1 --- /dev/null +++ b/apps/server/src/usage/opencodeUsageReader.test.ts @@ -0,0 +1,203 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Real SQLite and JSON fixtures exercise local usage reads. +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 { describe, expect, it } from "vite-plus/test"; + +import { readOpenCodeUsage, type OpenCodeUsageCache } from "./opencodeUsageReader.ts"; + +const SINCE = Date.parse("2026-07-31T00:00:00Z"); +const CREATED = Date.parse("2026-08-01T10:00:00Z"); +const message = (id: string, output = 8) => + JSON.stringify({ + id, + role: "assistant", + sessionID: "session-1", + modelID: "openai/gpt-6", + time: { created: CREATED }, + tokens: { input: 2, output, reasoning: 3, cache: { read: 4, write: 1 } }, + }); + +async function withRoot(run: (root: string) => Promise) { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "pylon-opencode-usage-")); + try { + await run(root); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } +} + +describe("local OpenCode usage reader", () => { + it("reads SQLite and legacy JSON with the same token accounting", async () => + withRoot(async (root) => { + const database = new NodeSqlite.DatabaseSync(NodePath.join(root, "opencode.db")); + database.exec( + "CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER)", + ); + database + .prepare("INSERT INTO message VALUES (?, ?, ?, ?)") + .run("db-1", "session-1", message("db-1"), CREATED); + database.close(); + const legacy = NodePath.join(root, "storage", "message", "session-1"); + await NodeFSP.mkdir(legacy, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(legacy, "json-1.json"), message("json-1")); + const read = await readOpenCodeUsage(root, SINCE); + expect(read.error).toBe(false); + expect(read.truncated).toBe(false); + const records = read.files.flatMap((file) => file.records); + expect(records).toHaveLength(2); + expect(records.map((record) => record.dedupeKey).sort()).toEqual([ + "opencode:db-1", + "opencode:json-1", + ]); + for (const record of records) { + expect(record.totals).toEqual({ + uncachedInputTokens: 2, + cachedInputTokens: 4, + cacheCreationTokens: 1, + outputTokens: 11, + reasoningTokens: 3, + }); + } + })); + + it("invalidates cached SQLite records when only its WAL changes", async () => + withRoot(async (root) => { + const dbPath = NodePath.join(root, "opencode.db"); + const database = new NodeSqlite.DatabaseSync(dbPath); + database.exec( + "PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0; CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER)", + ); + database + .prepare("INSERT INTO message VALUES (?, ?, ?, ?)") + .run("one", "session-1", message("one"), CREATED); + const entries = new Map< + string, + { + stamp: { size: number; mtimeMs: number }; + records: readonly import("./usageTranscripts.ts").UsageRecord[]; + } + >(); + let stores = 0; + const cache: OpenCodeUsageCache = { + get: (path, stamp) => { + const entry = entries.get(path); + return entry?.stamp.size === stamp.size && entry.stamp.mtimeMs === stamp.mtimeMs + ? entry.records + : undefined; + }, + set: (path, stamp, records) => { + entries.set(path, { stamp, records }); + stores++; + }, + }; + try { + expect( + (await readOpenCodeUsage(root, SINCE, cache)).files.flatMap((file) => file.records), + ).toHaveLength(1); + expect( + (await readOpenCodeUsage(root, SINCE, cache)).files.flatMap((file) => file.records), + ).toHaveLength(1); + expect(stores).toBe(1); + database + .prepare("INSERT INTO message VALUES (?, ?, ?, ?)") + .run("two", "session-1", message("two"), CREATED); + expect( + (await readOpenCodeUsage(root, SINCE, cache)).files.flatMap((file) => file.records), + ).toHaveLength(2); + expect(stores).toBe(2); + } finally { + database.close(); + } + })); + + it("marks oversized legacy files and excessive malformed rows as partial", async () => + withRoot(async (root) => { + const legacy = NodePath.join(root, "storage", "message", "session-1"); + await NodeFSP.mkdir(legacy, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(legacy, "oversize.json"), + "x".repeat(2 * 1024 * 1024 + 1), + ); + const database = new NodeSqlite.DatabaseSync(NodePath.join(root, "opencode.db")); + database.exec( + "CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER)", + ); + const insert = database.prepare("INSERT INTO message VALUES (?, ?, ?, ?)"); + database.exec("BEGIN"); + for (let index = 0; index <= 50_000; index++) { + insert.run(`bad-${index}`, "session-1", "{invalid", CREATED); + } + database.exec("COMMIT"); + database.close(); + const read = await readOpenCodeUsage(root, SINCE); + expect(read.truncated).toBe(true); + expect(read.error).toBe(true); + expect(read.files.flatMap((file) => file.records)).toHaveLength(0); + })); + + it("bounds unrelated root entries before selecting databases", async () => + withRoot(async (root) => { + for (let index = 0; index <= 1_000; index++) { + await NodeFSP.writeFile(NodePath.join(root, `unrelated-${index}`), ""); + } + const database = new NodeSqlite.DatabaseSync(NodePath.join(root, "opencode.db")); + database.exec( + "CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER)", + ); + database + .prepare("INSERT INTO message VALUES (?, ?, ?, ?)") + .run("valid", "session-1", message("valid"), CREATED); + database.close(); + const read = await readOpenCodeUsage(root, SINCE); + expect(read.truncated).toBe(true); + expect(read.files.length).toBeLessThanOrEqual(1); + })); + + it("reports malformed SQLite and JSON without caching either as complete", async () => + withRoot(async (root) => { + const database = new NodeSqlite.DatabaseSync(NodePath.join(root, "opencode.db")); + database.exec( + "CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER)", + ); + database + .prepare("INSERT INTO message VALUES (?, ?, ?, ?)") + .run("bad-db", "session-1", "{invalid", CREATED); + database.close(); + const legacy = NodePath.join(root, "storage", "message", "session-1"); + await NodeFSP.mkdir(legacy, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(legacy, "bad-json.json"), "{invalid"); + const stored: string[] = []; + const cache: OpenCodeUsageCache = { + get: () => undefined, + set: (path) => { + stored.push(path); + }, + }; + for (let attempt = 0; attempt < 2; attempt++) { + const read = await readOpenCodeUsage(root, SINCE, cache); + expect(read.error).toBe(true); + expect(read.truncated).toBe(false); + expect(read.files.flatMap((file) => file.records)).toHaveLength(0); + } + expect(stored).toEqual([]); + })); + + it("treats non-assistant and zero-token messages as normal skips", async () => + withRoot(async (root) => { + const database = new NodeSqlite.DatabaseSync(NodePath.join(root, "opencode.db")); + database.exec( + "CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER)", + ); + const insert = database.prepare("INSERT INTO message VALUES (?, ?, ?, ?)"); + insert.run("user", "session-1", JSON.stringify({ role: "user" }), CREATED); + insert.run("empty", "session-1", JSON.stringify({ role: "assistant", tokens: {} }), CREATED); + database.close(); + const read = await readOpenCodeUsage(root, SINCE); + expect(read.error).toBe(false); + expect(read.truncated).toBe(false); + expect(read.files.flatMap((file) => file.records)).toHaveLength(0); + })); +}); diff --git a/apps/server/src/usage/opencodeUsageReader.ts b/apps/server/src/usage/opencodeUsageReader.ts new file mode 100644 index 0000000000..c42b850a28 --- /dev/null +++ b/apps/server/src/usage/opencodeUsageReader.ts @@ -0,0 +1,310 @@ +// node:sqlite reads live OpenCode databases; Node fs walks legacy JSON history. +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeTimersPromises from "node:timers/promises"; + +import { totalTokens, type UsageRecord } from "./usageTranscripts.ts"; + +function object(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function tokens(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; +} + +function text(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +interface ParsedMessage { + readonly record: UsageRecord | null; + readonly malformed: boolean; +} + +/** OpenCode stores uncached input and reasoning separately from input/output. */ +function parseOpenCodeMessage( + source: string, + fallback: { + readonly id?: string; + readonly sessionId?: string; + readonly timestampMs?: number; + } = {}, +): ParsedMessage { + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + return { record: null, malformed: true }; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { record: null, malformed: true }; + } + const message = object(parsed); + if (message.role !== undefined && message.role !== "assistant") { + return { record: null, malformed: false }; + } + const usage = object(message.tokens); + const cache = object(usage.cache); + const modelReference = object(message.model); + const model = text(modelReference.id) || text(modelReference.modelID) || text(message.modelID); + const timestampMs = object(message.time).created ?? fallback.timestampMs; + const reasoningTokens = tokens(usage.reasoning); + const totals = { + uncachedInputTokens: tokens(usage.input), + cachedInputTokens: tokens(cache.read), + cacheCreationTokens: tokens(cache.write), + outputTokens: tokens(usage.output) + reasoningTokens, + reasoningTokens, + }; + if (totalTokens(totals) === 0) return { record: null, malformed: false }; + if (!model || typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) { + return { record: null, malformed: true }; + } + const id = fallback.id || text(message.id); + const cost = message.cost; + return { + record: { + provider: "opencode", + timestampMs, + model, + sessionId: fallback.sessionId || text(message.sessionID), + totals, + // OpenCode writes zero for models without a known rate, including paid + // subscription models. Let the shared price table estimate those records. + reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null, + fast: false, + dedupeKey: id ? `opencode:${id}` : null, + }, + malformed: false, + }; +} + +export interface OpenCodeUsageReadResult { + readonly files: readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]; + readonly missing: boolean; + readonly error: boolean; + readonly truncated: boolean; +} + +const MAX_FILES = 500; +const MAX_BYTES = 256 * 1024 * 1024; +const MAX_RECORDS = 50_000; +const MAX_JSON_BYTES = 2 * 1024 * 1024; +const MAX_ROOT_ENTRIES = 1_000; + +interface FileStamp { + readonly size: number; + readonly mtimeMs: number; +} + +export interface OpenCodeUsageCache { + readonly get: (path: string, stamp: FileStamp) => readonly UsageRecord[] | undefined; + readonly set: (path: string, stamp: FileStamp, records: readonly UsageRecord[]) => void; +} + +/** Reads current SQLite and pre-migration JSON stores without modifying either. */ +export async function readOpenCodeUsage( + root: string, + sinceMs: number, + cache?: OpenCodeUsageCache, +): Promise { + const files: { path: string; records: UsageRecord[] }[] = []; + let found = false; + let error = false; + let truncated = false; + let bytes = 0; + let recordsRead = 0; + let rowsVisited = 0; + const append = (records: UsageRecord[], record: UsageRecord | null) => { + if (record === null || record.timestampMs < sinceMs) return; + if (recordsRead >= MAX_RECORDS) { + truncated = true; + return; + } + records.push(record); + recordsRead++; + }; + + const stamp = async (path: string, database: boolean): Promise => { + const stat = await NodeFSP.stat(path); + let size = stat.size; + let mtimeMs = stat.mtimeMs; + if (database) { + const wal = await NodeFSP.stat(`${path}-wal`).catch((cause: NodeJS.ErrnoException) => { + if (cause.code === "ENOENT") return null; + throw cause; + }); + if (wal !== null) { + size += wal.size; + mtimeMs = Math.max(mtimeMs, wal.mtimeMs); + } + } + return { size, mtimeMs }; + }; + + let databases: string[] = []; + try { + let rootEntries = 0; + for await (const entry of await NodeFSP.opendir(root)) { + rootEntries++; + if (rootEntries > MAX_ROOT_ENTRIES) { + truncated = true; + break; + } + if (!entry.isFile() || !/^opencode(?:-[a-zA-Z0-9_-]+)?\.db$/.test(entry.name)) continue; + if (databases.length >= MAX_FILES) { + truncated = true; + continue; + } + databases.push(entry.name); + } + databases.sort((a, b) => + a === "opencode.db" ? -1 : b === "opencode.db" ? 1 : a.localeCompare(b), + ); + } catch (cause) { + if (object(cause).code !== "ENOENT") error = true; + } + for (const name of databases) { + found = true; + if (files.length >= MAX_FILES || recordsRead >= MAX_RECORDS) { + truncated = true; + break; + } + const file = { path: NodePath.join(root, name), records: [] as UsageRecord[] }; + files.push(file); + let database: NodeSqlite.DatabaseSync | undefined; + try { + const fileStamp = await stamp(file.path, true); + if (bytes + fileStamp.size > MAX_BYTES) { + truncated = true; + continue; + } + bytes += fileStamp.size; + const cached = cache?.get(file.path, fileStamp); + if (cached !== undefined) { + for (const record of cached) append(file.records, record); + continue; + } + database = new NodeSqlite.DatabaseSync(NodePath.join(root, name), { readOnly: true }); + // A busy live provider should fail this source promptly rather than + // stalling the server while SQLite waits for its writer. + database.exec("PRAGMA busy_timeout = 100"); + const tables = new Set( + database + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => row.name), + ); + if (!tables.has("message") && !tables.has("session_message")) error = true; + let fileMalformed = false; + for (const table of ["message", "session_message"] as const) { + if (!tables.has(table)) continue; + const columns = new Set( + database + .prepare(`PRAGMA table_info(${table})`) + .all() + .map((row) => row.name), + ); + const timestamp = columns.has("time_created") ? "time_created" : "NULL"; + const predicates = table === "session_message" ? ["type = 'assistant'"] : []; + if (timestamp !== "NULL") predicates.push("time_created >= ?"); + const where = predicates.length > 0 ? ` WHERE ${predicates.join(" AND ")}` : ""; + const statement = database.prepare( + `SELECT id, session_id, data, ${timestamp} AS created FROM ${table}${where}`, + ); + let count = 0; + for (const row of statement.iterate(...(timestamp === "NULL" ? [] : [sinceMs]))) { + if (recordsRead >= MAX_RECORDS || rowsVisited >= MAX_RECORDS) { + truncated = true; + break; + } + rowsVisited++; + const parsed = parseOpenCodeMessage(text(row.data), { + id: text(row.id), + sessionId: text(row.session_id), + ...(typeof row.created === "number" ? { timestampMs: row.created } : {}), + }); + if (parsed.malformed) { + error = true; + fileMalformed = true; + } + append(file.records, parsed.record); + if (++count % 256 === 0) await NodeTimersPromises.setImmediate(); + } + } + if (!truncated && !fileMalformed) cache?.set(file.path, fileStamp, file.records); + } catch { + error = true; + } finally { + database?.close(); + } + } + + // Do not follow symlinks, including cycles. The shared aggregator de-duplicates + // SQLite rows against legacy JSON copies after the per-file cache is loaded. + const directories = [NodePath.join(root, "storage", "message")]; + let visitedDirectories = 0; + let visitedEntries = 0; + while (directories.length > 0) { + if ( + files.length >= MAX_FILES || + recordsRead >= MAX_RECORDS || + visitedDirectories >= MAX_FILES || + visitedEntries >= MAX_FILES * 20 + ) { + truncated = true; + break; + } + const directory = directories.pop()!; + visitedDirectories++; + try { + for await (const entry of await NodeFSP.opendir(directory)) { + visitedEntries++; + if ( + files.length >= MAX_FILES || + recordsRead >= MAX_RECORDS || + visitedEntries >= MAX_FILES * 20 + ) { + truncated = true; + break; + } + const path = NodePath.join(directory, entry.name); + if (entry.isDirectory()) { + directories.push(path); + } else if (entry.isFile() && entry.name.endsWith(".json")) { + found = true; + const id = entry.name.slice(0, -5); + const file = { path, records: [] as UsageRecord[] }; + files.push(file); + try { + const fileStamp = await stamp(path, false); + if (fileStamp.size > MAX_JSON_BYTES || bytes + fileStamp.size > MAX_BYTES) { + truncated = true; + continue; + } + bytes += fileStamp.size; + const cached = cache?.get(path, fileStamp); + if (cached !== undefined) { + for (const record of cached) append(file.records, record); + continue; + } + const parsed = parseOpenCodeMessage(await NodeFSP.readFile(path, "utf8"), { id }); + if (parsed.malformed) error = true; + append(file.records, parsed.record); + if (!parsed.malformed) cache?.set(path, fileStamp, file.records); + } catch (cause) { + if (object(cause).code !== "ENOENT") error = true; + } + } + } + } catch (cause) { + if (object(cause).code !== "ENOENT") error = true; + } + } + return { files, missing: !found && !error, error, truncated }; +} diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 4bd7ff7ce6..43fb640fc2 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -91,6 +91,7 @@ describe("buildPeriodColumns", () => { { provider: "claude", value: 20 }, { provider: "grok", value: 0 }, { provider: "antigravity", value: 5 }, + { provider: "opencode", value: 0 }, ]); }); diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 05c0925e74..e4314f522d 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 { AntigravityIcon, ClaudeAI, GrokIcon, type Icon, OpenAI } from "../Icons"; +import { AntigravityIcon, ClaudeAI, GrokIcon, OpenCodeIcon, type Icon, OpenAI } from "../Icons"; type UsageProviderPresentation = { readonly label: string; @@ -35,6 +35,11 @@ export const PROVIDER_PRESENTATION = { color: "#4285f4", mark: AntigravityIcon, }, + opencode: { + label: "OpenCode", + color: "#8b5cf6", + 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 7bfb7a20da..ff3c7d7c5d 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -2,7 +2,7 @@ ## Understand your usage -**Usage** combines Codex, Claude Code, Grok Build, and Antigravity session history from your connected +**Usage** combines Codex, Claude Code, Grok Build, Antigravity, and local OpenCode session history from your connected environments. It shows token use, cache savings, provider shares, model breakdowns, and estimated API-equivalent cost. These estimates are not your subscription bill. @@ -21,6 +21,12 @@ Antigravity totals come from saved native conversations, including standalone CL available. Models without known prices still contribute tokens; set a custom model price to include their estimated cost. +OpenCode totals come from the local SQLite store and older JSON messages on each connected server. +Pylon reads a local provider instance's `OPENCODE_DATA_DIR` or `XDG_DATA_HOME` when configured, +and counts shared data directories once. An external OpenCode server URL does not expose its +history to Pylon; its remote account and sessions are not included by reading a local directory. +An oversized or unreadable store appears as partial coverage rather than a complete total. + **Past 24h** shows an hourly chart of the rolling 24-hour period; **7 days**, **30 days**, and **90 days** use daily resolution. The environment filter applies to both Usage and Limits. On web and desktop, Pylon remembers your view, period, metric, and environment selection. diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 33ff1a8bbe..5a5807edf4 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -21,18 +21,23 @@ 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 = 7 as const; +export const USAGE_CONTRACT_VERSION = 8 as const; /** * Oldest {@link UsageSummary} version a current client will still merge. * - * v6 adds `antigravity` to {@link UsageProviderKind}; v4 and v5 Claude/Codex/Grok - * buckets remain valid, so mixed-version environments keep those totals instead - * of treating every older server as stale. + * v8 adds OpenCode local history. Older summaries remain valid so mixed-version + * environments keep their existing provider totals. */ export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; -export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok", "antigravity"]); +export const UsageProviderKind = Schema.Literals([ + "claude", + "codex", + "grok", + "antigravity", + "opencode", +]); export type UsageProviderKind = typeof UsageProviderKind.Type; /** diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 60412b9f1d..6f040255b1 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -395,14 +395,14 @@ describe("mergeUsage", () => { expect(merged.staleEnvironments).toEqual(["env-b"]); }); - it("keeps the previous compatible contract version so additive provider expansions still merge", () => { + it("merges a v7 summary with an OpenCode v8 summary", () => { const merged = mergeUsage( [ environment( "env-a", summary( - [bucket({ costUsd: 10 })], - [{ provider: "claude", hostId: "mac", homePath: "/a" }], + [bucket({ costUsd: 10, provider: "opencode", model: "openai/gpt-6" })], + [{ provider: "opencode", hostId: "mac", homePath: "/a" }], ), ), environment( @@ -410,7 +410,7 @@ describe("mergeUsage", () => { summary( [bucket({ costUsd: 4, provider: "codex", model: "gpt-5.6-sol" })], [{ provider: "codex", hostId: "linux", homePath: "/b" }], - USAGE_CONTRACT_VERSION - 1, + 7, ), ), ],