fix(crush): keep the recorded session cost through the session cache (#1618)

Co-authored-by: Resham Joshi <65915470+iamtoruk@users.noreply.github.com>
This commit is contained in:
Perry Story 2026-10-04 14:31:42 -04:00 • committed by GitHub
parent 1e83611289
commit 86385e541f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 125 additions and 7 deletions

View file

@ -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.

View file

@ -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

View file

@ -182,6 +182,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
reasoningTokens: 0,
webSearchRequests: 0,
costUSD,
...(cost > 0 ? { costFromBilling: true } : {}),
tools: [],
bashCommands: [],
timestamp: epochSecondsToIso(session.updated_at ?? session.created_at),

View file

@ -411,6 +411,9 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// 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

View file

@ -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.

View file

@ -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<void> {
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<void> {
@ -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