fix(pricing): book daily cost to the day tokens were spent
token_usage holds a session's cumulative totals with no time, so /api/pricing/cost booked a multi-day session's whole cost to its start date and the status bar's Today read ~$0. TranscriptCache now also splits usage by each message's UTC hour (tokensByHour); the hook writer stores it in the new token_usage_hourly table (MAX per hour) and the server back-fills existing sessions at start. The daily split books each total over its hourly rows' local days and the rest to the start date, so the days still add up to the total.
This commit is contained in:
@@ -9,6 +9,7 @@ const dbModule = require("../db");
|
||||
const { stmts, db } = dbModule;
|
||||
const { broadcast } = require("../websocket");
|
||||
const TranscriptCache = require("../lib/transcript-cache");
|
||||
const { writeHourly } = require("../lib/hourly-usage");
|
||||
const { scanAndImportSubagents } = require("../../scripts/import-history");
|
||||
const { evaluateEvent } = require("../lib/alerts");
|
||||
const { ingestWorkflowsForSession } = require("../lib/workflow-ingest");
|
||||
@@ -1008,6 +1009,7 @@ const processEvent = db.transaction((hookType, data, tool = "claude") => {
|
||||
tokens.codeExec
|
||||
);
|
||||
}
|
||||
writeHourly(sessionId, result.tokensByHour);
|
||||
}
|
||||
|
||||
// Register API errors from transcript (quota limits, rate limits, overloaded, etc.)
|
||||
|
||||
+87
-22
@@ -222,6 +222,92 @@ function calculateDailyCosts(dailyTokenRows, pricingRules) {
|
||||
.map(([date, rows]) => ({ date, cost: calculateCost(rows, pricingRules, date).total_cost }));
|
||||
}
|
||||
|
||||
const USAGE_COLS = [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
"cache_write_1h_tokens",
|
||||
"web_search_requests",
|
||||
"web_fetch_requests",
|
||||
"code_execution_requests",
|
||||
];
|
||||
const BUCKET_COLS = ["model", "speed", "inference_geo", "service_tier"];
|
||||
|
||||
/**
|
||||
* Token rows per (local date, bucket) for the daily cost split. A bucket's
|
||||
* total (`token_usage`) is booked to the local days its `token_usage_hourly`
|
||||
* rows fall on; whatever the total holds beyond them (sessions recorded before
|
||||
* that table, sub-agent files, Codex/Antigravity) goes to the session's start
|
||||
* date, as it always did. Hours above the total (never expected) are scaled
|
||||
* down to it, so the days always add up to the total.
|
||||
*/
|
||||
function dailyTokenRows(tzModifier, whereClause, params) {
|
||||
const totals = db
|
||||
.prepare(
|
||||
`SELECT tu.session_id, DATE(s.started_at, ?) as date,
|
||||
${BUCKET_COLS.map((c) => `tu.${c}`).join(", ")},
|
||||
tu.input_tokens + tu.baseline_input as input_tokens,
|
||||
tu.output_tokens + tu.baseline_output as output_tokens,
|
||||
tu.cache_read_tokens + tu.baseline_cache_read as cache_read_tokens,
|
||||
tu.cache_write_tokens + tu.baseline_cache_write as cache_write_tokens,
|
||||
tu.cache_write_1h_tokens + tu.baseline_cache_write_1h as cache_write_1h_tokens,
|
||||
tu.web_search_requests + tu.baseline_web_search as web_search_requests,
|
||||
tu.web_fetch_requests + tu.baseline_web_fetch as web_fetch_requests,
|
||||
tu.code_execution_requests + tu.baseline_code_execution as code_execution_requests
|
||||
FROM token_usage tu
|
||||
JOIN sessions s ON s.id = tu.session_id
|
||||
${whereClause}`
|
||||
)
|
||||
.all(tzModifier, ...params);
|
||||
const hourly = db
|
||||
.prepare(
|
||||
`SELECT h.session_id, DATE(h.hour || ':00:00', ?) as date,
|
||||
${BUCKET_COLS.map((c) => `h.${c}`).join(", ")},
|
||||
${USAGE_COLS.map((c) => `SUM(h.${c}) as ${c}`).join(", ")}
|
||||
FROM token_usage_hourly h
|
||||
JOIN sessions s ON s.id = h.session_id
|
||||
${whereClause}
|
||||
GROUP BY h.session_id, 2, ${BUCKET_COLS.map((c) => `h.${c}`).join(", ")}`
|
||||
)
|
||||
.all(tzModifier, ...params);
|
||||
|
||||
const keyOf = (r) => [r.session_id, ...BUCKET_COLS.map((c) => r[c])].join("\u0001");
|
||||
const hoursByBucket = new Map();
|
||||
for (const h of hourly) {
|
||||
const k = keyOf(h);
|
||||
if (!hoursByBucket.has(k)) hoursByBucket.set(k, []);
|
||||
hoursByBucket.get(k).push(h);
|
||||
}
|
||||
|
||||
const out = new Map();
|
||||
const add = (date, bucket, values) => {
|
||||
const k = [date, ...BUCKET_COLS.map((c) => bucket[c])].join("\u0001");
|
||||
let row = out.get(k);
|
||||
if (!row) {
|
||||
row = { date, ...Object.fromEntries(BUCKET_COLS.map((c) => [c, bucket[c]])) };
|
||||
for (const c of USAGE_COLS) row[c] = 0;
|
||||
out.set(k, row);
|
||||
}
|
||||
for (const c of USAGE_COLS) row[c] += values[c];
|
||||
};
|
||||
for (const t of totals) {
|
||||
const days = hoursByBucket.get(keyOf(t)) || [];
|
||||
const scale = {};
|
||||
const rest = {};
|
||||
for (const c of USAGE_COLS) {
|
||||
const spent = days.reduce((n, d) => n + d[c], 0);
|
||||
scale[c] = spent > t[c] ? t[c] / spent : 1;
|
||||
rest[c] = Math.max(t[c] - spent, 0);
|
||||
}
|
||||
for (const d of days) {
|
||||
add(d.date, t, Object.fromEntries(USAGE_COLS.map((c) => [c, d[c] * scale[c]])));
|
||||
}
|
||||
if (USAGE_COLS.some((c) => rest[c] > 0)) add(t.date, t, rest);
|
||||
}
|
||||
return [...out.values()];
|
||||
}
|
||||
|
||||
// GET /api/pricing - List all pricing rules
|
||||
router.get("/", (_req, res) => {
|
||||
const rules = stmts.listPricing.all();
|
||||
@@ -387,28 +473,7 @@ router.get("/cost", (req, res) => {
|
||||
const { clause, params } = sourceColumnClause(parseSources(req), "s.source");
|
||||
const whereClause = clause ? `WHERE ${clause}` : "";
|
||||
|
||||
const dailyTokens = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
DATE(s.started_at, ?) as date,
|
||||
tu.model as model,
|
||||
tu.speed as speed,
|
||||
tu.inference_geo as inference_geo,
|
||||
tu.service_tier as service_tier,
|
||||
SUM(tu.input_tokens + tu.baseline_input) as input_tokens,
|
||||
SUM(tu.output_tokens + tu.baseline_output) as output_tokens,
|
||||
SUM(tu.cache_read_tokens + tu.baseline_cache_read) as cache_read_tokens,
|
||||
SUM(tu.cache_write_tokens + tu.baseline_cache_write) as cache_write_tokens,
|
||||
SUM(tu.cache_write_1h_tokens + tu.baseline_cache_write_1h) as cache_write_1h_tokens,
|
||||
SUM(tu.web_search_requests + tu.baseline_web_search) as web_search_requests,
|
||||
SUM(tu.web_fetch_requests + tu.baseline_web_fetch) as web_fetch_requests,
|
||||
SUM(tu.code_execution_requests + tu.baseline_code_execution) as code_execution_requests
|
||||
FROM token_usage tu
|
||||
JOIN sessions s ON s.id = tu.session_id
|
||||
${whereClause}
|
||||
GROUP BY 1, tu.model, tu.speed, tu.inference_geo, tu.service_tier`
|
||||
)
|
||||
.all(tzModifier, ...params);
|
||||
const dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
|
||||
const rules = stmts.listPricing.all();
|
||||
// Price the date-split rows so each day's usage bills at the rate effective on
|
||||
// that date (e.g. Sonnet 5's intro discount before 2026-08-31, standard after).
|
||||
|
||||
@@ -143,6 +143,7 @@ router.post("/clear-data", (_req, res) => {
|
||||
const counts = getTableCounts();
|
||||
db.pragma("foreign_keys = OFF");
|
||||
db.prepare("DELETE FROM token_usage").run();
|
||||
db.prepare("DELETE FROM token_usage_hourly").run();
|
||||
db.prepare("DELETE FROM events").run();
|
||||
db.prepare("DELETE FROM agents").run();
|
||||
db.prepare("DELETE FROM sessions").run();
|
||||
@@ -368,6 +369,9 @@ router.post("/cleanup", (req, res) => {
|
||||
.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`)
|
||||
.run(...ids).changes;
|
||||
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...ids);
|
||||
db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run(
|
||||
...ids
|
||||
);
|
||||
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...ids);
|
||||
result.purged_sessions = toDelete.length;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user