mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-19 18:23:41 +00:00
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:
parent
1e83611289
commit
86385e541f
6 changed files with 125 additions and 7 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue