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:
2026-10-05 08:03:05 +07:00
parent ed1bf20e6b
commit 65fe680d38
11 changed files with 478 additions and 25 deletions
+72
View File
@@ -0,0 +1,72 @@
/**
* @file Per-hour token usage (`token_usage_hourly`): written from a Claude
* transcript's `tokensByHour` (`server/lib/transcript-cache.js`) by the hook
* writer, back-filled once for sessions recorded before the table existed, and
* read only by the daily cost split in `GET /api/pricing/cost` — so a session
* that runs for days books each day's spend to that day instead of its start.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { db, stmts } = require("../db");
const TranscriptCache = require("./transcript-cache");
/** Write every hour of one transcript parse (MAX per hour, see the statement). */
function writeHourly(sessionId, tokensByHour) {
// ponytail: rewrites every hour of the session on each hook — ~1 row per
// active hour per model, cheap; write only the newest hours if it ever shows.
for (const t of Object.values(tokensByHour || {})) {
stmts.upsertHourlyUsage.run(
sessionId,
t.hour,
t.model,
t.speed,
t.geo,
t.tier,
t.input,
t.output,
t.cacheRead,
t.cacheWrite,
t.cacheWrite1h,
t.webSearch,
t.webFetch,
t.codeExec
);
}
}
/**
* Fill the table for Claude sessions that have tokens but no hourly rows yet,
* one transcript per tick so a large history never blocks the event loop.
* Resolves with the number of sessions written.
*/
function backfillHourly() {
const rows = db
.prepare(
`SELECT s.id, s.transcript_path FROM sessions s
WHERE s.transcript_path IS NOT NULL AND s.transcript_path != ''
AND EXISTS (SELECT 1 FROM token_usage tu WHERE tu.session_id = s.id)
AND NOT EXISTS (SELECT 1 FROM token_usage_hourly h WHERE h.session_id = s.id)`
)
.all();
const cache = new TranscriptCache(1);
let written = 0;
return new Promise((resolve) => {
const next = (i) => {
if (i >= rows.length) return resolve(written);
const { id, transcript_path } = rows[i];
try {
const result = cache.extract(transcript_path);
if (result?.tokensByHour && Object.keys(result.tokensByHour).length > 0) {
db.transaction(() => writeHourly(id, result.tokensByHour))();
written++;
}
} catch {
/* an unreadable transcript keeps its start-date booking */
}
setImmediate(() => next(i + 1));
};
next(0);
});
}
module.exports = { writeHourly, backfillHourly };
+34 -2
View File
@@ -1,5 +1,5 @@
/**
* @file TranscriptCache class for efficient extraction of token usage and compaction data from JSONL transcript files, with stat-based caching and incremental reads to handle append-only growth without re-reading the entire file. Also extracts API error entries and turn duration system messages for enhanced analytics.
* @file TranscriptCache class for efficient extraction of token usage (in total and per UTC hour, `tokensByHour`) and compaction data from JSONL transcript files, with stat-based caching and incremental reads to handle append-only growth without re-reading the entire file. Also extracts API error entries and turn duration system messages for enhanced analytics.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,6 +16,22 @@ const {
const MAX_CACHE_ENTRIES = 200;
/** `YYYY-MM-DDTHH` (UTC) of a transcript timestamp, or null when it has none. */
function utcHour(ts) {
if (ts === undefined || ts === null) return null;
const d = new Date(ts);
return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 13);
}
/** Add `tokens` into `map[key]`, creating the bucket (with its hour, if any). */
function addBucket(map, key, tokens) {
if (!map[key]) {
map[key] = emptyBucket(tokens.model, tokens.speed, tokens.geo, tokens.tier);
if (tokens.hour) map[key].hour = tokens.hour;
}
accumulateBucket(map[key], tokens);
}
// Marker text Claude Code writes into the transcript when a turn is cancelled
// by the user (Esc). The synthetic entry is `type:"user"` and also carries an
// `interruptedMessageId` field; we accept either signal so detection survives
@@ -168,6 +184,7 @@ class TranscriptCache {
merged.usageExtras.inference_geos.length > 0);
const result = {
tokensByModel: hasTokens ? merged.tokensByModel : null,
tokensByHour: hasTokens ? merged.tokensByHour : null,
compaction: merged.compaction,
errors: merged.errors,
turnDurations: hasTurnDurations ? merged.turnDurations : null,
@@ -363,6 +380,10 @@ class TranscriptCache {
_initParseState() {
return {
tokensByModel: {},
// The same buckets split by the UTC hour of each message, so cost can be
// booked to the day it was spent (`token_usage_hourly`), not the day the
// session started. Key: `<hour><bucketKey>`.
tokensByHour: {},
compaction: null,
errors: [],
turnDurations: [],
@@ -533,7 +554,12 @@ class TranscriptCache {
if (!state.tokensByModel[key]) {
state.tokensByModel[key] = emptyBucket(model, speed, geo, tier);
}
accumulateBucket(state.tokensByModel[key], extractUsageFields(msg.usage));
const fields = extractUsageFields(msg.usage);
accumulateBucket(state.tokensByModel[key], fields);
const hour = utcHour(entry.timestamp);
if (hour) {
addBucket(state.tokensByHour, hour + key, { model, speed, geo, tier, hour, ...fields });
}
}
if (msg.usage.service_tier) state.usageExtras.service_tiers.add(msg.usage.service_tier);
@@ -591,6 +617,7 @@ class TranscriptCache {
return {
tokensByModel: hasTokens ? state.tokensByModel : null,
tokensByHour: hasTokens ? state.tokensByHour : null,
compaction: state.compaction,
errors: hasErrors ? state.errors : null,
turnDurations: hasTurnDurations ? state.turnDurations : null,
@@ -642,6 +669,10 @@ class TranscriptCache {
accumulateBucket(tokensByModel[key], tokens);
}
}
const tokensByHour = this._cloneTokens(cached.result?.tokensByHour) || {};
for (const [key, tokens] of Object.entries(incremental?.tokensByHour || {})) {
addBucket(tokensByHour, key, tokens);
}
let compaction = cached.result?.compaction
? this._cloneCompaction(cached.result.compaction)
@@ -728,6 +759,7 @@ class TranscriptCache {
return {
tokensByModel,
tokensByHour,
compaction,
errors,
turnDurations,