Skip to content
Merged
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
3 changes: 3 additions & 0 deletions apps/mobile/src/features/usage/usageProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ export const PROVIDER_ORDER: readonly UsageProviderKind[] = [
"claude",
"grok",
"antigravity",
"opencode",
];

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

/**
Expand All @@ -30,5 +32,6 @@ export function useProviderColors(): Record<UsageProviderKind, string> {
codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",
grok: scheme === "dark" ? "#a1a1aa" : "#52525b",
antigravity: "#4285f4",
opencode: "#8b5cf6",
};
}
132 changes: 132 additions & 0 deletions apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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",
() =>
Expand Down
99 changes: 99 additions & 0 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import * as NodePath from "node:path";
import {
ClaudeSettings,
CodexSettings,
OpenCodeSettings,
type ProviderInstanceConfig,
USAGE_CONTRACT_VERSION,
type ServerSettings as ServerSettingsValue,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -107,6 +109,7 @@ export const StandaloneAntigravityConversations = Context.Reference<string>(

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({
Expand Down Expand Up @@ -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<Pick<ProviderInstanceConfig, "config" | "environment">> =
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) {
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading