diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 8ede20c1ebad..e83435b467f1 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -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"; @@ -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))); @@ -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(",") @@ -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", @@ -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 = diff --git a/apps/server/src/usage/devinUsageReader.ts b/apps/server/src/usage/devinUsageReader.ts new file mode 100644 index 000000000000..45d68c3681c1 --- /dev/null +++ b/apps/server/src/usage/devinUsageReader.ts @@ -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 { + 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(); + 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 }; +} diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 6c95a36df19b..c65b9fcad9f6 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -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"; @@ -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); diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 5fde08553888..46fd1fc8d69f 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -629,6 +629,7 @@ describe("parseDevinCanonicalLogLine", () => { reasoningTokens: 20, }, reportedCostUsd: 0.0125, + fast: false, dedupeKey: "event-prompt", }); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 641338a1e812..cb8ffb0c1fe9 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1082,8 +1082,8 @@ function UsageCoverageNotice({ ) : null} {hasDevinSource ? ( - Devin ACP usage is read from this T3 server'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." : "."} ) : null} diff --git a/docs/user/usage.md b/docs/user/usage.md index 037b32f78cba..b06f9a3dac2b 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -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