From 93ea7f3fb28eedeac38549950c481036b2489c2b Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Fri, 10 Jul 2026 20:13:02 +0200 Subject: [PATCH 1/2] feat(claude): account for /advisor escalations under the advisor model Claude Code's advisor tool (/advisor) escalates hard decisions mid-turn to a stronger model. Those tokens are recorded inside message.usage.iterations as advisor_message entries with their own model and are excluded from the top-level message.usage totals. The parser read only the top-level totals, so advisor spend was silently dropped and never attributed to the advisor model. Emit a separate call per advisor_message iteration priced under the advisor model, preserve those iterations through entry compaction and the large-line (>32KB) byte-scanner path, and fold advisor cost into the guard budget. Bump the claude parser and guard cache versions so existing caches re-parse and pick up the advisor spend. --- src/guard/usage.ts | 16 ++- src/parser.ts | 127 +++++++++++++++++++- src/session-cache.ts | 2 +- src/types.ts | 23 ++++ tests/parser-advisor-usage.test.ts | 180 +++++++++++++++++++++++++++++ 5 files changed, 341 insertions(+), 7 deletions(-) create mode 100644 tests/parser-advisor-usage.test.ts diff --git a/src/guard/usage.ts b/src/guard/usage.ts index eaafdc74..3a584b17 100644 --- a/src/guard/usage.ts +++ b/src/guard/usage.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, stat, writeFile } from 'fs/promises' import { readSessionLines } from '../fs-utils.js' -import { parseApiCall, parseJsonlLine } from '../parser.js' +import { parseApiCall, parseAdvisorCalls, parseJsonlLine } from '../parser.js' import { EDIT_TOOLS } from '../classifier.js' import { allowPath, sessionCachePath, sessionsDir } from './store.js' @@ -33,7 +33,9 @@ export type GuardCache = { // v2: per-message-id replace fold (perMessage map, sawEdit boolean) and the // guard/sessions/ cache location. v1 caches are ignored and cold-reparse once. -export const GUARD_CACHE_VERSION = 2 +// v3: fold advisor (/advisor) cost into each message; v2 caches computed the +// per-message cost without it, so they must cold-reparse. +export const GUARD_CACHE_VERSION = 3 // `commit` must be the git subcommand: `git`, optionally flag tokens (long // flags, or a short flag with an optional separate value like `-c k=v`), then @@ -121,14 +123,18 @@ export async function computeSessionUsage( if (!entry) continue const call = parseApiCall(entry) if (!call) continue + // Advisor (/advisor) escalations are billed under a separate model and live + // outside the top-level usage, so add their cost to the message total. + const advisorCost = parseAdvisorCalls(entry).reduce((sum, a) => sum + a.costUSD, 0) + const lineCost = call.costUSD + advisorCost // Last-wins per message id, matching the shipped dedupeStreamingMessageIds. // Lines without an id (rare, and never streamed in copies) just add. const msgId = (entry.message as { id?: string } | undefined)?.id if (msgId) { - cache.costUSD += call.costUSD - (cache.perMessage[msgId] ?? 0) - cache.perMessage[msgId] = call.costUSD + cache.costUSD += lineCost - (cache.perMessage[msgId] ?? 0) + cache.perMessage[msgId] = lineCost } else { - cache.costUSD += call.costUSD + cache.costUSD += lineCost } for (const tc of call.toolSequence?.flat() ?? []) { if (!cache.sawEdit && EDIT_TOOLS.has(tc.tool)) cache.sawEdit = true diff --git a/src/parser.ts b/src/parser.ts index 2f5de981..632361df 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -24,6 +24,7 @@ import { } from './session-cache.js' import type { ParsedProviderCall, SessionSource } from './providers/types.js' import type { + ApiUsageIteration, AssistantMessageContent, ClassifiedTurn, ContentBlock, @@ -276,6 +277,26 @@ function readJsonNumberField(source: string, objectBounds: JsonValueBounds | nul return Number.isFinite(value) ? value : undefined } +// The large-line parsers avoid JSON.parse on the whole (multi-KB) line, but the +// usage object itself is tiny; parse just that slice to recover advisor +// (/advisor) iterations, which the byte-scanner cannot cheaply extract. Without +// this, an advisor escalation on a large assistant turn would be dropped. +function extractAdvisorIterations(usageObjectJson: string): ApiUsageIteration[] | undefined { + let parsed: unknown + try { + parsed = JSON.parse(usageObjectJson) + } catch { + return undefined + } + const iterations = (parsed as { iterations?: unknown }).iterations + if (!Array.isArray(iterations)) return undefined + const advisor = iterations.filter( + (it): it is ApiUsageIteration => + !!it && typeof it === 'object' && (it as { type?: unknown }).type === 'advisor_message', + ) + return advisor.length > 0 ? advisor : undefined +} + function parseLargeUsage(source: string, usageBounds: JsonValueBounds | null) { const usage: AssistantMessageContent['usage'] = { input_tokens: readJsonNumberField(source, usageBounds, 'input_tokens') ?? 0, @@ -307,6 +328,9 @@ function parseLargeUsage(source: string, usageBounds: JsonValueBounds | null) { const speed = readJsonString(source, findObjectFieldValue(source, usageBounds.start, usageBounds.end, 'speed')) if (speed === 'standard' || speed === 'fast') usage.speed = speed + + const advisor = extractAdvisorIterations(source.slice(usageBounds.start, usageBounds.end)) + if (advisor) usage.iterations = advisor } return usage @@ -652,6 +676,9 @@ function parseLargeUsageBuffer(source: Buffer, usageBounds: BufferJsonValueBound const speed = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, usageBounds.start, usageBounds.end, 'speed')) if (speed === 'standard' || speed === 'fast') usage.speed = speed + + const advisor = extractAdvisorIterations(source.subarray(usageBounds.start, usageBounds.end).toString('utf8')) + if (advisor) usage.iterations = advisor } return usage @@ -952,6 +979,33 @@ export function compactEntry(raw: JournalEntry): JournalEntry { } } if (u.speed) compactUsage.speed = u.speed + // Preserve only advisor_message iterations (/advisor sub-usage) so + // parseAdvisorCalls can attribute the advisor model's spend; drop the rest to + // keep the cache small. Other iteration types (plain `message`, and the + // `fallback_message` written when a turn retries on another model) are not + // accounted here, a separate pre-existing gap, so they are not preserved. + if (Array.isArray(u.iterations)) { + const advisorIterations = u.iterations + .filter((it): it is ApiUsageIteration => !!it && it.type === 'advisor_message') + .map(it => { + const compact: ApiUsageIteration = { type: 'advisor_message' } + if (typeof it.model === 'string') compact.model = it.model + if (it.input_tokens) compact.input_tokens = it.input_tokens + if (it.output_tokens) compact.output_tokens = it.output_tokens + if (it.cache_creation_input_tokens) compact.cache_creation_input_tokens = it.cache_creation_input_tokens + if (it.cache_read_input_tokens) compact.cache_read_input_tokens = it.cache_read_input_tokens + if (it.cache_creation) { + compact.cache_creation = { + ...(it.cache_creation.ephemeral_5m_input_tokens ? { ephemeral_5m_input_tokens: it.cache_creation.ephemeral_5m_input_tokens } : {}), + ...(it.cache_creation.ephemeral_1h_input_tokens ? { ephemeral_1h_input_tokens: it.cache_creation.ephemeral_1h_input_tokens } : {}), + } + } + if (it.server_tool_use?.web_search_requests) compact.server_tool_use = { web_search_requests: it.server_tool_use.web_search_requests } + if (it.speed) compact.speed = it.speed + return compact + }) + if (advisorIterations.length > 0) compactUsage.iterations = advisorIterations + } entry.message = { type: 'message', @@ -1037,7 +1091,10 @@ export function isPositiveNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) && value > 0 } -function extractClaudeCacheCreation(usage: AssistantMessageContent['usage']): { totalTokens: number; oneHourTokens: number } { +function extractClaudeCacheCreation(usage: { + cache_creation_input_tokens?: number + cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number } +}): { totalTokens: number; oneHourTokens: number } { const legacyTotal = safeNumber(usage.cache_creation_input_tokens) const cacheCreation = usage.cache_creation const fiveMinuteTokens = safeNumber(cacheCreation?.ephemeral_5m_input_tokens) @@ -1149,6 +1206,73 @@ export function parseApiCall(entry: JournalEntry): ParsedApiCall | null { }) } +/// Claude Code's advisor tool (/advisor) escalates hard decisions to a stronger +/// advisor model mid-turn. Those tokens are recorded as `advisor_message` +/// records inside `message.usage.iterations` under the advisor's own model, and +/// are excluded from the top-level `message.usage` totals that `parseApiCall` +/// reads. Emit them as separate calls so the advisor's spend is counted and +/// attributed to the advisor model rather than silently dropped. +export function parseAdvisorCalls(entry: JournalEntry): ParsedApiCall[] { + if (entry.type !== 'assistant') return [] + const msg = entry.message as AssistantMessageContent | undefined + const iterations = msg?.usage?.iterations + if (!msg?.usage || !Array.isArray(iterations)) return [] + + const calls: ParsedApiCall[] = [] + const baseKey = msg.id ?? `claude:${entry.timestamp}` + // Ordinal among advisor entries (not the raw array index) so the dedup key is + // identical whether it is computed from the raw record (guard path) or the + // compacted record whose non-advisor iterations were dropped (report path). + let advisorOrdinal = 0 + for (const it of iterations) { + if (!it || it.type !== 'advisor_message') continue + const model = typeof it.model === 'string' && it.model ? it.model : msg.model + if (!model) continue + const index = advisorOrdinal++ + + const cacheCreation = extractClaudeCacheCreation(it) + const tokens: TokenUsage = { + inputTokens: it.input_tokens ?? 0, + outputTokens: it.output_tokens ?? 0, + cacheCreationInputTokens: cacheCreation.totalTokens, + cacheReadInputTokens: it.cache_read_input_tokens ?? 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: it.server_tool_use?.web_search_requests ?? 0, + } + const speed = it.speed ?? msg.usage.speed ?? 'standard' + const costUSD = calculateCost( + model, + tokens.inputTokens, + tokens.outputTokens, + tokens.cacheCreationInputTokens, + tokens.cacheReadInputTokens, + tokens.webSearchRequests, + speed, + cacheCreation.oneHourTokens, + ) + + calls.push(applyLocalModelSavings({ + provider: 'claude', + model, + usage: tokens, + costUSD, + tools: [], + mcpTools: [], + skills: [], + subagentTypes: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed, + timestamp: entry.timestamp ?? '', + bashCommands: [], + deduplicationKey: `${baseKey}:advisor:${index}`, + cacheCreationOneHourTokens: cacheCreation.oneHourTokens || undefined, + })) + } + return calls +} + export function dedupeStreamingMessageIds(entries: JournalEntry[]): JournalEntry[] { const firstIdxById = new Map() const lastIdxById = new Map() @@ -1203,6 +1327,7 @@ function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set): Parse if (msgId) seenMsgIds.add(msgId) const call = parseApiCall(entry) if (call) currentCalls.push(call) + for (const advisorCall of parseAdvisorCalls(entry)) currentCalls.push(advisorCall) } } diff --git a/src/session-cache.ts b/src/session-cache.ts index 7d02e9cd..45630462 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -109,7 +109,7 @@ const PROVIDER_ENV_VARS: Record = { export const DURABLE_PROVIDER_NAMES: ReadonlySet = new Set(['copilot']) const PROVIDER_PARSE_VERSIONS: Record = { - claude: 'cowork-space-grouping-v1', + claude: 'advisor-usage-v1', cline: 'worktree-project-grouping-v1', // Bump when the Codex parser changes attribution so unchanged, already-cached // session files re-parse (session-cache.json serves them without invoking the diff --git a/src/types.ts b/src/types.ts index c3cc8222..4491d8a8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,29 @@ export type ApiUsage = { web_fetch_requests?: number } speed?: 'standard' | 'fast' + // Claude Code advisor tool (/advisor): per-turn sub-usage records. A record + // with type 'advisor_message' carries the advisor model's own tokens and is + // NOT included in the top-level totals above; type 'message' records mirror + // the main model and are already covered by the top-level totals. + iterations?: ApiUsageIteration[] +} + +export type ApiUsageIteration = { + type?: string + model?: string + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_creation?: { + ephemeral_5m_input_tokens?: number + ephemeral_1h_input_tokens?: number + } + cache_read_input_tokens?: number + server_tool_use?: { + web_search_requests?: number + web_fetch_requests?: number + } + speed?: 'standard' | 'fast' } export type AssistantMessageContent = { diff --git a/tests/parser-advisor-usage.test.ts b/tests/parser-advisor-usage.test.ts new file mode 100644 index 00000000..513271e0 --- /dev/null +++ b/tests/parser-advisor-usage.test.ts @@ -0,0 +1,180 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' + +import { parseApiCall, parseAdvisorCalls, compactEntry, parseAllSessions, clearSessionCache } from '../src/parser.js' +import { calculateCost, loadPricing } from '../src/models.js' +import type { JournalEntry } from '../src/types.js' + +const MAIN_MODEL = 'claude-sonnet-4-20250514' +const ADVISOR_MODEL = 'claude-opus-4-20250514' + +// Shape mirrors a real Claude Code /advisor turn: the top-level usage covers +// only the main model, and the advisor's tokens live in an `advisor_message` +// iteration under its own model. The two `message` iterations sum to the +// top-level usage (verified against real Claude Code session data). +function advisorEntry(): JournalEntry { + return { + type: 'assistant', + timestamp: '2026-07-10T10:00:00.000Z', + sessionId: 's1', + message: { + type: 'message', + role: 'assistant', + model: MAIN_MODEL, + id: 'msg-advisor-1', + content: [], + usage: { + input_tokens: 2, + output_tokens: 491, + cache_creation_input_tokens: 7853, + cache_read_input_tokens: 226584, + iterations: [ + { type: 'message', input_tokens: 1, output_tokens: 45, cache_creation_input_tokens: 7192, cache_read_input_tokens: 109696 }, + { type: 'advisor_message', model: ADVISOR_MODEL, input_tokens: 159419, output_tokens: 7805, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + { type: 'message', input_tokens: 1, output_tokens: 446, cache_creation_input_tokens: 661, cache_read_input_tokens: 116888 }, + ], + }, + }, + } as unknown as JournalEntry +} + +describe('advisor usage parsing', () => { + beforeAll(async () => { + await loadPricing() + }) + + it('leaves the main-model call attributed to the main model and its top-level totals', () => { + const call = parseApiCall(advisorEntry()) + expect(call).not.toBeNull() + expect(call!.model).toBe(MAIN_MODEL) + expect(call!.usage.inputTokens).toBe(2) + expect(call!.usage.outputTokens).toBe(491) + expect(call!.usage.cacheReadInputTokens).toBe(226584) + expect(call!.deduplicationKey).toBe('msg-advisor-1') + }) + + it('emits a separate call for the advisor iteration, priced under the advisor model', () => { + const advisorCalls = parseAdvisorCalls(advisorEntry()) + expect(advisorCalls).toHaveLength(1) + const a = advisorCalls[0]! + expect(a.model).toBe(ADVISOR_MODEL) + expect(a.usage.inputTokens).toBe(159419) + expect(a.usage.outputTokens).toBe(7805) + expect(a.deduplicationKey).toBe('msg-advisor-1:advisor:0') + + const expectedCost = calculateCost(ADVISOR_MODEL, 159419, 7805, 0, 0, 0, 'standard', 0) + expect(a.costUSD).toBeCloseTo(expectedCost, 10) + expect(a.costUSD).toBeGreaterThan(0) + }) + + it('does not double-count: advisor tokens are absent from the main call', () => { + const call = parseApiCall(advisorEntry())! + // 159419 is the advisor input; it must never appear on the main call. + expect(call.usage.inputTokens).not.toBe(159419) + }) + + it('returns no advisor calls when iterations hold only main-model messages', () => { + const entry = advisorEntry() + // Strip the advisor_message iteration. + const usage = (entry.message as { usage: { iterations: unknown[] } }).usage + usage.iterations = usage.iterations.filter((it: unknown) => (it as { type?: string }).type !== 'advisor_message') + expect(parseAdvisorCalls(entry)).toHaveLength(0) + }) + + it('returns no advisor calls when there is no iterations array', () => { + const entry = advisorEntry() + delete (entry.message as { usage: { iterations?: unknown } }).usage.iterations + expect(parseAdvisorCalls(entry)).toHaveLength(0) + }) + + it('survives compaction: compactEntry keeps advisor iterations so the call is still emitted', () => { + const compacted = compactEntry(advisorEntry()) + const advisorCalls = parseAdvisorCalls(compacted) + expect(advisorCalls).toHaveLength(1) + expect(advisorCalls[0]!.model).toBe(ADVISOR_MODEL) + expect(advisorCalls[0]!.usage.inputTokens).toBe(159419) + expect(advisorCalls[0]!.deduplicationKey).toBe('msg-advisor-1:advisor:0') + }) +}) + +describe('advisor usage end-to-end through parseAllSessions', () => { + let tmpDir: string | null = null + const savedEnv = { config: process.env['CLAUDE_CONFIG_DIR'], cache: process.env['CODEBURN_CACHE_DIR'] } + + beforeAll(async () => { + await loadPricing() + }) + + afterEach(async () => { + clearSessionCache() + if (savedEnv.config === undefined) delete process.env['CLAUDE_CONFIG_DIR'] + else process.env['CLAUDE_CONFIG_DIR'] = savedEnv.config + if (savedEnv.cache === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = savedEnv.cache + if (tmpDir) { + await rm(tmpDir, { recursive: true, force: true }) + tmpDir = null + } + }) + + it('attributes advisor spend to the advisor model in the session breakdown', async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-advisor-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache') + const proj = join(tmpDir, 'projects', 'p') + await mkdir(proj, { recursive: true }) + const user = JSON.stringify({ type: 'user', timestamp: '2026-07-10T09:59:59.000Z', sessionId: 's1', message: { role: 'user', content: 'hi' } }) + const assistant = JSON.stringify(advisorEntry()) + await writeFile(join(proj, 's1.jsonl'), `${user}\n${assistant}\n`) + + clearSessionCache() + const projects = await parseAllSessions(undefined, 'claude') + const inputByModel: Record = {} + for (const p of projects) { + for (const s of p.sessions) { + for (const [model, b] of Object.entries(s.modelBreakdown ?? {})) { + inputByModel[model] = (inputByModel[model] ?? 0) + (b.tokens?.inputTokens ?? 0) + } + } + } + // Main model keeps only its top-level tokens; advisor model carries its own. + const advisorModelKey = Object.keys(inputByModel).find(m => m.toLowerCase().includes('opus')) + expect(advisorModelKey).toBeDefined() + expect(inputByModel[advisorModelKey!]).toBe(159419) + const mainModelKey = Object.keys(inputByModel).find(m => m.toLowerCase().includes('sonnet')) + expect(inputByModel[mainModelKey!]).toBe(2) + }) + + it('counts advisor spend even when the assistant line exceeds the large-line (32KB) threshold', async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-advisor-big-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache') + const proj = join(tmpDir, 'projects', 'p') + await mkdir(proj, { recursive: true }) + + // Pad the content past 32KB so parseJsonlLine routes to the large-line + // byte-scanner path (which previously dropped iterations entirely). + const entry = advisorEntry() + ;(entry.message as { content: unknown[] }).content = [{ type: 'text', text: 'x'.repeat(40_000) }] + const assistant = JSON.stringify(entry) + expect(Buffer.byteLength(assistant, 'utf8')).toBeGreaterThan(32 * 1024) + const user = JSON.stringify({ type: 'user', timestamp: '2026-07-10T09:59:59.000Z', sessionId: 's1', message: { role: 'user', content: 'hi' } }) + await writeFile(join(proj, 's1.jsonl'), `${user}\n${assistant}\n`) + + clearSessionCache() + const projects = await parseAllSessions(undefined, 'claude') + const inputByModel: Record = {} + for (const p of projects) { + for (const s of p.sessions) { + for (const [model, b] of Object.entries(s.modelBreakdown ?? {})) { + inputByModel[model] = (inputByModel[model] ?? 0) + (b.tokens?.inputTokens ?? 0) + } + } + } + const advisorModelKey = Object.keys(inputByModel).find(m => m.toLowerCase().includes('opus')) + expect(advisorModelKey).toBeDefined() + expect(inputByModel[advisorModelKey!]).toBe(159419) + }) +}) From 03a7ee96f87029275a285f65f4d06aa81106d6ed Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Thu, 16 Jul 2026 13:52:27 +0200 Subject: [PATCH 2/2] test(guard): pin advisor cost fold in the live budget The guard's advisor fold in computeSessionUsage had no test; dropping advisorCost from the line cost left all 30 guard tests green. Pins the fold (advisor line costs more than the same line without iterations) and the per-message replace semantics under streaming rewrites. --- tests/guard-hooks.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/guard-hooks.test.ts b/tests/guard-hooks.test.ts index ef5d6b21..8ac69f3d 100644 --- a/tests/guard-hooks.test.ts +++ b/tests/guard-hooks.test.ts @@ -88,6 +88,31 @@ describe('incremental session cache', () => { expect(r2.cache.costUSD).toBeGreaterThan(r1.cache.costUSD) }) + it('folds advisor (/advisor) iteration cost into the session total', async () => { + const plain = await transcript([assistantLine('a')]) + const plainCost = (await computeSessionUsage(emptyCache(SID), plain)).cache.costUSD + + // Same line plus an advisor_message iteration: the advisor's tokens live + // outside the top-level usage, so the guard total must exceed the plain + // line by the advisor call's own cost. + const line = JSON.parse(assistantLine('a')) as { message: { usage: Record } } + line.message.usage['iterations'] = [ + { type: 'message', input_tokens: 1_000_000, output_tokens: 200_000 }, + { type: 'advisor_message', model: 'claude-opus-4-20250514', input_tokens: 500_000, output_tokens: 50_000 }, + ] + const withAdvisor = await transcript([JSON.stringify(line) + '\n']) + const advisorTotal = (await computeSessionUsage(emptyCache(SID), withAdvisor)).cache.costUSD + + expect(plainCost).toBeGreaterThan(0) + expect(advisorTotal).toBeGreaterThan(plainCost) + + // Streaming rewrite of the same message id replaces, not adds: the fold + // stays stable when the advisor-carrying line is written twice. + const rewritten = await transcript([JSON.stringify(line) + '\n', JSON.stringify(line) + '\n']) + const rewrittenTotal = (await computeSessionUsage(emptyCache(SID), rewritten)).cache.costUSD + expect(rewrittenTotal).toBeCloseTo(advisorTotal, 10) + }) + it('resets to a cold parse when the transcript shrinks (rotation)', async () => { const base = await tmp() const path = await transcript([assistantLine('a'), assistantLine('b')])