Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read usage from configured external OpenCode servers

When settings.providers.opencode.serverUrl targets an OpenCode server on another machine, this unconditionally scans the T3 server process's local XDG data directory. Sessions driven through the supported external-server path are stored remotely, so the Usage page reports the source as missing or silently omits all of that OpenCode activity; the reader needs to account for the configured server mode rather than treating the local store as authoritative.

AGENTS.md reference: AGENTS.md:L19-L21

Useful? React with 👍 / 👎.

let rates: RateTable = new Map();
let ratesFetchedAtMs: number | null = null;
let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable";
Expand Down Expand Up @@ -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<string>();
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,
Expand Down
155 changes: 155 additions & 0 deletions apps/server/src/usage/usageOpenCodeReader.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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 });
}
});
});
Loading
Loading