Merge pull request #2866 from actus7/consolidate/cost-tracker-v3

fix(hooks): consolidate cost-tracker and pricing fixes (2 PRs)
This commit is contained in:
haelyra
2026-08-28 21:00:49 -04:00
committed by GitHub
6 changed files with 346 additions and 151 deletions
+21 -3
View File
@@ -70,19 +70,37 @@ function readHarnessCost(sessionId, maxAgeSeconds) {
// Approximate per-1M-token billing rates (USD).
// Cache creation: 1.25x input rate. Cache read: 0.1x input rate.
// Source: https://platform.claude.com/docs/en/about-claude/pricing
// Current-generation list prices: Fable/Mythos 5 $10/$50, Opus 5 and
// Opus 4.5-4.8 $5/$25, Sonnet 5 $2/$10, Sonnet 4.6 $3/$15, and Haiku 4.5
// $1/$5. Opus 4.0/4.1 and Opus 3 stay on the legacy $15/$75 tier.
const RATE_TABLE = {
haiku: { in: 0.80, out: 4.0, cacheWrite: 1.00, cacheRead: 0.08 },
sonnet: { in: 3.00, out: 15.0, cacheWrite: 3.75, cacheRead: 0.30 },
opus: { in: 15.00, out: 75.0, cacheWrite: 18.75, cacheRead: 1.50 }
haiku: { in: 1.00, out: 5.0, cacheWrite: 1.25, cacheRead: 0.10 },
sonnet: { in: 3.00, out: 15.0, cacheWrite: 3.75, cacheRead: 0.30 },
sonnet5: { in: 2.00, out: 10.0, cacheWrite: 2.50, cacheRead: 0.20 },
opus: { in: 5.00, out: 25.0, cacheWrite: 6.25, cacheRead: 0.50 },
opusLegacy: { in: 15.00, out: 75.0, cacheWrite: 18.75, cacheRead: 1.50 },
fable: { in: 10.00, out: 50.0, cacheWrite: 12.50, cacheRead: 1.00 }
};
// Opus 4.0's dated snapshot omits the minor segment, so an `opus-4-0`
// substring check alone misses `claude-opus-4-20250514`.
const LEGACY_OPUS_RE = /3-opus|opus-4-0(?!\d)|opus-4-1(?!\d)|opus-4[-@]\d{8}/;
function getRates(model) {
const m = String(model || '').toLowerCase();
if (m.includes('fable') || m.includes('mythos')) return RATE_TABLE.fable;
if (m.includes('haiku')) return RATE_TABLE.haiku;
if (isSonnet5(m)) return RATE_TABLE.sonnet5;
if (LEGACY_OPUS_RE.test(m)) return RATE_TABLE.opusLegacy;
if (m.includes('opus')) return RATE_TABLE.opus;
return RATE_TABLE.sonnet;
}
function isSonnet5(model) {
return /(?:^|[^a-z0-9])sonnet-5(?:[^a-z0-9]|$)/.test(model);
}
function toNumber(v) {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
+8 -1
View File
@@ -43,9 +43,16 @@ function extractSessionSummary(transcriptPath) {
if (entry.type === 'user' || entry.role === 'user' || entry.message?.role === 'user') {
// Support both direct content and nested message.content (Claude Code JSONL format)
const rawContent = entry.message?.content ?? entry.content;
// Skip tool_result carrier turns — they are not user asks.
const isToolResult = Array.isArray(rawContent) && rawContent.some(c => c && c.type === 'tool_result');
const text = typeof rawContent === 'string' ? rawContent : Array.isArray(rawContent) ? rawContent.map(c => (c && c.text) || '').join(' ') : '';
const cleaned = stripAnsi(text).trim();
if (cleaned) {
// Skip harness noise: local command echoes, caveats, system reminders.
const isNoise = /^<(local-command-caveat|local-command-stdout|command-name|command-message|command-args|system-reminder|task-notification)/i.test(cleaned);
// `isMeta` is also used for genuine channel- and plugin-originated
// human prompts. Exclude known structured noise above instead of
// discarding every metadata-marked user turn.
if (cleaned && !isToolResult && !isNoise) {
userMessages.push(cleaned.slice(0, 200));
}
}
-32
View File
@@ -1,32 +0,0 @@
'use strict';
/**
* Shared cost estimation for ECC hooks.
*
* Approximate per-1M-token blended rates (conservative defaults).
*/
const RATE_TABLE = {
haiku: { in: 0.8, out: 4.0 },
sonnet: { in: 3.0, out: 15.0 },
opus: { in: 15.0, out: 75.0 }
};
/**
* Estimate USD cost from token counts.
* @param {string} model - Model name (may contain "haiku", "sonnet", or "opus")
* @param {number} inputTokens
* @param {number} outputTokens
* @returns {number} Estimated cost in USD (rounded to 6 decimal places)
*/
function estimateCost(model, inputTokens, outputTokens) {
const normalized = String(model || '').toLowerCase();
let rates = RATE_TABLE.sonnet;
if (normalized.includes('haiku')) rates = RATE_TABLE.haiku;
if (normalized.includes('opus')) rates = RATE_TABLE.opus;
const cost = (inputTokens / 1_000_000) * rates.in + (outputTokens / 1_000_000) * rates.out;
return Math.round(cost * 1e6) / 1e6;
}
module.exports = { estimateCost, RATE_TABLE };
+282 -1
View File
@@ -54,6 +54,49 @@ function runScript(input, envOverrides = {}) {
return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' };
}
function removeHarnessCostCache(sessionId) {
const cachePath = path.join(os.tmpdir(), `harness-cost-${sessionId}.json`);
try {
fs.unlinkSync(cachePath);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
}
function assertSonnet5CacheCost(cacheUsage, expectedCost, description) {
const tmpHome = makeTempDir();
const sessionId = `sonnet5-${description}-${process.pid}-${Date.now()}`;
const transcriptPath = path.join(tmpHome, 'session.jsonl');
writeTranscript(transcriptPath, [{
type: 'assistant',
message: {
id: `msg_sonnet5_${description}`,
model: 'claude-sonnet-5',
usage: {
input_tokens: 1_000_000,
output_tokens: 1_000_000,
...cacheUsage,
},
},
}]);
try {
removeHarnessCostCache(sessionId);
const result = runScript(
{ session_id: sessionId, transcript_path: transcriptPath },
withTempHome(tmpHome)
);
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.strictEqual(row.estimated_cost_usd, expectedCost, description);
} finally {
removeHarnessCostCache(sessionId);
fs.rmSync(tmpHome, { recursive: true, force: true });
}
}
function runTests() {
console.log('\n=== Testing cost-tracker.js ===\n');
@@ -297,7 +340,245 @@ function runTests() {
}
}) ? passed++ : failed++);
// 9. Ignores stale harness-cost cache and falls back to transcript estimate
// 9. Prices Sonnet 5 at the documented $2/$10 rate.
(test('prices Sonnet 5 at $12 per 1M input + 1M output tokens', () => {
const tmpHome = makeTempDir();
const sessionId = `sonnet5-${process.pid}-${Date.now()}`;
const transcriptPath = path.join(tmpHome, 'session.jsonl');
writeTranscript(transcriptPath, [
{
type: 'assistant',
message: {
id: 'msg_sonnet5',
model: 'claude-sonnet-5',
usage: { input_tokens: 1_000_000, output_tokens: 1_000_000 },
},
},
]);
fs.writeFileSync(
path.join(os.tmpdir(), `harness-cost-${sessionId}.json`),
JSON.stringify({ ts: Math.floor(Date.now() / 1000), cost_usd: 999 }),
'utf8'
);
try {
removeHarnessCostCache(sessionId);
const result = runScript(
{ session_id: sessionId, transcript_path: transcriptPath },
withTempHome(tmpHome)
);
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.strictEqual(row.estimated_cost_usd, 12, 'Expected Sonnet 5 1M/1M to cost $12.00');
} finally {
removeHarnessCostCache(sessionId);
fs.rmSync(tmpHome, { recursive: true, force: true });
}
}) ? passed++ : failed++);
// 9b. Sonnet 5 cache write/read tokens use the correct rates.
(test('prices Sonnet 5 cache tokens at the documented rates', () => {
const tmpHome = makeTempDir();
const sessionId = `sonnet5-cache-${process.pid}-${Date.now()}`;
const transcriptPath = path.join(tmpHome, 'session.jsonl');
writeTranscript(transcriptPath, [
{
type: 'assistant',
message: {
id: 'msg_sonnet5_cache',
model: 'claude-sonnet-5',
usage: {
input_tokens: 1_000_000,
output_tokens: 1_000_000,
cache_creation_input_tokens: 1_000_000,
cache_read_input_tokens: 1_000_000,
},
},
},
]);
try {
removeHarnessCostCache(sessionId);
const result = runScript(
{ session_id: sessionId, transcript_path: transcriptPath },
withTempHome(tmpHome)
);
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.strictEqual(row.estimated_cost_usd, 14.7, 'Expected Sonnet 5 1M input + 1M output + 1M cache write + 1M cache read to cost $14.70');
} finally {
removeHarnessCostCache(sessionId);
fs.rmSync(tmpHome, { recursive: true, force: true });
}
}) ? passed++ : failed++);
// 9c. Cache write/read rates are independently covered.
(test('prices Sonnet 5 cache writes at $2.50 per 1M tokens', () => {
assertSonnet5CacheCost(
{ cache_creation_input_tokens: 1_000_000 },
14.5,
'cache-write'
);
}) ? passed++ : failed++);
(test('prices Sonnet 5 cache reads at $0.20 per 1M tokens', () => {
assertSonnet5CacheCost(
{ cache_read_input_tokens: 1_000_000 },
12.2,
'cache-read'
);
}) ? passed++ : failed++);
// 10. Sonnet 4.6 keeps the existing $3/$15 rate and is not mistaken for Sonnet 5.
(test('prices Sonnet 4.6 at $18 per 1M input + 1M output tokens', () => {
const tmpHome = makeTempDir();
const sessionId = `sonnet46-${process.pid}-${Date.now()}`;
const transcriptPath = path.join(tmpHome, 'session.jsonl');
writeTranscript(transcriptPath, [
{
type: 'assistant',
message: {
id: 'msg_sonnet46',
model: 'claude-sonnet-4-6',
usage: { input_tokens: 1_000_000, output_tokens: 1_000_000 },
},
},
]);
try {
removeHarnessCostCache(sessionId);
const result = runScript(
{ session_id: sessionId, transcript_path: transcriptPath },
withTempHome(tmpHome)
);
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.strictEqual(row.estimated_cost_usd, 18, 'Expected Sonnet 4.6 1M/1M to remain $18.00');
} finally {
removeHarnessCostCache(sessionId);
fs.rmSync(tmpHome, { recursive: true, force: true });
}
}) ? passed++ : failed++);
// 10b. Dated Sonnet 5 IDs are matched correctly.
(test('prices dated Sonnet 5 IDs at $12', () => {
const tmpHome = makeTempDir();
const sessionId = `sonnet5-dated-${process.pid}-${Date.now()}`;
const transcriptPath = path.join(tmpHome, 'session.jsonl');
writeTranscript(transcriptPath, [
{
type: 'assistant',
message: {
id: 'msg_sonnet5_dated',
model: 'claude-sonnet-5-20261001',
usage: { input_tokens: 1_000_000, output_tokens: 1_000_000 },
},
},
]);
try {
removeHarnessCostCache(sessionId);
const result = runScript(
{ session_id: sessionId, transcript_path: transcriptPath },
withTempHome(tmpHome)
);
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.strictEqual(row.estimated_cost_usd, 12, 'Expected dated Sonnet 5 1M/1M to cost $12.00');
} finally {
removeHarnessCostCache(sessionId);
fs.rmSync(tmpHome, { recursive: true, force: true });
}
}) ? passed++ : failed++);
// 10c. Near-miss Sonnet 5 IDs fall back to standard Sonnet rates.
(test('rejects claude-sonnet-50 as a Sonnet 5 near-miss', () => {
const tmpHome = makeTempDir();
const sessionId = `sonnet50-near-miss-${process.pid}-${Date.now()}`;
const transcriptPath = path.join(tmpHome, 'session.jsonl');
writeTranscript(transcriptPath, [
{
type: 'assistant',
message: {
id: 'msg_sonnet50',
model: 'claude-sonnet-50',
usage: { input_tokens: 1_000_000, output_tokens: 1_000_000 },
},
},
]);
try {
removeHarnessCostCache(sessionId);
const result = runScript(
{ session_id: sessionId, transcript_path: transcriptPath },
withTempHome(tmpHome)
);
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.strictEqual(row.estimated_cost_usd, 18, 'Expected claude-sonnet-50 near-miss to fall back to $18.00 Sonnet rate');
} finally {
removeHarnessCostCache(sessionId);
fs.rmSync(tmpHome, { recursive: true, force: true });
}
}) ? passed++ : failed++);
// 10d. Opus 4.0's dated ID has no explicit minor segment. It must retain
// the legacy $15/$75 rate while Opus 4.5 uses the current $5/$25 rate.
(test('distinguishes the dated Opus 4.0 snapshot from current Opus 4.x', () => {
const priceModel = model => {
const tmpHome = makeTempDir();
const sessionId = `opus-rate-${process.pid}-${Date.now()}-${model}`;
const transcriptPath = path.join(tmpHome, 'session.jsonl');
writeTranscript(transcriptPath, [
{
type: 'assistant',
message: {
id: `msg_${model}`,
model,
usage: { input_tokens: 1_000_000, output_tokens: 1_000_000 },
},
},
]);
try {
removeHarnessCostCache(sessionId);
const result = runScript(
{ session_id: sessionId, transcript_path: transcriptPath },
withTempHome(tmpHome)
);
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
return JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()).estimated_cost_usd;
} finally {
removeHarnessCostCache(sessionId);
fs.rmSync(tmpHome, { recursive: true, force: true });
}
};
assert.strictEqual(
priceModel('claude-opus-4-20250514'),
90,
'Expected dated Opus 4.0 to retain the legacy $15/$75 rate'
);
assert.strictEqual(
priceModel('claude-opus-4-5-20251101'),
30,
'Expected Opus 4.5 to use the current $5/$25 rate'
);
}) ? passed++ : failed++);
// 11. Ignores stale harness-cost cache and falls back to transcript estimate
(test('ignores stale harness-cost cache (>300s) and uses transcript estimate', () => {
const tmpHome = makeTempDir();
const sessionId = 'harness-stale-' + Date.now();
+35
View File
@@ -2118,6 +2118,41 @@ async function runTests() {
passed++;
else failed++;
if (
await asyncTest('keeps isMeta human prompts while filtering structured transcript noise', async () => {
const testDir = createTestDir();
const transcriptPath = path.join(testDir, 'transcript.jsonl');
const lines = [
JSON.stringify({ type: 'user', isMeta: true, content: 'Prompt delivered by a channel plugin' }),
JSON.stringify({ type: 'user', isMeta: true, content: '<system-reminder>internal harness context</system-reminder>' }),
JSON.stringify({
type: 'user',
message: { role: 'user', content: [{ type: 'tool_result', content: 'tool output' }] },
}),
];
fs.writeFileSync(transcriptPath, lines.join('\n'));
const result = await runScript(
path.join(scriptsDir, 'session-end.js'),
JSON.stringify({ transcript_path: transcriptPath }),
{ HOME: testDir, USERPROFILE: testDir }
);
assert.strictEqual(result.code, 0);
const sessionsDir = getCanonicalSessionsDir(testDir);
const sessionFiles = fs.readdirSync(sessionsDir).filter(file => file.endsWith('.tmp'));
assert.strictEqual(sessionFiles.length, 1, 'Should create one session file');
const content = fs.readFileSync(path.join(sessionsDir, sessionFiles[0]), 'utf8');
assert.ok(content.includes('Prompt delivered by a channel plugin'));
assert.ok(!content.includes('internal harness context'));
assert.ok(!content.includes('tool output'));
assert.ok(content.includes('Total user messages: 1'));
cleanupTestDir(testDir);
})
)
passed++;
else failed++;
if (
await asyncTest('extracts tool names and file paths from transcript', async () => {
const testDir = createTestDir();
-114
View File
@@ -1,114 +0,0 @@
/**
* Tests for scripts/lib/cost-estimate.js
*
* Run with: node tests/lib/cost-estimate.test.js
*/
const assert = require('assert');
const { estimateCost, RATE_TABLE } = require('../../scripts/lib/cost-estimate');
// Test helper
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (err) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing cost-estimate.js ===\n');
let passed = 0;
let failed = 0;
// RATE_TABLE structure
console.log('RATE_TABLE:');
if (
test('RATE_TABLE has haiku, sonnet, opus keys', () => {
assert.ok(RATE_TABLE.haiku, 'Missing haiku');
assert.ok(RATE_TABLE.sonnet, 'Missing sonnet');
assert.ok(RATE_TABLE.opus, 'Missing opus');
assert.strictEqual(typeof RATE_TABLE.haiku.in, 'number');
assert.strictEqual(typeof RATE_TABLE.haiku.out, 'number');
assert.strictEqual(typeof RATE_TABLE.sonnet.in, 'number');
assert.strictEqual(typeof RATE_TABLE.sonnet.out, 'number');
assert.strictEqual(typeof RATE_TABLE.opus.in, 'number');
assert.strictEqual(typeof RATE_TABLE.opus.out, 'number');
})
)
passed++;
else failed++;
// estimateCost tests
console.log('\nestimateCost:');
if (
test('opus 1M/1M tokens returns 90', () => {
const cost = estimateCost('opus', 1_000_000, 1_000_000);
assert.strictEqual(cost, 90);
})
)
passed++;
else failed++;
if (
test('sonnet 1M/1M tokens returns 18', () => {
const cost = estimateCost('sonnet', 1_000_000, 1_000_000);
assert.strictEqual(cost, 18);
})
)
passed++;
else failed++;
if (
test('haiku 1M/1M tokens returns 4.8', () => {
const cost = estimateCost('haiku', 1_000_000, 1_000_000);
assert.strictEqual(cost, 4.8);
})
)
passed++;
else failed++;
if (
test('null model with 0 tokens returns 0', () => {
const cost = estimateCost(null, 0, 0);
assert.strictEqual(cost, 0);
})
)
passed++;
else failed++;
if (
test('full model name claude-opus-4-6 uses opus rates', () => {
const cost = estimateCost('claude-opus-4-6', 500, 200);
// (500 / 1_000_000) * 15 + (200 / 1_000_000) * 75 = 0.0075 + 0.015 = 0.0225
const expected = Math.round(0.0225 * 1e6) / 1e6;
assert.strictEqual(cost, expected);
})
)
passed++;
else failed++;
if (
test('unknown model falls back to sonnet rates', () => {
const cost = estimateCost('unknown-model', 1_000_000, 1_000_000);
assert.strictEqual(cost, 18);
})
)
passed++;
else failed++;
// Summary
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
return { passed, failed };
}
const { failed } = runTests();
process.exit(failed > 0 ? 1 : 0);