Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions src/guard/usage.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
127 changes: 126 additions & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from './session-cache.js'
import type { ParsedProviderCall, SessionSource } from './providers/types.js'
import type {
ApiUsageIteration,
AssistantMessageContent,
ClassifiedTurn,
ContentBlock,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, number>()
const lastIdxById = new Map<string, number>()
Expand Down Expand Up @@ -1203,6 +1327,7 @@ function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>): Parse
if (msgId) seenMsgIds.add(msgId)
const call = parseApiCall(entry)
if (call) currentCalls.push(call)
for (const advisorCall of parseAdvisorCalls(entry)) currentCalls.push(advisorCall)
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const PROVIDER_ENV_VARS: Record<string, string[]> = {
export const DURABLE_PROVIDER_NAMES: ReadonlySet<string> = new Set(['copilot'])

const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
claude: 'cowork-space-grouping-v1',
claude: 'advisor-usage-v1',
cline: 'worktree-project-grouping-v1',
codewhale: 'aggregate-session-v1',
// Bump when the Codex parser changes attribution so unchanged, already-cached
Expand Down
23 changes: 23 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
25 changes: 25 additions & 0 deletions tests/guard-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> } }
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')])
Expand Down
Loading