diff --git a/src/lib/db/usageSummary.ts b/src/lib/db/usageSummary.ts index 867794ef14..edef9b41b4 100644 --- a/src/lib/db/usageSummary.ts +++ b/src/lib/db/usageSummary.ts @@ -25,7 +25,8 @@ export function sumUsageTokensThisMonth(db: SqliteAdapter = getDbInstance()): nu COALESCE(( SELECT SUM(tokens_input + tokens_output) FROM usage_history - WHERE substr(timestamp, 1, 7) = strftime('%Y-%m', 'now') + WHERE timestamp >= strftime('%Y-%m-01T00:00:00.000Z','now') + AND timestamp < strftime('%Y-%m-01T00:00:00.000Z','now','+1 month') ), 0) + COALESCE(( SELECT SUM(total_input_tokens + total_output_tokens) diff --git a/tests/unit/free-tier-used-this-month.test.ts b/tests/unit/free-tier-used-this-month.test.ts index acde52c14c..d6d82c945f 100644 --- a/tests/unit/free-tier-used-this-month.test.ts +++ b/tests/unit/free-tier-used-this-month.test.ts @@ -60,3 +60,42 @@ test("sumUsageTokensThisMonth includes the current month's raw usage_history row assert.equal(sumUsageTokensThisMonth(), 400 + 50); }); + +// #10509 sweep: the `substr(timestamp, 1, 7) = strftime('%Y-%m', 'now')` predicate was +// fragile/non-indexable (SQLite cannot use a range index on a substr() expression, and a +// non-ISO-shaped timestamp string silently mismatches). Replaced with an indexable UTC +// month-range comparison (`timestamp >= AND timestamp < `). +// This test pins the exact boundary: the first instant of the current month is INCLUDED, +// the last instant of the PREVIOUS month is EXCLUDED, and a NEXT-month row is EXCLUDED too +// (guards the upper-bound half of the range, which substr() could never express directly). +test("sumUsageTokensThisMonth uses an inclusive-start/exclusive-end UTC month range (#10509)", () => { + const db = getDbInstance(); + 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')));`); + db.exec("DELETE FROM usage_history"); + db.exec("DELETE FROM daily_usage_summary"); + + const monthStart = db + .prepare("SELECT strftime('%Y-%m-01T00:00:00.000Z','now') AS s") + .get() as { s: string }; + const nextMonthStart = db + .prepare("SELECT strftime('%Y-%m-01T00:00:00.000Z','now','+1 month') AS s") + .get() as { s: string }; + const lastInstantOfPrevMonth = new Date( + new Date(monthStart.s).getTime() - 1 + ).toISOString(); + + const insHistory = db.prepare( + "INSERT INTO usage_history (provider, model, tokens_input, tokens_output, timestamp) VALUES (?,?,?,?,?)" + ); + insHistory.run("openai", "gpt-4.1", 10, 0, monthStart.s); // first instant of THIS month — included + insHistory.run("openai", "gpt-4.1", 9999, 0, lastInstantOfPrevMonth); // last ms of PREV month — excluded + insHistory.run("openai", "gpt-4.1", 9999, 0, nextMonthStart.s); // first instant of NEXT month — excluded + + assert.equal(sumUsageTokensThisMonth(), 10); +});