mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-25 08:22:42 +00:00
fix(codex): stop double-billing reasoning output, price cache writes at the explicit rate only
Reasoning tokens are a subset of output_tokens for OpenAI models, not an extra bucket: on a 1,396-rollout corpus all 134,316 token_count events carrying a total satisfy input + output == total. CodeBurn added reasoning_output_tokens on top when pricing a codex call, in the cache-rehydration re-price, and in the models/audit display sums. That overstated codex cost by $166.03 (3.5%) and displayed output tokens by 34.6% on that corpus. Both cost sites and the display sums now go through one shared billableOutputTokens() so a cold parse and a warm read cannot drift apart. cache_write_input_tokens (codex PR #33454) was never read and cacheCreationInputTokens was hardcoded to 0. It is now carved out of the uncached-input bucket and clamped to it, but routed to the cache-write bucket ONLY when the pricing source publishes an explicit cache-write rate. buildCosts fabricates 1.25x input when a source omits one, which is correct for Anthropic and would have invented a surcharge OpenAI never charged on gpt-5.5 / 5.4 / 5.3-codex / gpt-5. ModelCosts now carries cacheWriteCostIsExplicit so that distinction survives getModelCosts. A cost change invalidates persisted output: codex-results.json v10 -> v11 (stores costUSD verbatim), the codex parse version moves (the token-bucket change does not self-heal on read), and the daily cache goes 20 -> 23 (21 is claimed by the #946 landing branch and 22 by PR #1056). The upgrade-path corpus asserts codex tokens and calls exactly and reports the repricing. Closes #1075
This commit is contained in:
parent
e12fb39e3f
commit
fda7e8024d
15 changed files with 542 additions and 27 deletions
|
|
@ -86,8 +86,11 @@ describe('aggregateAudit', () => {
|
|||
expect(r.raw.reasoningTokens).toBe(10)
|
||||
expect(r.raw.cacheReadInputTokens).toBe(200)
|
||||
expect(r.raw.cachedInputTokens).toBe(300)
|
||||
// reasoning folds into output for pricing
|
||||
expect(r.displayed.outputTokens).toBe(110)
|
||||
// Reasoning does NOT fold into output for claude or codex: both bill it
|
||||
// as part of output_tokens already, so adding it would double-count
|
||||
// (#1075). Providers that report reasoning as a separate bucket still get
|
||||
// the additive treatment - see tests/codex-pricing-1075.test.ts.
|
||||
expect(r.displayed.outputTokens).toBe(100)
|
||||
// cache read is the SUM of per-call max(anthropic, openai), not max of sums
|
||||
expect(r.displayed.cacheReadTokens).toBe(500)
|
||||
// attributed cost is preserved exactly
|
||||
|
|
|
|||
69
tests/codex-pricing-1075-rehydrate.test.ts
Normal file
69
tests/codex-pricing-1075-rehydrate.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// #1075, cost site 2 of 2. Codex is NOT on parser.ts's reported-cost
|
||||
// pass-through allowlist, so the session cache stores its calls with
|
||||
// `costUSD: undefined` and every warm run re-prices them from the stored token
|
||||
// buckets in cachedCallToApiCall. That line and the one in the codex provider
|
||||
// are twins: if only one drops the reasoning double-count, a user's number
|
||||
// changes between a cold and a warm run. This drives the full parseAllSessions
|
||||
// pipeline twice against the same file to prove they agree.
|
||||
//
|
||||
// Own file because the codex provider captures CODEX_HOME when its module is
|
||||
// first evaluated, so the env must be set before any import of it.
|
||||
|
||||
import { afterAll, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
const testRoot = vi.hoisted(() => {
|
||||
const root = `${process.env['TMPDIR'] || '/tmp'}/codex-1075-rehydrate-${process.pid}-${Date.now()}`
|
||||
process.env['HOME'] = `${root}/home`
|
||||
process.env['USERPROFILE'] = `${root}/home`
|
||||
process.env['CODEX_HOME'] = `${root}/codex`
|
||||
return root
|
||||
})
|
||||
|
||||
const CODEX_HOME = join(testRoot, 'codex')
|
||||
const CACHE_DIR = join(testRoot, 'cache')
|
||||
|
||||
// gpt-5.5: input 5e-6, output 30e-6, cacheRead 5e-7 (src/data/litellm-snapshot.json).
|
||||
// 800 uncached input + 200 cached + 1000 output, of which 400 are reasoning.
|
||||
const EXPECTED = 800 * 5e-6 + 200 * 5e-7 + 1000 * 30e-6
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['HOME'] = join(testRoot, 'home')
|
||||
process.env['USERPROFILE'] = join(testRoot, 'home')
|
||||
process.env['CODEX_HOME'] = CODEX_HOME
|
||||
process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(testRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('prices a codex call the same on a cold parse and a cache-rehydrated read', async () => {
|
||||
const sessionDir = join(CODEX_HOME, 'sessions', '2026', '08', '16')
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await mkdir(CACHE_DIR, { recursive: true })
|
||||
const usage = { input_tokens: 1000, cached_input_tokens: 200, output_tokens: 1000, reasoning_output_tokens: 400, total_tokens: 2000 }
|
||||
await writeFile(join(sessionDir, 'rollout-1075.jsonl'), [
|
||||
JSON.stringify({ type: 'session_meta', timestamp: '2026-08-16T10:00:00Z', payload: { session_id: 's1075', model: 'gpt-5.5', cwd: '/Users/test/proj', originator: 'codex_cli_rs' } }),
|
||||
JSON.stringify({ type: 'response_item', timestamp: '2026-08-16T10:00:10Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-08-16T10:01:00Z', payload: { type: 'token_count', info: { model: 'gpt-5.5', last_token_usage: usage, total_token_usage: usage } } }),
|
||||
].join('\n') + '\n')
|
||||
|
||||
const { clearSessionCache, parseAllSessions } = await import('../src/parser.js')
|
||||
|
||||
clearSessionCache()
|
||||
const cold = await parseAllSessions(undefined, 'codex')
|
||||
const coldCost = cold.reduce((sum, p) => sum + p.totalCostUSD, 0)
|
||||
|
||||
// Drop the in-memory cache only: session-cache.json on disk now serves the
|
||||
// unchanged file, so this run's cost comes out of cachedCallToApiCall.
|
||||
clearSessionCache()
|
||||
const warm = await parseAllSessions(undefined, 'codex')
|
||||
const warmCost = warm.reduce((sum, p) => sum + p.totalCostUSD, 0)
|
||||
|
||||
// Revert only src/providers/codex.ts and the cold leg breaks; revert only
|
||||
// src/parser.ts's outputForCost and the warm leg breaks.
|
||||
expect(coldCost).toBeCloseTo(EXPECTED, 12)
|
||||
expect(warmCost).toBeCloseTo(EXPECTED, 12)
|
||||
})
|
||||
352
tests/codex-pricing-1075.test.ts
Normal file
352
tests/codex-pricing-1075.test.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
// Regression suite for #1075 (reported by chr-evensen).
|
||||
//
|
||||
// Two independent codex pricing bugs, each with the site that would silently
|
||||
// drift from its twin if only one half were reverted:
|
||||
//
|
||||
// A. reasoning_output_tokens is a SUBSET of output_tokens (OpenAI bills
|
||||
// reasoning as part of output; every token_count event in a 134k-event
|
||||
// corpus satisfies input + output == total), but codeburn added the two.
|
||||
// Priced in TWO places -- the fresh parse in src/providers/codex.ts and
|
||||
// the cache-rehydration re-price in src/parser.ts -- plus three display
|
||||
// sums. Both cost sites now go through billableOutputTokens(). The
|
||||
// cache-rehydration half lives in codex-pricing-1075-rehydrate.test.ts,
|
||||
// which needs CODEX_HOME set before the provider module is evaluated.
|
||||
//
|
||||
// B. cache_write_input_tokens was never read. It is now carved out of the
|
||||
// uncached-input bucket, but ONLY on models whose pricing source carries
|
||||
// an explicit cache-write rate: buildCosts() fabricates 1.25x input when
|
||||
// the source omits one, which is right for Anthropic but would invent a
|
||||
// surcharge OpenAI never charged on every pre-5.6 model.
|
||||
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { aggregateAudit } from '../src/audit-report.js'
|
||||
import { aggregateModels } from '../src/models-report.js'
|
||||
import { clearCodexMemCaches, readCachedCodexResults } from '../src/codex-cache.js'
|
||||
import { currentTzKey, ensureCacheHydrated, toDateString, type DailyEntry } from '../src/daily-cache.js'
|
||||
import { createCodexProvider } from '../src/providers/codex.js'
|
||||
import type { ParsedProviderCall } from '../src/providers/types.js'
|
||||
import type {
|
||||
ClassifiedTurn,
|
||||
ParsedApiCall,
|
||||
ProjectSummary,
|
||||
SessionSummary,
|
||||
TaskCategory,
|
||||
TokenUsage,
|
||||
} from '../src/types.js'
|
||||
|
||||
// Snapshot ground truth (src/data/litellm-snapshot.json), USD per token:
|
||||
// gpt-5.6-terra input 2e-6 output 12e-6 cacheWrite 2.5e-6 (EXPLICIT) cacheRead 2e-7
|
||||
// gpt-5.5 input 5e-6 output 30e-6 cacheWrite null (fabricated) cacheRead 5e-7
|
||||
const TERRA = { input: 2e-6, output: 12e-6, cacheWrite: 2.5e-6, cacheRead: 2e-7 }
|
||||
const GPT55 = { input: 5e-6, output: 30e-6, cacheRead: 5e-7 }
|
||||
|
||||
let tmpDir: string
|
||||
beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'codex-1075-')) })
|
||||
afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }) })
|
||||
|
||||
type Usage = {
|
||||
input_tokens: number
|
||||
cached_input_tokens?: number
|
||||
cache_write_input_tokens?: number
|
||||
output_tokens: number
|
||||
reasoning_output_tokens?: number
|
||||
}
|
||||
|
||||
async function parseOneEvent(model: string, usage: Usage): Promise<ParsedProviderCall> {
|
||||
const total = usage.input_tokens + usage.output_tokens
|
||||
const sessionDir = join(tmpDir, 'sessions', '2026', '08', '16')
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
const filePath = join(sessionDir, `rollout-${model}-${Math.random().toString(36).slice(2)}.jsonl`)
|
||||
await writeFile(filePath, [
|
||||
JSON.stringify({
|
||||
type: 'session_meta',
|
||||
timestamp: '2026-08-16T10:00:00Z',
|
||||
payload: { cwd: '/Users/t/p', originator: 'codex-cli', session_id: 's1075', model },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-08-16T10:01:00Z',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: { model, last_token_usage: { ...usage, total_tokens: total }, total_token_usage: { ...usage, total_tokens: total } },
|
||||
},
|
||||
}),
|
||||
].join('\n') + '\n')
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const parser = provider.createSessionParser({ path: filePath, project: 'test', provider: 'codex' }, new Set())
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) calls.push(call)
|
||||
expect(calls).toHaveLength(1)
|
||||
return calls[0]!
|
||||
}
|
||||
|
||||
// ── Fix A: reasoning is already inside output ─────────────────────────────
|
||||
|
||||
describe('#1075 A - reasoning is not billed on top of output', () => {
|
||||
it('prices a fresh codex parse from output_tokens alone', async () => {
|
||||
const call = await parseOneEvent('gpt-5.5', {
|
||||
input_tokens: 1000,
|
||||
cached_input_tokens: 200,
|
||||
output_tokens: 1000,
|
||||
reasoning_output_tokens: 400,
|
||||
})
|
||||
|
||||
// 800 uncached input + 200 cached + 1000 output. The 400 reasoning tokens
|
||||
// are INSIDE the 1000, so they must not be priced again.
|
||||
const expected = 800 * GPT55.input + 200 * GPT55.cacheRead + 1000 * GPT55.output
|
||||
expect(call.costUSD).toBeCloseTo(expected, 12)
|
||||
// Guard the direction: the pre-fix arithmetic charged 1400 output tokens.
|
||||
const preFix = 800 * GPT55.input + 200 * GPT55.cacheRead + 1400 * GPT55.output
|
||||
expect(call.costUSD).toBeLessThan(preFix)
|
||||
// The raw fields are still reported untouched; only the pricing changed.
|
||||
expect(call.outputTokens).toBe(1000)
|
||||
expect(call.reasoningTokens).toBe(400)
|
||||
})
|
||||
|
||||
it('does not double-count reasoning in the displayed output tokens', async () => {
|
||||
const codex = makeApiCall('codex', 'gpt-5.5', { outputTokens: 1000, reasoningTokens: 400 })
|
||||
// A provider that really does report reasoning as a separate bucket keeps
|
||||
// the additive behaviour, so this is a codex carve-out and not a blanket
|
||||
// change to every display sum.
|
||||
const additive = makeApiCall('hermes', 'gpt-5.5', { outputTokens: 1000, reasoningTokens: 400 })
|
||||
const projects = [makeProject([codex, additive])]
|
||||
|
||||
const auditRows = await aggregateAudit(projects)
|
||||
expect(auditRows.find(r => r.provider === 'codex')!.displayed.outputTokens).toBe(1000)
|
||||
expect(auditRows.find(r => r.provider === 'hermes')!.displayed.outputTokens).toBe(1400)
|
||||
|
||||
const modelRows = await aggregateModels(projects)
|
||||
expect(modelRows.find(r => r.provider === 'codex')!.outputTokens).toBe(1000)
|
||||
expect(modelRows.find(r => r.provider === 'hermes')!.outputTokens).toBe(1400)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Fix B: cache_write_input_tokens, guarded ──────────────────────────────
|
||||
|
||||
describe('#1075 B - cache_write_input_tokens', () => {
|
||||
it('prices cache writes at the explicit rate on gpt-5.6-terra', async () => {
|
||||
const call = await parseOneEvent('gpt-5.6-terra', {
|
||||
input_tokens: 1000,
|
||||
cached_input_tokens: 200,
|
||||
cache_write_input_tokens: 300,
|
||||
output_tokens: 100,
|
||||
})
|
||||
|
||||
expect(call.inputTokens).toBe(500)
|
||||
expect(call.cacheCreationInputTokens).toBe(300)
|
||||
expect(call.cacheReadInputTokens).toBe(200)
|
||||
const expected =
|
||||
500 * TERRA.input +
|
||||
300 * TERRA.cacheWrite +
|
||||
200 * TERRA.cacheRead +
|
||||
100 * TERRA.output
|
||||
expect(expected).toBeCloseTo(0.00299, 12)
|
||||
expect(call.costUSD).toBeCloseTo(expected, 12)
|
||||
})
|
||||
|
||||
it('THE GUARD: leaves cache writes in the input bucket when the model has no explicit rate', async () => {
|
||||
// gpt-5.5 carries `null` for cache_creation_input_token_cost, so
|
||||
// buildCosts fabricates 1.25x input for it. OpenAI charges nothing extra
|
||||
// to write cache before gpt-5.6, so routing these tokens through that
|
||||
// fabricated rate would invent a surcharge. Cost must be byte-identical to
|
||||
// the pre-fix number. Delete the guard and this test fails.
|
||||
const withWrite = await parseOneEvent('gpt-5.5', {
|
||||
input_tokens: 1000,
|
||||
cached_input_tokens: 200,
|
||||
cache_write_input_tokens: 300,
|
||||
output_tokens: 100,
|
||||
})
|
||||
const withoutWrite = await parseOneEvent('gpt-5.5', {
|
||||
input_tokens: 1000,
|
||||
cached_input_tokens: 200,
|
||||
output_tokens: 100,
|
||||
})
|
||||
|
||||
expect(withWrite.inputTokens).toBe(800)
|
||||
expect(withWrite.cacheCreationInputTokens).toBe(0)
|
||||
const expected = 800 * GPT55.input + 200 * GPT55.cacheRead + 100 * GPT55.output
|
||||
expect(withWrite.costUSD).toBeCloseTo(expected, 12)
|
||||
expect(withWrite.costUSD).toBeCloseTo(withoutWrite.costUSD, 12)
|
||||
// The fabricated rate is 1.25 x 5e-6; make sure not a cent of it landed.
|
||||
expect(withWrite.costUSD).toBeLessThan(expected + 300 * GPT55.input * 1.25)
|
||||
})
|
||||
|
||||
it('clamps a cache-write count larger than the uncached input', async () => {
|
||||
const call = await parseOneEvent('gpt-5.6-terra', {
|
||||
input_tokens: 1000,
|
||||
cached_input_tokens: 200,
|
||||
cache_write_input_tokens: 5000,
|
||||
output_tokens: 100,
|
||||
})
|
||||
|
||||
expect(call.inputTokens).toBe(0)
|
||||
expect(call.cacheCreationInputTokens).toBe(800)
|
||||
expect(call.costUSD).toBeCloseTo(800 * TERRA.cacheWrite + 200 * TERRA.cacheRead + 100 * TERRA.output, 12)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Cache invalidation: a cost change must not be served from stale bytes ──
|
||||
|
||||
describe('#1075 cache invalidation', () => {
|
||||
it('discards a v10 codex results cache (it stores costUSD verbatim)', async () => {
|
||||
const cacheDir = join(tmpDir, 'cache')
|
||||
await mkdir(cacheDir, { recursive: true })
|
||||
const sessionFile = join(tmpDir, 'rollout-stale.jsonl')
|
||||
await writeFile(sessionFile, '{}\n')
|
||||
|
||||
const { statSync } = await import('fs')
|
||||
const s = statSync(sessionFile)
|
||||
const stale: ParsedProviderCall = {
|
||||
provider: 'codex',
|
||||
model: 'gpt-5.5',
|
||||
inputTokens: 800,
|
||||
outputTokens: 1000,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 200,
|
||||
cachedInputTokens: 200,
|
||||
reasoningTokens: 400,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.0445, // the pre-fix, reasoning-double-counted number
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: '2026-08-16T10:01:00Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'codex:stale',
|
||||
}
|
||||
await writeFile(join(cacheDir, 'codex-results.json'), JSON.stringify({
|
||||
version: 10,
|
||||
files: { [sessionFile]: { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size, project: 'p', calls: [stale] } },
|
||||
}))
|
||||
|
||||
const prevCacheDir = process.env['CODEBURN_CACHE_DIR']
|
||||
process.env['CODEBURN_CACHE_DIR'] = cacheDir
|
||||
try {
|
||||
clearCodexMemCaches()
|
||||
// Revert CODEX_CACHE_VERSION to 10 and this returns the stale $0.0445 call.
|
||||
expect(await readCachedCodexResults(sessionFile)).toBeNull()
|
||||
} finally {
|
||||
if (prevCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']; else process.env['CODEBURN_CACHE_DIR'] = prevCacheDir
|
||||
}
|
||||
})
|
||||
|
||||
it('re-derives days finalized at daily-cache v20', async () => {
|
||||
const cacheRoot = join(tmpDir, 'daily')
|
||||
await mkdir(cacheRoot, { recursive: true })
|
||||
const prevCacheDir = process.env['CODEBURN_CACHE_DIR']
|
||||
process.env['CODEBURN_CACHE_DIR'] = cacheRoot
|
||||
try {
|
||||
const date = toDateString(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
|
||||
const yesterday = toDateString(new Date(Date.now() - 24 * 60 * 60 * 1000))
|
||||
const oldPath = join(cacheRoot, 'daily-cache.v20.json')
|
||||
const oldCache = {
|
||||
version: 20,
|
||||
savingsConfigHash: 'cfg',
|
||||
tzKey: currentTzKey(),
|
||||
lastComputedDate: yesterday,
|
||||
days: [codexDay(date, 99)],
|
||||
complete: true,
|
||||
watermarkTrusted: true,
|
||||
}
|
||||
await writeFile(oldPath, JSON.stringify(oldCache))
|
||||
|
||||
let parseCount = 0
|
||||
const hydrated = await ensureCacheHydrated(
|
||||
async () => { parseCount++; return [] },
|
||||
() => [codexDay(date, 2)],
|
||||
'cfg',
|
||||
() => true,
|
||||
)
|
||||
|
||||
// Drop MIN_SUPPORTED_VERSION back to 20 and the v20 day is trusted as-is,
|
||||
// so parseCount stays 0 and the day keeps its overstated $99.
|
||||
expect(parseCount).toBe(1)
|
||||
expect(hydrated.days.find(d => d.date === date)?.cost).toBe(2)
|
||||
expect(JSON.parse(await readFile(oldPath, 'utf8'))).toEqual(oldCache)
|
||||
} finally {
|
||||
if (prevCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']; else process.env['CODEBURN_CACHE_DIR'] = prevCacheDir
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
function makeApiCall(provider: string, model: string, usage: Partial<TokenUsage>): ParsedApiCall {
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
...usage,
|
||||
},
|
||||
costUSD: 0,
|
||||
tools: [],
|
||||
mcpTools: [],
|
||||
skills: [],
|
||||
hasAgentSpawn: false,
|
||||
hasPlanMode: false,
|
||||
speed: 'standard',
|
||||
timestamp: '2026-08-16T00:00:00.000Z',
|
||||
bashCommands: [],
|
||||
deduplicationKey: `${provider}-${model}`,
|
||||
}
|
||||
}
|
||||
|
||||
function makeProject(calls: ParsedApiCall[]): ProjectSummary {
|
||||
const turn: ClassifiedTurn = {
|
||||
userMessage: 't',
|
||||
assistantCalls: calls,
|
||||
timestamp: '2026-08-16T00:00:00.000Z',
|
||||
sessionId: 's1',
|
||||
category: 'feature' as TaskCategory,
|
||||
retries: 0,
|
||||
hasEdits: false,
|
||||
}
|
||||
const session: SessionSummary = {
|
||||
sessionId: 's1',
|
||||
project: 'p',
|
||||
firstTimestamp: '2026-08-16T00:00:00.000Z',
|
||||
lastTimestamp: '2026-08-16T00:00:00.000Z',
|
||||
totalCostUSD: 0,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
totalCacheWriteTokens: 0,
|
||||
apiCalls: 0,
|
||||
turns: [turn],
|
||||
modelBreakdown: {},
|
||||
toolBreakdown: {},
|
||||
mcpBreakdown: {},
|
||||
bashBreakdown: {},
|
||||
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
|
||||
skillBreakdown: {},
|
||||
}
|
||||
return { project: 'p', projectPath: 'p', sessions: [session], totalCostUSD: 0, totalApiCalls: 0 }
|
||||
}
|
||||
|
||||
function codexDay(date: string, cost: number): DailyEntry {
|
||||
const tokens = { inputTokens: 100, outputTokens: 20, cacheReadTokens: 30, cacheWriteTokens: 0 }
|
||||
return {
|
||||
date,
|
||||
cost,
|
||||
savingsUSD: 0,
|
||||
calls: 1,
|
||||
sessions: 1,
|
||||
...tokens,
|
||||
editTurns: 0,
|
||||
oneShotTurns: 0,
|
||||
models: { 'GPT-5.5': { calls: 1, cost, savingsUSD: 0, ...tokens } },
|
||||
categories: {},
|
||||
providers: { codex: { calls: 1, cost, savingsUSD: 0, sessions: 1, ...tokens } },
|
||||
}
|
||||
}
|
||||
|
|
@ -237,11 +237,14 @@ describe('aggregateModels', () => {
|
|||
expect(above.find(r => r.provider === 'cursor')).toBeUndefined()
|
||||
})
|
||||
|
||||
// Providers that report reasoning as a bucket SEPARATE from output still get
|
||||
// it added in. Codex and claude do not - they bill reasoning inside
|
||||
// output_tokens - and that carve-out is covered in codex-pricing-1075.test.ts.
|
||||
it('counts reasoning tokens as output tokens', async () => {
|
||||
const project = makeProject([
|
||||
makeTurn('feature', [
|
||||
{
|
||||
provider: 'codex',
|
||||
provider: 'hermes',
|
||||
model: 'gpt-5',
|
||||
usage: { ...emptyTokens(), inputTokens: 100, outputTokens: 50, reasoningTokens: 200 },
|
||||
costUSD: 1.0,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue