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
+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,