From 86385e541fc57c10780490159a646f01c32d5206 Mon Sep 17 00:00:00 2001 From: Perry Story Date: Sun, 4 Oct 2026 14:31:42 -0400 Subject: [PATCH] fix(crush): keep the recorded session cost through the session cache (#1618) Co-authored-by: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> --- CHANGELOG.md | 1 + src/daily-cache.ts | 8 +- src/providers/crush.ts | 1 + src/session-cache.ts | 3 + tests/providers/crush.test.ts | 1 + tests/reported-cost-cache.test.ts | 118 ++++++++++++++++++++++++++++-- 6 files changed, 125 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69ca40ec..edec95d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Roo Code support. Roo Code was discontinued in May 2026 (its repository is archived). ### Fixed +- **Crush: the session cost Crush records now reaches reports instead of a much smaller re-priced figure.** Crush accumulates each session's cost in `sessions.cost`, but its `prompt_tokens` and `completion_tokens` columns are not session totals; they hold recent usage. Crush was not on the reported-cost pass-through, so the session cache dropped the recorded cost and every read, cold and warm, re-priced the session from those counters. On real data the reported figure was roughly a twelfth of what Crush recorded for the same sessions. A session with a positive recorded cost now keeps it through the cache, the same way Warp, OpenClaw and Pi do; a session that recorded zero keeps the existing behavior and is still priced from its tokens. Crush sessions re-parse once and settled days re-derive (daily cache v49). - **DeepSeek v4 and the gpt-5.6 Codex SKUs price at their repriced rates again.** The 0.9.21 snapshot had caught the 2026-08-24 repricings mid-transition and relaxed two pricing invariants. LiteLLM has since synced DeepSeek v4 to the official peak rates (pro $1.32/$3.96 per million, flash $0.30/$1.20, off-peak half), so the stale hand-pinned entries are gone and bare and `deepseek/`-prefixed rows agree again. The same refresh exposed a bundler ordering defect: a reseller's `openrouter/deepseek/deepseek-v4-pro` row could claim the `deepseek/deepseek-v4-pro` slot ahead of the official row (whichever came first in the upstream JSON), quietly pricing the namespaced id at the resale rate, ~40% under official peak. A prefixed row may now fill only a slot that is not itself an upstream entry, so the official row always wins and the fill no longer depends on key order. The same fix reprices `zai/glm-4.6` from the vercel_ai_gateway resale row ($0.45/$1.8 per million) to the official row ($0.6/$2.2), and moves `mistral/codestral-embed` and `mistral/mistral-embed` to the fallback table at unchanged rates. The gpt-5.6-codex and gpt-5.6-codex-max entries, still absent from LiteLLM, now mirror the repriced gpt-5.6 base row verbatim including its >272k tier, restoring the codex-equals-base assertion at $4/$20 per million. DeepSeek v4 sessions re-price on read; no re-parse needed. (#1134) - **Codex long-context calls now use the published higher rates at their prompt-token threshold.** The bundled and live pricing readers retain LiteLLM's context tiers, and Codex applies them per call; below-threshold calls and other providers retain base pricing, and explicit price overrides still win. Audit totals use the same per-call calculation. Pricing and daily-cache versions advance so warm history re-derives with the corrected rates. The regenerated bundle preserves the snapshot refresh's base-rate guards and exact-key carry-forward for previously priced models. (#1076) - Bundled pricing is refreshed: 5,896 primary entries (298 added, 158 repriced by upstream, 364 no-longer-published ids moved to the fallback table, which grows from 212 to 543). `grok-4.6` base input moves to $1.25/M; `claude-opus-4` is pinned at Anthropic list price since LiteLLM dropped it. diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 7c58d2f7..5b7cf419 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -246,7 +246,13 @@ import type { DateRange, ProjectSummary } from './types.js' // v48: #1620 Antigravity manage_task/search_web/read_url_content/invoke_subagent // reclassify turns. Calls and cost are unchanged, but settled antigravity // category totals need re-derivation. -export const DAILY_CACHE_VERSION = 48 +// v49: Crush's recorded cumulative session cost now passes through the session +// cache instead of being re-priced from its non-cumulative token counters. Days +// finalized before v49 hold the re-priced figure with unchanged call counts, so the +// read path's equal-call rule keeps them; the bump re-derives surviving days. +// Call counts are unchanged, so no PENDING_REDERIVE_PROVIDER_VERSIONS entry is +// needed. +export const DAILY_CACHE_VERSION = 49 const MIN_SUPPORTED_VERSION = 28 /// Providers whose per-day CALL COUNT means something different at diff --git a/src/providers/crush.ts b/src/providers/crush.ts index 0d9d3ccc..0dcf0ded 100644 --- a/src/providers/crush.ts +++ b/src/providers/crush.ts @@ -182,6 +182,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars reasoningTokens: 0, webSearchRequests: 0, costUSD, + ...(cost > 0 ? { costFromBilling: true } : {}), tools: [], bashCommands: [], timestamp: epochSecondsToIso(session.updated_at ?? session.created_at), diff --git a/src/session-cache.ts b/src/session-cache.ts index 18843639..581509f9 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -411,6 +411,9 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // reparse because session-cache otherwise bypasses the provider parser. // Compose both suffixes so cached sessions receive both accounting fixes. codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1-session-meta-fields-v1-codex-pricing-v1-codex-tps-v1-codex-mcp-skills-v1-activity-price-v1-fork-replay-burst-v1-codex-token-usage-record-v1', + // reported-cost-v1: cached Crush calls stored costUSD: undefined and must + // re-parse to keep the recorded session cost. + crush: 'reported-cost-v1', cursor: 'composer-anchored-crediting-v1-est-cost', // full-turn-accounting: every assistant message counts as a turn // (previously only the first after each user message survived), tool_use diff --git a/tests/providers/crush.test.ts b/tests/providers/crush.test.ts index fab03bb0..c4cbab8b 100644 --- a/tests/providers/crush.test.ts +++ b/tests/providers/crush.test.ts @@ -224,6 +224,7 @@ describe('crush provider', () => { expect(call.inputTokens).toBe(1234) expect(call.outputTokens).toBe(567) expect(call.costUSD).toBeCloseTo(0.0789, 6) + expect(call.costFromBilling).toBe(true) expect(call.sessionId).toBe('sess-1') expect(call.deduplicationKey).toBe('crush:sess-1') // Crush stores epoch seconds; 1_700_000_999 sec → 2023-11-14T22:29:59.000Z. diff --git a/tests/reported-cost-cache.test.ts b/tests/reported-cost-cache.test.ts index 2881d90c..3a31a658 100644 --- a/tests/reported-cost-cache.test.ts +++ b/tests/reported-cost-cache.test.ts @@ -3,17 +3,20 @@ // cachedCallToApiCall, so a cost that providerCallToCachedCall drops is // re-priced from tokens on EVERY read, cold and warm alike. // -// OpenClaw and Pi both write a per-message `usage.cost.total` and both were -// dropping it. Their fallback — an absent or zero reported cost — is a token -// estimate and must keep being re-priced, so the decision is per call -// (`costFromBilling`), not per provider. +// OpenClaw and Pi write per-message `usage.cost.total`; Crush's `sessions.cost` +// is its recorded cumulative session cost, while `prompt_tokens` and +// `completion_tokens` are non-cumulative counters. Their recorded costs must +// survive caching, while zero-cost rows retain the existing token repricing. // -// Own file because both providers resolve their roots from the home directory -// when their module is first evaluated, so HOME must be set before any import. +// Own file because providers resolve roots from the home directory when their +// modules are first evaluated, so HOME must be set before any import. import { afterAll, beforeEach, expect, it, vi } from 'vitest' import { mkdir, rm, writeFile } from 'fs/promises' +import { mkdirSync } from 'fs' import { join } from 'path' +import { createRequire } from 'node:module' +import { isSqliteAvailable } from '../src/sqlite.js' const testRoot = vi.hoisted(() => { const root = `${process.env['TMPDIR'] || '/tmp'}/reported-cost-cache-${process.pid}-${Date.now()}` @@ -34,6 +37,64 @@ const LARGE_FLOOR = 600 * 3e-6 + 200 * 15e-6 + 100 * 3e-7 // 0.00483 const OPENCLAW_REPORTED = 0.05 // ~10x its token floor const PI_REPORTED = 0.4 // ~83x its token floor +const requireForTest = createRequire(import.meta.url) + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +function createCrushDb(dir: string): string { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + mkdirSync(dir, { recursive: true }) + const dbPath = join(dir, 'crush.db') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, parent_session_id TEXT, title TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, cost REAL NOT NULL DEFAULT 0.0, + updated_at INTEGER NOT NULL, created_at INTEGER NOT NULL, summary_message_id TEXT, todos TEXT + ) + `) + db.exec(` + CREATE TABLE messages ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, + parts TEXT NOT NULL DEFAULT '[]', model TEXT, created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, finished_at INTEGER, provider TEXT, + is_summary_message INTEGER DEFAULT 0 NOT NULL + ) + `) + db.close() + return dbPath +} + +function withCrushDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + try { + fn(db) + } finally { + db.close() + } +} + +function insertCrushSession(db: TestDb, id: string, promptTokens: number, completionTokens: number, cost: number): void { + db.prepare('INSERT INTO sessions (id, title, message_count, prompt_tokens, completion_tokens, cost, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') + .run(id, 'test session', 1, promptTokens, completionTokens, cost, 1_700_000_000, 1_700_000_000) +} + +function insertCrushMessage(db: TestDb, sessionId: string, id: string): void { + db.prepare("INSERT INTO messages (id, session_id, role, parts, model, created_at, updated_at) VALUES (?, ?, 'assistant', '[]', 'claude-sonnet-4-6', 1700000000, 1700000000)") + .run(id, sessionId) +} + +async function writeCrushRegistry(globalData: string, projectDir: string): Promise { + await mkdir(globalData, { recursive: true }) + await writeFile(join(globalData, 'projects.json'), JSON.stringify([{ path: projectDir, data_dir: '.crush' }])) +} + const ts = (offsetSec: number) => new Date(Date.UTC(2026, 7, 16, 10, 0, offsetSec)).toISOString() async function writeOpenClawSession(): Promise { @@ -92,6 +153,7 @@ beforeEach(async () => { process.env['HOME'] = HOME process.env['USERPROFILE'] = HOME process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR + process.env['CRUSH_GLOBAL_DATA'] = join(testRoot, 'crush-global') await rm(HOME, { recursive: true, force: true }) await rm(CACHE_DIR, { recursive: true, force: true }) await mkdir(CACHE_DIR, { recursive: true }) @@ -99,6 +161,7 @@ beforeEach(async () => { afterAll(async () => { await rm(testRoot, { recursive: true, force: true }) + delete process.env['CRUSH_GLOBAL_DATA'] }) it('keeps an OpenClaw reported cost cold and warm, and re-prices the rest', async () => { @@ -139,6 +202,49 @@ it('keeps a Pi reported cost cold and warm, and re-prices the rest', async () => expect(costs.get('r3')).toBeUndefined() }) +it('a positive recorded cost survives the cache cold and warm', async () => { + if (!isSqliteAvailable()) return + + const globalData = join(testRoot, 'crush-global') + const projectDir = join(testRoot, 'crush-project-positive') + const dbPath = createCrushDb(join(projectDir, '.crush')) + withCrushDb(dbPath, db => { + insertCrushSession(db, 'positive', 1, 1, 1.23) + insertCrushMessage(db, 'positive', 'positive-message') + }) + await writeCrushRegistry(globalData, projectDir) + + const { clearSessionCache, parseAllSessions } = await import('../src/parser.js') + clearSessionCache() + const cold = (await parseAllSessions(undefined, 'crush')).reduce((sum, project) => sum + project.totalCostUSD, 0) + clearSessionCache() + const warm = (await parseAllSessions(undefined, 'crush')).reduce((sum, project) => sum + project.totalCostUSD, 0) + expect(cold).toBeCloseTo(1.23, 10) + expect(warm).toBeCloseTo(1.23, 10) +}) + +it('a zero-cost row keeps the existing token-repricing behavior', async () => { + if (!isSqliteAvailable()) return + + const globalData = join(testRoot, 'crush-global') + const projectDir = join(testRoot, 'crush-project-zero') + const dbPath = createCrushDb(join(projectDir, '.crush')) + withCrushDb(dbPath, db => { + insertCrushSession(db, 'zero', 1000, 500, 0) + insertCrushMessage(db, 'zero', 'zero-message') + }) + await writeCrushRegistry(globalData, projectDir) + + const { clearSessionCache, parseAllSessions } = await import('../src/parser.js') + clearSessionCache() + const cold = (await parseAllSessions(undefined, 'crush')).reduce((sum, project) => sum + project.totalCostUSD, 0) + clearSessionCache() + const warm = (await parseAllSessions(undefined, 'crush')).reduce((sum, project) => sum + project.totalCostUSD, 0) + const expected = 1000 * 3e-6 + 500 * 15e-6 + expect(cold).toBeCloseTo(expected, 10) + expect(warm).toBeCloseTo(expected, 10) +}) + it('holds the reported-cost provider set to its documented membership', async () => { const { REPORTED_COST_PROVIDERS } = await import('../src/parser.js') // A provider joins this set (or starts setting costFromBilling) only