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
46 changes: 32 additions & 14 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAuthSupport.ts";
import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts";
import { parseDevinAccountConsumptionPayload } from "./devinAccountUsage.ts";
import { readDevinUsage } from "./devinUsageReader.ts";
import { readOpenCodeUsage } from "./opencodeUsageReader.ts";
import { readAntigravityUsage } from "./antigravityUsageReader.ts";
import { readCursorAccountUsage } from "./cursorUsageReader.ts";
Expand Down Expand Up @@ -739,7 +740,33 @@ export const make = Effect.gen(function* () {
Effect.provideService(Path.Path, path),
);
const scanned: ScannedDir[] = [];
const home = NodeOS.homedir();
const userHome =
(platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home;
const dataHome = hostEnvironment["XDG_DATA_HOME"]?.trim();
const dataRoot =
dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(userHome, ".local", "share");
const devinCliDir = path.join(dataRoot, "devin", "cli");
const devinDir = yield* fileSystem
.realPath(devinCliDir)
.pipe(Effect.orElseSucceed(() => devinCliDir));
const devin = yield* Effect.promise(() => readDevinUsage(devinDir, windowStartMs));
if (!devin.missing) {
scanned.push({
provider: "devin",
dir: devinDir,
volumeId: yield* Effect.promise(() => readDirectoryVolumeId(devinDir)),
files: devin.files,
status: devin.error ? "partial" : "ok",
message: devin.error
? "Some Devin CLI history could not be read."
: "Devin usage is read from the Devin CLI's local session history.",
});
}
for (const { provider, dir, volumeId, fileName, eventLog } of dirs) {
// Devin's own history already includes the sessions T3 ran, so T3's
// event logs are only a fallback when that history is absent.
if (provider === "devin" && !devin.missing) continue;
const exists = yield* fileSystem
.exists(dir)
.pipe(Effect.catchCause(() => Effect.succeed(false)));
Expand All @@ -764,7 +791,6 @@ export const make = Effect.gen(function* () {
scanned.push({ provider, dir, volumeId, files: parsedFiles });
}

const home = NodeOS.homedir();
const envRoots = Effect.fnUntraced(function* (key: string, defaults: readonly string[]) {
const roots = hostEnvironment[key]
?.split(",")
Expand All @@ -779,13 +805,7 @@ export const make = Effect.gen(function* () {
}
return [...canonical];
});
const dataHome = hostEnvironment["XDG_DATA_HOME"]?.trim();
for (const dir of yield* envRoots("OPENCODE_DATA_DIR", [
path.join(
dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(home, ".local", "share"),
"opencode",
),
])) {
for (const dir of yield* envRoots("OPENCODE_DATA_DIR", [path.join(dataRoot, "opencode")])) {
const result = yield* Effect.promise(() => readOpenCodeUsage(dir, windowStartMs));
scanned.push({
provider: "opencode",
Expand Down Expand Up @@ -852,20 +872,18 @@ export const make = Effect.gen(function* () {
...(failed ? { message: "Some Antigravity history could not be read." } : {}),
});
}
const cursorUserHome =
(platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home;
const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim();
const cursorHome =
platform === "darwin"
? path.join(cursorUserHome, "Library", "Application Support")
? path.join(userHome, "Library", "Application Support")
: platform === "win32"
? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming")
? hostEnvironment["APPDATA"] || path.join(userHome, "AppData", "Roaming")
: configHome && path.isAbsolute(configHome)
? configHome
: path.join(cursorUserHome, ".config");
: path.join(userHome, ".config");
const cursorAuthPath =
platform === "darwin"
? path.join(cursorUserHome, ".cursor", "auth.json")
? path.join(userHome, ".cursor", "auth.json")
: path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json");
const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"];
const loginUnavailable =
Expand Down
106 changes: 106 additions & 0 deletions apps/server/src/usage/devinUsageReader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// node:sqlite reads the live Devin CLI session database.
// @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 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.trim() : "";
}

export interface DevinUsageReadResult {
readonly files: readonly { readonly path: string; readonly records: readonly UsageRecord[] }[];
readonly missing: boolean;
readonly error: boolean;
}

/**
* Reads per-request usage from Devin's `sessions.db`, which holds every CLI
* session including the ones T3 drives over ACP.
*
* Each assistant message records its own request metrics, and `input_tokens`
* already excludes cache reads. Forked sessions copy nodes, so the same
* message appears in many rows; its `message_id` identifies it once. Only the
* usage fields are extracted in SQL so large tool outputs never reach JS.
*/
export async function readDevinUsage(root: string, sinceMs: number): Promise<DevinUsageReadResult> {
const path = NodePath.join(root, "sessions.db");
try {
if (!(await NodeFSP.stat(path)).isFile()) return { files: [], missing: true, error: false };
} catch (cause) {
const missing = (cause as { code?: unknown }).code === "ENOENT";
return { files: [], missing, error: !missing };
}

const records: UsageRecord[] = [];
const seen = new Set<string>();
let error = false;
let database: NodeSqlite.DatabaseSync | undefined;
try {
database = new NodeSqlite.DatabaseSync(path, { 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");
// Row `created_at` is Unix seconds and is never earlier than the message
// itself (fork copies are written later), so it safely bounds the scan.
const statement = database.prepare(`
SELECT
session_id,
created_at,
json_extract(chat_message, '$.message_id') AS id,
json_extract(chat_message, '$.metadata.created_at') AS message_created_at,
json_extract(chat_message, '$.metadata.generation_model') AS model,
json_extract(chat_message, '$.metadata.metrics.input_tokens') AS input_tokens,
json_extract(chat_message, '$.metadata.metrics.output_tokens') AS output_tokens,
json_extract(chat_message, '$.metadata.metrics.cache_read_tokens') AS cache_read_tokens,
json_extract(chat_message, '$.metadata.metrics.cache_creation_tokens') AS cache_creation_tokens
FROM message_nodes
WHERE created_at >= ? AND json_extract(chat_message, '$.role') = 'assistant'
ORDER BY row_id
`);
let count = 0;
for (const row of statement.iterate(Math.floor(sinceMs / 1000))) {
if (++count % 256 === 0) await NodeTimersPromises.setImmediate();
const id = text(row.id);
if (!id || seen.has(id)) continue;
seen.add(id);
const parsedAt = Date.parse(text(row.message_created_at));
const timestampMs = Number.isFinite(parsedAt)
? parsedAt
: typeof row.created_at === "number"
? row.created_at * 1000
: Number.NaN;
if (!Number.isFinite(timestampMs) || timestampMs < sinceMs) continue;
const totals = {
uncachedInputTokens: tokens(row.input_tokens),
cachedInputTokens: tokens(row.cache_read_tokens),
cacheCreationTokens: tokens(row.cache_creation_tokens),
outputTokens: tokens(row.output_tokens),
reasoningTokens: 0,
};
if (totalTokens(totals) === 0) continue;
records.push({
provider: "devin",
timestampMs,
model: text(row.model) || "devin",
sessionId: text(row.session_id),
totals,
reportedCostUsd: null,
fast: false,
dedupeKey: `devin:${id}`,
});
}
} catch {
error = true;
} finally {
database?.close();
}
return { files: [{ path, records }], missing: false, error };
}
74 changes: 74 additions & 0 deletions apps/server/src/usage/usageTranscriptReader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as NodeSqlite from "node:sqlite";
import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest";

import { readTranscriptRecords } from "./usageTranscriptReader.ts";
import { readDevinUsage } from "./devinUsageReader.ts";
import { readOpenCodeUsage } from "./opencodeUsageReader.ts";
import { readCursorAccountUsage } from "./cursorUsageReader.ts";
import { readAntigravityUsage } from "./antigravityUsageReader.ts";
Expand Down Expand Up @@ -515,6 +516,79 @@ describe("SQLite usage readers", () => {
}
});

it("counts forked Devin message copies once using each message's own time and metrics", async () => {
const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "sessions.db"));
try {
db.exec(
"CREATE TABLE message_nodes (row_id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, node_id INTEGER, chat_message TEXT, created_at INTEGER)",
);
const assistant = (id: string, createdAt: string) =>
JSON.stringify({
message_id: id,
role: "assistant",
content: "x".repeat(1000),
metadata: {
created_at: createdAt,
generation_model: "swe-2-max",
metrics: {
input_tokens: 156,
output_tokens: 467,
cache_read_tokens: 80781,
cache_creation_tokens: null,
},
},
});
const insert = db.prepare(
"INSERT INTO message_nodes (session_id, node_id, chat_message, created_at) VALUES (?, ?, ?, ?)",
);
insert.run("brave-otter", 1, JSON.stringify({ role: "user", content: "hi" }), 1780000000);
insert.run("brave-otter", 2, assistant("msg-1", "2026-05-28T20:26:40.500Z"), 1780000000);
// A fork written a day later copies msg-1 and adds msg-2.
insert.run("brave-otter-fork", 1, assistant("msg-1", "2026-05-28T20:26:40.500Z"), 1780086400);
insert.run("brave-otter-fork", 2, assistant("msg-2", "2026-05-29T20:26:41Z"), 1780086401);

const all = await readDevinUsage(dir, 0);
assert.isFalse(all.error);
const records = all.files.flatMap((file) => file.records);
assert.deepStrictEqual(
records.map((record) => [record.dedupeKey, record.sessionId, record.timestampMs]),
[
["devin:msg-1", "brave-otter", 1780000000500],
["devin:msg-2", "brave-otter-fork", 1780086401000],
],
);
assert.strictEqual(records[0]?.model, "swe-2-max");
assert.deepStrictEqual(records[0]?.totals, {
uncachedInputTokens: 156,
cachedInputTokens: 80781,
cacheCreationTokens: 0,
outputTokens: 467,
reasoningTokens: 0,
});

// The fork's later copy must not pull msg-1 into a window that starts after it.
const recent = await readDevinUsage(dir, 1780086400000);
assert.deepStrictEqual(
recent.files.flatMap((file) => file.records).map((record) => record.dedupeKey),
["devin:msg-2"],
);
} finally {
db.close();
}
});

it("reports a missing Devin history separately from an unreadable one", async () => {
assert.deepStrictEqual(await readDevinUsage(dir, 0), {
files: [],
missing: true,
error: false,
});
await NodeFSP.writeFile(NodePath.join(dir, "sessions.db"), "not a database");
const unreadable = await readDevinUsage(dir, 0);
assert.isFalse(unreadable.missing);
assert.isTrue(unreadable.error);
});

it("deduplicates Antigravity generation and step usage while preserving retry model and token buckets", async () => {
const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "session-1.db"));
const stamp = protoNumber(1, 1780000000);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/usage/usageTranscripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,7 @@ describe("parseDevinCanonicalLogLine", () => {
reasoningTokens: 20,
},
reportedCostUsd: 0.0125,
fast: false,
dedupeKey: "event-prompt",
});
});
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1082,8 +1082,8 @@ function UsageCoverageNotice({
) : null}
{hasDevinSource ? (
<span>
Devin ACP usage is read from this T3 server&apos;s local event logs. Devin account billing
is kept separate from the local token/cost estimate
Devin usage is read from local Devin CLI history on each server. Devin account billing is
kept separate from the local token/cost estimate
{accountUsage?.status === "available" ? " and is shown below." : "."}
</span>
) : null}
Expand Down
5 changes: 3 additions & 2 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ desktop when the terminal is not focused. Customize `usage.open` in
environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent
cost. These estimates are not your subscription bill.

Devin ACP records come from canonical T3 provider event logs and cover sessions driven through the
selected T3 server. Use the provider filter above the breakdown to focus the chart and tables, and
Devin records come from the Devin CLI's local session history, so they include sessions run
outside T3. When that history is missing, T3 falls back to its own event logs, which only cover
sessions driven through the selected T3 server. Use the provider filter above the breakdown to focus the chart and tables, and
use the download button to export the current window as CSV. The page can optionally show official
Devin organization ACUs in a separate section when a `cog_...` service key with
`ViewOrgConsumption` permission and `DEVIN_ORG_ID` are configured on the server; ACUs are never
Expand Down