feat(observability): opt-in token capture in Stop hook (VOYAGE_TOKEN_METER)
This commit is contained in:
parent
62ebc28e3f
commit
46d51f8088
3 changed files with 243 additions and 2 deletions
|
|
@ -15,11 +15,15 @@
|
|||
// main-transcript sum UNDER-counts total Voyage cost. Every record is stamped
|
||||
// scope:'main-context' so no reader mistakes cost_usd for the session total.
|
||||
//
|
||||
// All functions in this module are PURE (no I/O). The impure capture shell
|
||||
// (captureTokenUsage) is added in Step 4.
|
||||
// The four core functions (parseTranscriptUsage, deriveCost, buildRecord,
|
||||
// upsertSessionRecord) are PURE (no I/O). captureTokenUsage is the impure
|
||||
// shell that wires them to the filesystem (read transcript → upsert jsonl).
|
||||
//
|
||||
// Zero npm dependencies. Node stdlib only.
|
||||
|
||||
import { readFileSync, existsSync, writeFileSync, renameSync, statSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
|
||||
// Per-Mtok USD prices, resolved 2026-06-26 via the claude-api skill reference:
|
||||
// base input/output from the model table; cache rates from the prompt-caching
|
||||
// doc multipliers (cache_read 0.1x, write_5m 1.25x, write_1h 2.0x of input).
|
||||
|
|
@ -159,3 +163,64 @@ export function upsertSessionRecord(existingText, record) {
|
|||
if (!replaced) out.push(JSON.stringify(record));
|
||||
return out.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Last main-chain (non-sidechain) assistant model in the transcript. Used to
|
||||
* pick the price-table key. Returns null when no model is found (→ deriveCost
|
||||
* refuses to estimate). Pure.
|
||||
*/
|
||||
export function lastMainChainModel(text) {
|
||||
const lines = (text || '').split('\n');
|
||||
let model = null;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '') continue;
|
||||
let obj;
|
||||
try { obj = JSON.parse(trimmed); }
|
||||
catch { continue; }
|
||||
if (!obj || obj.type !== 'assistant' || obj.isSidechain === true) continue;
|
||||
const m = obj.message && obj.message.model;
|
||||
if (typeof m === 'string' && m) model = m;
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Impure capture shell (Step 4). Reads the transcript, derives main-context
|
||||
* token totals + cost, and UPSERTS a one-line-per-session record into
|
||||
* {dataDir}/token-usage-stats.jsonl via an atomic temp+rename write
|
||||
* (EXDEV mitigation: tmp lives in the same dir as the target).
|
||||
*
|
||||
* Returns the written record, or null when skipped (no path / no dataDir /
|
||||
* transcript not a readable regular file). Throws propagate to the caller —
|
||||
* the Stop hook wraps this in try/catch so capture stays fail-open.
|
||||
*
|
||||
* @param {{transcriptPath:string, sessionId:string, dataDir:string,
|
||||
* now?:string}} args
|
||||
*/
|
||||
export function captureTokenUsage({ transcriptPath, sessionId, dataDir, now }) {
|
||||
if (!transcriptPath || !dataDir) return null;
|
||||
let st;
|
||||
try { st = statSync(transcriptPath); }
|
||||
catch { return null; }
|
||||
if (!st.isFile()) return null;
|
||||
|
||||
const text = readFileSync(transcriptPath, 'utf-8');
|
||||
const totals = parseTranscriptUsage(text);
|
||||
const model = lastMainChainModel(text);
|
||||
const record = buildRecord({
|
||||
sessionId,
|
||||
model,
|
||||
totals,
|
||||
now: now || new Date().toISOString(),
|
||||
});
|
||||
|
||||
const outPath = join(dataDir, 'token-usage-stats.jsonl');
|
||||
const existing = existsSync(outPath) ? readFileSync(outPath, 'utf-8') : '';
|
||||
const updated = upsertSessionRecord(existing, record);
|
||||
|
||||
const tmpPath = join(dirname(outPath), '.token-usage-stats.jsonl.tmp');
|
||||
writeFileSync(tmpPath, updated);
|
||||
renameSync(tmpPath, outPath);
|
||||
return record;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue