From d4fbd952b5d466ad301b1820ec91bf8cc8b6aaff Mon Sep 17 00:00:00 2001 From: adevwithpurpose Date: Sat, 15 Aug 2026 19:30:08 -0300 Subject: [PATCH] fix(dashboard): count live usage_history rows in Free Tier 'used this month' (#10381) --- .../fixes/10381-free-tier-usage-history.md | 1 + src/lib/db/usageSummary.ts | 31 +++++++++++++-- tests/unit/free-tier-used-this-month.test.ts | 39 +++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fixes/10381-free-tier-usage-history.md diff --git a/changelog.d/fixes/10381-free-tier-usage-history.md b/changelog.d/fixes/10381-free-tier-usage-history.md new file mode 100644 index 0000000000..4009855cc0 --- /dev/null +++ b/changelog.d/fixes/10381-free-tier-usage-history.md @@ -0,0 +1 @@ +- fix(dashboard): Free Tier 'used this month' now includes live usage_history rows, not just the rolled-up daily summary (#10381) diff --git a/src/lib/db/usageSummary.ts b/src/lib/db/usageSummary.ts index 1885d7c7fb..867794ef14 100644 --- a/src/lib/db/usageSummary.ts +++ b/src/lib/db/usageSummary.ts @@ -1,14 +1,37 @@ import { getDbInstance } from "./core.ts"; import type { SqliteAdapter } from "./adapters/types.ts"; -/** Total input+output tokens rolled up in daily_usage_summary for the current calendar month. */ +/** + * Total input+output tokens for the current calendar month, across BOTH storage legs: + * + * 1. `daily_usage_summary` — the rolled-up aggregate (filled only by the retention + * cleanup path rolling up rows OLDER than `retention.usageHistory`, default 365 days), + * so the current month's rows never appear here until ~a year later (or the operator + * lowers retention). + * 2. `usage_history` — the live per-request rows (written by `saveRequestUsage` with + * `tokens_input`/`tokens_output` and an ISO `timestamp`), which hold the current month's + * actual usage. + * + * #10381: reading only leg 1 always returned ~0 for the current month. No double-count: + * a row is rolled into `daily_usage_summary` only immediately before being deleted from + * `usage_history` by the same cleanup step. The analytics layer uses the same + * reconciliation in `buildUnifiedSource`. + */ export function sumUsageTokensThisMonth(db: SqliteAdapter = getDbInstance()): number { try { const row = db .prepare( - `SELECT COALESCE(SUM(total_input_tokens + total_output_tokens), 0) AS used - FROM daily_usage_summary - WHERE date >= strftime('%Y-%m-01','now')` + `SELECT + COALESCE(( + SELECT SUM(tokens_input + tokens_output) + FROM usage_history + WHERE substr(timestamp, 1, 7) = strftime('%Y-%m', 'now') + ), 0) + + COALESCE(( + SELECT SUM(total_input_tokens + total_output_tokens) + FROM daily_usage_summary + WHERE date >= strftime('%Y-%m-01', 'now') + ), 0) AS used` ) .get() as { used: number } | undefined; return row?.used ?? 0; diff --git a/tests/unit/free-tier-used-this-month.test.ts b/tests/unit/free-tier-used-this-month.test.ts index 6092c4bd72..acde52c14c 100644 --- a/tests/unit/free-tier-used-this-month.test.ts +++ b/tests/unit/free-tier-used-this-month.test.ts @@ -21,3 +21,42 @@ test("sumUsageTokensThisMonth sums only the current calendar month's rolled-up t insert.run("groq", "llama", "2000-01-01", 9999, 9999); // long ago — excluded assert.equal(sumUsageTokensThisMonth(), 400); }); + +// #10381: the current month's LIVE usage lives in usage_history (per-request rows written +// by saveRequestUsage) and is never rolled into daily_usage_summary until retention cleanup +// (~365 days). sumUsageTokensThisMonth must count both legs without double-counting. +test("sumUsageTokensThisMonth includes the current month's raw usage_history rows (#10381)", () => { + const db = getDbInstance(); + // Ensure both tables exist (defensive, same shape as the migrations). + db.exec(`CREATE TABLE IF NOT EXISTS usage_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT, model TEXT, connection_id TEXT, + api_key_id TEXT, api_key_name TEXT, tokens_input INTEGER DEFAULT 0, tokens_output INTEGER DEFAULT 0, + tokens_cache_read INTEGER DEFAULT 0, tokens_cache_creation INTEGER DEFAULT 0, tokens_reasoning INTEGER DEFAULT 0, + service_tier TEXT DEFAULT 'standard', status TEXT, success INTEGER DEFAULT 1, latency_ms INTEGER DEFAULT 0, + ttft_ms INTEGER DEFAULT 0, error_code TEXT, timestamp TEXT NOT NULL);`); + db.exec(`CREATE TABLE IF NOT EXISTS daily_usage_summary (id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT NOT NULL, model TEXT NOT NULL, date TEXT NOT NULL, total_requests INTEGER NOT NULL DEFAULT 0, total_input_tokens INTEGER NOT NULL DEFAULT 0, total_output_tokens INTEGER NOT NULL DEFAULT 0, total_cost REAL NOT NULL DEFAULT 0.0, created_at TEXT NOT NULL DEFAULT (datetime('now')));`); + + // Isolate from the shared DB (getDbInstance is a singleton across test cases): start empty. + db.exec("DELETE FROM usage_history"); + db.exec("DELETE FROM daily_usage_summary"); + + const now = new Date(); + const thisMonth = now.toISOString().slice(0, 7); // YYYY-MM + const liveStamp = `${thisMonth}-15T12:00:00.000Z`; + const pastStamp = "2000-01-15T12:00:00.000Z"; + + const insHistory = db.prepare( + "INSERT INTO usage_history (provider, model, tokens_input, tokens_output, timestamp) VALUES (?,?,?,?,?)" + ); + insHistory.run("openai", "gpt-4.1", 150, 250, liveStamp); // 400 current-month live tokens + insHistory.run("openai", "gpt-4.1", 9999, 9999, pastStamp); // very old — excluded + + // A rolled-up current-month row coexisting (no double-count — the source usage_history row + // was already deleted by the retention rollup, so both legs are additive and disjoint). + const insSummary = db.prepare( + "INSERT INTO daily_usage_summary (provider, model, date, total_input_tokens, total_output_tokens) VALUES (?,?,?,?,?)" + ); + insSummary.run("groq", "llama", `${thisMonth}-20`, 25, 25); // +50 rolled-up + + assert.equal(sumUsageTokensThisMonth(), 400 + 50); +});