mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-03 21:35:13 +00:00
165 lines
6.5 KiB
TypeScript
165 lines
6.5 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
|
|
|
import { loadPricing } from '../src/models.js'
|
|
import { parseAllSessions, clearSessionCache } from '../src/parser.js'
|
|
import type { DateRange, ProjectSummary } from '../src/types.js'
|
|
|
|
// End-to-end proof for issue #639: a provider that sets `costIsEstimated` on its
|
|
// parsed calls must carry that truth all the way onto ParsedApiCall and into the
|
|
// session/model aggregates, surviving the session-cache round trip. CodeWhale is
|
|
// used because the same provider yields a measured call (metadata carries a real
|
|
// `cost`) or an estimated one (no `cost`, so tokens are priced) purely from the
|
|
// fixture, which keeps the measured-vs-estimated contrast in one code path.
|
|
|
|
const FIXTURE_DAY = Date.UTC(2026, 6, 14)
|
|
const RANGE: DateRange = {
|
|
start: new Date(FIXTURE_DAY - 24 * 60 * 60 * 1000),
|
|
end: new Date(FIXTURE_DAY + 24 * 60 * 60 * 1000),
|
|
}
|
|
|
|
let home: string
|
|
let cacheDir: string
|
|
let prevHome: string | undefined
|
|
let prevCache: string | undefined
|
|
|
|
beforeAll(async () => {
|
|
await loadPricing()
|
|
})
|
|
|
|
beforeEach(async () => {
|
|
home = await mkdtemp(join(tmpdir(), 'codeburn-est-home-'))
|
|
cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-est-cache-'))
|
|
prevHome = process.env['CODEWHALE_HOME']
|
|
prevCache = process.env['CODEBURN_CACHE_DIR']
|
|
process.env['CODEWHALE_HOME'] = home
|
|
process.env['CODEBURN_CACHE_DIR'] = cacheDir
|
|
clearSessionCache()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
clearSessionCache()
|
|
if (prevHome === undefined) delete process.env['CODEWHALE_HOME']
|
|
else process.env['CODEWHALE_HOME'] = prevHome
|
|
if (prevCache === undefined) delete process.env['CODEBURN_CACHE_DIR']
|
|
else process.env['CODEBURN_CACHE_DIR'] = prevCache
|
|
await rm(home, { recursive: true, force: true })
|
|
await rm(cacheDir, { recursive: true, force: true })
|
|
})
|
|
|
|
async function writeSession(id: string, opts: { cost?: Record<string, number> | null; model?: string }): Promise<void> {
|
|
const sessions = join(home, 'sessions')
|
|
await mkdir(sessions, { recursive: true })
|
|
const metadata: Record<string, unknown> = {
|
|
id,
|
|
title: 'estimated-cost fixture',
|
|
created_at: '2026-07-14T10:00:00.000Z',
|
|
updated_at: '2026-07-14T11:00:00.000Z',
|
|
message_count: 0,
|
|
total_tokens: 200_000,
|
|
model: opts.model ?? 'gpt-4o',
|
|
model_provider: 'openai',
|
|
workspace: '/repos/est-fixture',
|
|
mode: 'agent',
|
|
}
|
|
if (opts.cost !== null && opts.cost !== undefined) metadata.cost = opts.cost
|
|
await writeFile(join(sessions, `${id}.json`), JSON.stringify({ schema_version: 1, metadata, messages: [] }))
|
|
}
|
|
|
|
function onlyCall(projects: ProjectSummary[]) {
|
|
const calls = projects.flatMap(p => p.sessions.flatMap(s => s.turns.flatMap(t => t.assistantCalls)))
|
|
expect(calls).toHaveLength(1)
|
|
return calls[0]!
|
|
}
|
|
|
|
function onlySession(projects: ProjectSummary[]) {
|
|
const sessions = projects.flatMap(p => p.sessions)
|
|
expect(sessions).toHaveLength(1)
|
|
return sessions[0]!
|
|
}
|
|
|
|
describe('estimated cost propagation (#639)', () => {
|
|
it('flags an estimated call and rolls the estimated dollars up through session/model/project', async () => {
|
|
await writeSession('estimated', { cost: null })
|
|
const projects = await parseAllSessions(RANGE, 'codewhale')
|
|
|
|
const call = onlyCall(projects)
|
|
expect(call.costUSD).toBeGreaterThan(0)
|
|
expect(call.isEstimated).toBe(true)
|
|
|
|
const session = onlySession(projects)
|
|
expect(session.totalEstimatedCostUSD).toBeCloseTo(call.costUSD)
|
|
const modelEntry = Object.values(session.modelBreakdown)[0]!
|
|
expect(modelEntry.estimatedCostUSD).toBeCloseTo(call.costUSD)
|
|
|
|
expect(projects[0]!.totalEstimatedCostUSD).toBeCloseTo(call.costUSD)
|
|
// Metadata only: the estimated portion is never subtracted from cost.
|
|
expect(session.totalCostUSD).toBeCloseTo(call.costUSD)
|
|
})
|
|
|
|
it('does not flag a measured call (real cost in metadata)', async () => {
|
|
await writeSession('measured', { cost: { session_cost_usd: 0.25 } })
|
|
const projects = await parseAllSessions(RANGE, 'codewhale')
|
|
|
|
const call = onlyCall(projects)
|
|
expect(call.costUSD).toBeCloseTo(0.25)
|
|
expect(call.isEstimated).toBeFalsy()
|
|
|
|
const session = onlySession(projects)
|
|
expect(session.totalEstimatedCostUSD ?? 0).toBe(0)
|
|
expect(Object.values(session.modelBreakdown)[0]!.estimatedCostUSD ?? 0).toBe(0)
|
|
expect(projects[0]!.totalEstimatedCostUSD ?? 0).toBe(0)
|
|
})
|
|
|
|
it('keeps the flag after the session-cache round trip (second parse is cache-served)', async () => {
|
|
await writeSession('estimated', { cost: null })
|
|
const first = await parseAllSessions(RANGE, 'codewhale')
|
|
const firstCost = onlyCall(first).costUSD
|
|
|
|
// Do NOT clear the cache: the second parse hydrates the call from
|
|
// session-cache.json, exercising CachedCall.isEstimated persistence.
|
|
const second = await parseAllSessions(RANGE, 'codewhale')
|
|
const call = onlyCall(second)
|
|
expect(call.isEstimated).toBe(true)
|
|
expect(call.costUSD).toBeCloseTo(firstCost)
|
|
expect(onlySession(second).totalEstimatedCostUSD).toBeCloseTo(firstCost)
|
|
})
|
|
})
|
|
|
|
describe('cross-provider project merge (#639 regression)', () => {
|
|
function summaryFor(path: string, opts: { cost: number; estimated?: number }): ProjectSummary {
|
|
return {
|
|
project: path.split('/').pop()!,
|
|
projectPath: path,
|
|
sessions: [],
|
|
totalCostUSD: opts.cost,
|
|
totalSavingsUSD: 0,
|
|
totalApiCalls: 1,
|
|
totalProxiedCostUSD: 0,
|
|
...(opts.estimated !== undefined ? { totalEstimatedCostUSD: opts.estimated } : {}),
|
|
} as ProjectSummary
|
|
}
|
|
|
|
it('keeps merged-in estimated dollars when two providers share a repo', async () => {
|
|
const { mergeProjectsByCrossProviderKey } = await import('../src/parser.js')
|
|
const merged = mergeProjectsByCrossProviderKey([
|
|
summaryFor('/repos/shared', { cost: 10 }), // measured provider, no estimate
|
|
summaryFor('/repos/shared', { cost: 5, estimated: 5 }), // estimated provider
|
|
])
|
|
expect(merged.size).toBe(1)
|
|
const project = [...merged.values()][0]!
|
|
expect(project.totalCostUSD).toBe(15)
|
|
expect(project.totalEstimatedCostUSD).toBe(5)
|
|
})
|
|
|
|
it('sums estimated dollars when both merged sides carry them', async () => {
|
|
const { mergeProjectsByCrossProviderKey } = await import('../src/parser.js')
|
|
const merged = mergeProjectsByCrossProviderKey([
|
|
summaryFor('/repos/shared', { cost: 3, estimated: 3 }),
|
|
summaryFor('/repos/shared', { cost: 4, estimated: 4 }),
|
|
])
|
|
expect([...merged.values()][0]!.totalEstimatedCostUSD).toBe(7)
|
|
})
|
|
})
|