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
32 changes: 31 additions & 1 deletion src/main/db/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,17 @@ export function initDatabase(dbPath: string) {
value INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_usage_events_kind ON usage_events (kind);
CREATE TABLE IF NOT EXISTS usage_token_ledger (
provider TEXT NOT NULL,
scope_id TEXT NOT NULL,
epoch INTEGER NOT NULL,
last_counter INTEGER NOT NULL,
PRIMARY KEY (provider, scope_id, epoch)
);
CREATE TABLE IF NOT EXISTS usage_token_samples (
sample_id TEXT PRIMARY KEY,
ts INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS scheduled_tasks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
Expand Down Expand Up @@ -225,7 +236,7 @@ export function initDatabase(dbPath: string) {

// Baseline schema version for future DB migrations.
// New upgrade steps should live behind this gate when we need them.
const SCHEMA_VERSION = 26;
const SCHEMA_VERSION = 27;

const storedVersion = Number(
(
Expand Down Expand Up @@ -496,6 +507,25 @@ export function initDatabase(dbPath: string) {
}
}

if (storedVersion < 27) {
// Main-process token ledger: cumulative-counter state keyed by provider
// scope+epoch, and exact-once dedup of per-call usage samples. Counted
// amounts land in usage_events as kind="tokens_v2" rows.
sqlite.exec(`
CREATE TABLE IF NOT EXISTS usage_token_ledger (
provider TEXT NOT NULL,
scope_id TEXT NOT NULL,
epoch INTEGER NOT NULL,
last_counter INTEGER NOT NULL,
PRIMARY KEY (provider, scope_id, epoch)
);
CREATE TABLE IF NOT EXISTS usage_token_samples (
sample_id TEXT PRIMARY KEY,
ts INTEGER NOT NULL
);
`);
}

sqlite
.prepare(
"INSERT INTO app_state (key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
Expand Down
5 changes: 5 additions & 0 deletions src/main/db/runtimeItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,11 @@ export function dbApplyThreadRuntimeEvents(
}
break;

case "usage.spent":
// Token consumption is not a chat item; the usage ledger persists it
// (recordUsageSpentFromRuntimeEvents) alongside this function.
break;

default:
break;
}
Expand Down
119 changes: 119 additions & 0 deletions src/main/profile/tokenStats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import Database from "better-sqlite3";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { closeDatabase, initDatabase } from "../db/connection";
import { dbAppendUsageEvents } from "../db/usageEvents";
import { computeProfileTokenStats } from "./tokenStats";

// node_modules/better-sqlite3 may be compiled for Electron's ABI. Fall back to
// the Node-ABI binding used by the headless server (mirrors src/main/db tests).
const serverNativeBinding = join(process.cwd(), "dist", "server-native", "better_sqlite3.node");
let nativeBindingEnv: string | undefined;

function databaseOpens(nativeBinding?: string): boolean {
if (nativeBinding && !existsSync(nativeBinding)) return false;
try {
const database = nativeBinding
? new Database(":memory:", { nativeBinding })
: new Database(":memory:");
database.close();
return true;
} catch {
return false;
}
}

if (!databaseOpens()) {
if (!databaseOpens(serverNativeBinding)) {
execFileSync(process.execPath, [join(process.cwd(), "scripts", "prepare-server-native.mjs")], {
stdio: "inherit",
});
}
if (!databaseOpens(serverNativeBinding)) {
throw new Error("Unable to prepare a Node-compatible better-sqlite3 binding for tests.");
}
nativeBindingEnv = serverNativeBinding;
}

describe("computeProfileTokenStats (real sqlite)", () => {
let dir: string;

beforeEach(() => {
if (nativeBindingEnv) {
process.env.PORACODE_BETTER_SQLITE3_NATIVE_BINDING = nativeBindingEnv;
}
dir = mkdtempSync(join(tmpdir(), "lc-tokenstats-test-"));
initDatabase(join(dir, "state.sqlite"));
});

afterEach(() => {
closeDatabase();
rmSync(dir, { recursive: true, force: true });
});

it("sums legacy tokens and exact tokens_v2 rows together", () => {
const now = Date.now();
dbAppendUsageEvents([
{ ts: now, kind: "tokens", provider: "claude", value: 100 },
{ ts: now, kind: "tokens_v2", provider: "claude", value: 25 },
{ ts: now, kind: "tokens_v2", provider: "codex", value: 50 },
// Zero/negative deltas and unrelated kinds never reach the sums.
{ ts: now, kind: "tokens_v2", provider: "codex", value: 0 },
{ ts: now, kind: "turn", provider: "codex", value: 4000 },
]);

const stats = computeProfileTokenStats({ utcOffsetMinutes: 0 });
expect(stats.available).toBe(true);
expect(stats.lifetimeTokens).toBe(175);
expect(stats.providers.find((p) => p.provider === "claude")?.tokens).toBe(125);
expect(stats.providers.find((p) => p.provider === "codex")?.tokens).toBe(50);
});

it("lists base providers with activity but no tokens_v2 rows as unavailable", () => {
const now = Date.now();
dbAppendUsageEvents([
{ ts: now, kind: "tokens_v2", provider: "claude", value: 10 },
{ ts: now, kind: "thread_started", provider: "claude" },
{ ts: now, kind: "thread_started", provider: "kimi" },
{ ts: now, kind: "turn", provider: "qwen", value: 5000 },
// Account-scoped kinds fold to their base provider for the honesty list.
{ ts: now, kind: "turn", provider: "kimi:for-coding", value: 3000 },
// A zero-value tokens_v2 row still proves exact coverage.
{ ts: now, kind: "thread_started", provider: "codex" },
{ ts: now, kind: "tokens_v2", provider: "codex", value: 0 },
]);

const stats = computeProfileTokenStats({ utcOffsetMinutes: 0 });
expect(stats.unavailableProviders).toEqual(["kimi", "qwen"]);
});

it("reports no unavailable providers when the log has no token rows", () => {
dbAppendUsageEvents([{ ts: Date.now(), kind: "message", provider: "claude" }]);

const stats = computeProfileTokenStats({ utcOffsetMinutes: 0 });
expect(stats.available).toBe(false);
expect(stats.lifetimeTokens).toBe(0);
expect(stats.unavailableProviders).toEqual([]);
});

it("ignores activity from before the first exact (tokens_v2) row", () => {
const now = Date.now();
const day = 86_400_000;
dbAppendUsageEvents([
// Pre-ledger-era activity: qwen ran before any exact telemetry existed on
// this device — not evidence of missing coverage.
{ ts: now - 10 * day, kind: "thread_started", provider: "qwen" },
{ ts: now - 10 * day, kind: "turn", provider: "qwen", value: 5000 },
// Ledger era starts here.
{ ts: now - day, kind: "tokens_v2", provider: "claude", value: 10 },
// kimi ran in the exact era but produced no exact rows.
{ ts: now, kind: "thread_started", provider: "kimi" },
]);

const stats = computeProfileTokenStats({ utcOffsetMinutes: 0 });
expect(stats.unavailableProviders).toEqual(["kimi"]);
});
});
46 changes: 41 additions & 5 deletions src/main/profile/tokenStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ import { accountLabel, modelKey, modelLabel, providerLabel } from "./labels";

/**
* Token usage from Poracode's own activity, read from the durable `usage_events`
* log (kind="tokens" - per-turn deltas captured at the canonical-event layer for
* every provider, incl. all ACP agents). No external transcript scanning, and no
* dependency on threads (survives delete/archive). Reported both globally (folded
* to the base provider) and per account (each profile separately).
* log. Two row kinds feed the sums: kind="tokens_v2" (exact spend counted by the
* main-process usage ledger from provider-reported counters) and kind="tokens"
* (legacy per-turn deltas captured at the canonical-event layer — approximate
* occupancy growth, kept for historical continuity). No external transcript
* scanning, and no dependency on threads (survives delete/archive). Reported
* both globally (folded to the base provider) and per account (each profile
* separately).
*/

function round1(value: number): number {
Expand Down Expand Up @@ -50,6 +53,7 @@ function emptyTokenStats(
accounts: [],
models: [],
tokenHeatmap: heatmap,
unavailableProviders: [],
};
}

Expand Down Expand Up @@ -80,12 +84,32 @@ export function computeProfileTokenStats(req: ProfileStatsRequest): ProfileToken
const perProvider = new Map<string, number>();
const perAccount = new Map<string, number>();
const perModel = new Map<string, number>();
// Coverage sets for the honesty list: providers that ran threads or turns vs
// providers with at least one exact (tokens_v2) token row. A zero-value
// tokens_v2 row still proves the provider reports exact usage. Computed over
// the whole device log (ignoring the active account/window filters), but
// activity only counts once the exact-telemetry era started on this device
// (first tokens_v2 row) — otherwise every provider would flash "unavailable"
// right after upgrade until it next records exact rows.
const activityFirstTs = new Map<string, number>();
const exactTokenProviders = new Set<string>();
let firstExactTs: number | undefined;
let lifetimeTokens = 0;
const windowDays = statsWindowDays(req.window);
const startDayIndex = windowDays ? windowStartIndex(todayIndex, windowDays) : undefined;

for (const row of dbGetAllUsageEvents()) {
if (row.kind !== "tokens" || row.value <= 0) continue;
if (row.provider) {
const base = baseAgentKind(row.provider);
if (row.kind === "tokens_v2") {
exactTokenProviders.add(base);
if (firstExactTs === undefined || row.ts < firstExactTs) firstExactTs = row.ts;
} else if (row.kind === "thread_started" || row.kind === "turn") {
const prev = activityFirstTs.get(base);
if (prev === undefined || row.ts < prev) activityFirstTs.set(base, row.ts);
}
}
if ((row.kind !== "tokens" && row.kind !== "tokens_v2") || row.value <= 0) continue;
// Scope to the selected account (exact account-kind match) when filtering.
if (req.provider && row.provider !== req.provider) continue;
const dayIndex = localDayIndex(row.ts, offset);
Expand Down Expand Up @@ -146,6 +170,17 @@ export function computeProfileTokenStats(req: ProfileStatsRequest): ProfileToken
}))
.sort((a, b) => b.count - a.count);

const unavailableProviders =
firstExactTs === undefined
? []
: [...activityFirstTs]
.filter(
([provider, firstActivityTs]) =>
firstActivityTs >= firstExactTs && !exactTokenProviders.has(provider),
)
.map(([provider]) => provider)
.sort();

const result: ProfileTokenStats = {
available: lifetimeTokens > 0,
scope: req.scope ?? "device",
Expand All @@ -160,6 +195,7 @@ export function computeProfileTokenStats(req: ProfileStatsRequest): ProfileToken
accounts,
models,
tokenHeatmap,
unavailableProviders,
};
tokenCache.set(cacheKey, { generation, result });
return result;
Expand Down
Loading