Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/mobile/src/features/usage/usageProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe
* Series and table order. The chart stacks providers from the bottom in this
* order, so it also fixes which band sits on top of the bars.
*/
export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"];
export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok", "opencode"];

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

/**
Expand All @@ -23,5 +24,6 @@ export function useProviderColors(): Record<UsageProviderKind, string> {
claude: "#d97757",
codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",
grok: scheme === "dark" ? "#a1a1aa" : "#52525b",
opencode: "#8b5cf6",
};
}
125 changes: 125 additions & 0 deletions apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeSqlite from "node:sqlite";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { UsageDay } from "@t3tools/contracts";
import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";

import * as ServerConfig from "../config.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as UsageService from "./UsageService.ts";

const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));

const EmptyRatesHttpClient = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}))),
),
);

it.effect("includes OpenCode SQLite usage and source health in the summary", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-usage-service-" });
const databasePath = path.join(root, "opencode.db");
const completedAt = Date.parse("2026-08-27T12:00:00.000Z");

const database = new NodeSqlite.DatabaseSync(databasePath);
try {
database.exec(`
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
`);
database
.prepare(
"INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)",
)
.run(
"message-1",
"session-1",
completedAt,
completedAt,
encodeUnknownJson({
role: "assistant",
time: { completed: completedAt },
providerID: "openai",
modelID: "gpt-5.4",
tokens: {
input: 5,
output: 2,
reasoning: 1,
cache: { read: 4, write: 3 },
},
cost: 0.25,
}),
);
} finally {
database.close();
}

const usageService = yield* UsageService.make.pipe(
Effect.provide(
Layer.mergeAll(
ServerSettings.layerTest({
providers: {
claudeAgent: { homePath: path.join(root, "claude") },
codex: { homePath: path.join(root, "codex") },
},
}),
ServerConfig.layerTest(process.cwd(), path.join(root, "t3-home")),
EmptyRatesHttpClient,
Layer.succeed(HostProcessEnvironment, {
GROK_HOME: path.join(root, "grok"),
OPENCODE_DB: databasePath,
}),
),
),
);

const summary = yield* usageService.readSummary({
sinceDay: UsageDay.make("2026-08-27"),
untilDay: UsageDay.make("2026-08-27"),
timeZone: "UTC",
});

expect(
summary.sources.find((source) => source.fingerprint.provider === "opencode"),
).toMatchObject({
status: "ok",
scannedFiles: 1,
skippedFiles: 0,
malformedRecords: 0,
distinctSessions: 1,
});
expect(summary.buckets.find((bucket) => bucket.provider === "opencode")).toMatchObject({
day: "2026-08-27",
model: "openai/gpt-5.4",
totals: {
uncachedInputTokens: 5,
cachedInputTokens: 4,
cacheCreationTokens: 3,
outputTokens: 3,
reasoningTokens: 1,
},
costUsd: 0.25,
costSource: "providerReported",
records: 1,
sessions: 1,
});
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
);
108 changes: 99 additions & 9 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
/**
* 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 stores (Claude Code, Codex,
* Grok Build, and OpenCode) 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
* scans only reparse files that changed.
* JSONL transcripts are append-only, so parsed records are memoised per file
* by `(size, mtime)`. OpenCode's windowed SQLite query is read fresh. A cold
* 30-day JSONL scan of ~1.4 GB lands around 2-3 seconds; warm scans only reparse
* files that changed.
*
* @module UsageService
*/
import * as NodeOS from "node:os";

import {
USAGE_CONTRACT_VERSION,
type UsageProviderKind,
type UsageSource,
type UsageSummary,
type UsageSummaryInput,
Expand Down Expand Up @@ -46,14 +47,15 @@ import {
readDirectoryVolumeId,
readTranscriptRecords,
} from "./usageTranscriptReader.ts";
import { readOpenCodeUsage, resolveOpenCodeDatabasePaths } from "./usageOpenCode.ts";
import {
decodeScanCache,
dedupeWithinFile,
encodeScanCache,
pruneScanCache,
type ScanCache,
} from "./usageScanCache.ts";
import type { UsageRecord } from "./usageTranscripts.ts";
import type { UsageRecord, UsageTranscriptProviderKind } from "./usageTranscripts.ts";

const LITELLM_RATES_URL =
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
Expand Down Expand Up @@ -240,6 +242,38 @@ export const make = Effect.gen(function* () {
];
});

/** Resolves every persisted database the installed OpenCode channel can use. */
const resolveOpenCodeDatabases = Effect.fn("UsageService.resolveOpenCodeDatabases")(function* () {
const xdgDataHome = hostEnvironment["XDG_DATA_HOME"]?.trim() ?? "";
const dataRoot =
xdgDataHome.length > 0
? path.resolve(expandHomePath(xdgDataHome))
: path.join(NodeOS.homedir(), ".local", "share");
const dataDirectory = path.join(dataRoot, "opencode");
const directoryEntries = yield* fileSystem
.readDirectory(dataDirectory)
.pipe(Effect.catchCause(() => Effect.succeed([] as const)));

const databasePaths = resolveOpenCodeDatabasePaths({
dataDirectory,
databaseOverride: hostEnvironment["OPENCODE_DB"],
disableChannelDatabase: hostEnvironment["OPENCODE_DISABLE_CHANNEL_DB"],
Comment thread
aflekkas marked this conversation as resolved.
Outdated
directoryEntries,
path,
});
const hasOverride = (hostEnvironment["OPENCODE_DB"]?.trim().length ?? 0) > 0;
return {
databasePaths,
// Buckets are provider-scoped rather than source-scoped. Treat all
// channel databases as one source so another environment cannot claim
// one database and accidentally merge duplicate buckets from another.
sourcePath:
hasOverride && databasePaths.length === 1
? (databasePaths[0] ?? dataDirectory)
: dataDirectory,
};
});

/**
* Loads the persisted scan cache exactly once per process.
*
Expand Down Expand Up @@ -277,7 +311,7 @@ export const make = Effect.gen(function* () {
filePath: string,
size: number,
mtimeMs: number,
provider: UsageProviderKind,
provider: UsageTranscriptProviderKind,
): Effect.Effect<readonly UsageRecord[]> =>
Effect.gen(function* () {
const cached = fileCache.get(filePath);
Expand Down Expand Up @@ -345,6 +379,7 @@ export const make = Effect.gen(function* () {
// The home resolvers ask for `Path` themselves; satisfy them from the
// instance we already hold so `readSummary` stays context-free.
const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path));
const openCode = yield* resolveOpenCodeDatabases();
const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`);
if (Option.isNone(windowStart)) {
return yield* new UsageReadError({
Expand Down Expand Up @@ -425,6 +460,61 @@ export const make = Effect.gen(function* () {
});
}

if (openCode.databasePaths.length > 0) {
const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(openCode.sourcePath));
const sessionIds = new Set<string>();
let existingDatabases = 0;
let scannedFiles = 0;
let skippedFiles = 0;
let malformedRecords = 0;

for (const databasePath of openCode.databasePaths) {
const exists = yield* fileSystem
.exists(databasePath)
.pipe(Effect.catchCause(() => Effect.succeed(false)));
if (!exists) continue;
existingDatabases += 1;

const result = yield* Effect.promise(() => readOpenCodeUsage(databasePath, windowStartMs));
if (result === null) {
skippedFiles += 1;
continue;
}

scannedFiles += 1;
malformedRecords += result.malformedRecords;
for (const record of result.records) {
if (aggregator.add(record) && record.sessionId.length > 0) {
sessionIds.add(record.sessionId);
}
}
}

const missing = existingDatabases === 0;
const failed = existingDatabases > 0 && scannedFiles === 0;
const partial = !missing && !failed && (skippedFiles > 0 || malformedRecords > 0);
sources.push({
fingerprint: {
hostId,
provider: "opencode",
resolvedHomePath: openCode.sourcePath,
volumeId,
},
status: missing ? "missing" : failed ? "failed" : partial ? "partial" : "ok",
scannedFiles,
skippedFiles,
malformedRecords,
distinctSessions: sessionIds.size,
message: missing
? "No usage database on this environment."
: failed
? "The usage database could not be read."
: partial
? "Some OpenCode usage databases or rows were skipped."
: null,
});
}

const pruned = pruneScanCache(fileCache, {
livePaths,
walkedRoots,
Expand Down
Loading
Loading