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
15 changes: 14 additions & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { existsSync } from 'fs'
import { lstat, readFile, readdir, stat } from 'fs/promises'
import { basename, dirname, join, resolve, sep } from 'path'
import { readSessionLines } from './fs-utils.js'
Expand Down Expand Up @@ -2040,7 +2041,19 @@ function getOrCreateProviderSection(cache: SessionCache, provider: string): Prov
const envFp = computeEnvFingerprint(provider)
const existing = cache.providers[provider]
if (existing && existing.envFingerprint === envFp) return existing
const section = { envFingerprint: envFp, files: {} }
const section: ProviderSection = { envFingerprint: envFp, files: {} }
// A fingerprint change (env override or parse-version bump) must re-parse
// every present source, but for durable providers the cache is the ONLY
// remaining record of usage whose source rows were already pruned (OTel
// orphans). Discarding those with the section would permanently erase
// month-to-date history that cannot be re-derived, so carry forward exactly
// the entries whose source no longer exists; everything present on disk is
// dropped and re-parsed under the new fingerprint.
if (existing && DURABLE_PROVIDER_NAMES.has(provider)) {
for (const [path, file] of Object.entries(existing.files)) {
if (!existsSync(path)) section.files[path] = file
}
}
cache.providers[provider] = section
return section
}
Expand Down
96 changes: 96 additions & 0 deletions src/providers/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,28 @@ type SubagentSelectedData = {
tools?: string[]
}

// Per-model usage rollup the CLI writes into session.shutdown. inputTokens is
// cache-INCLUSIVE (input + cache_read + cache_write); see the shutdown handler.
type ShutdownModelUsage = {
inputTokens?: number
outputTokens?: number
cacheReadTokens?: number
cacheWriteTokens?: number
reasoningTokens?: number
}

type SessionShutdownData = {
modelMetrics?: Record<string, { usage?: ShutdownModelUsage }>
sessionStartTime?: number
}

type CopilotEvent =
| { type: 'session.start'; data: SessionStartData; timestamp?: string }
| { type: 'session.model_change'; data: ModelChangeData; timestamp?: string }
| { type: 'user.message'; data: UserMessageData; timestamp?: string }
| { type: 'assistant.message'; data: AssistantMessageData; timestamp?: string }
| { type: 'subagent.selected'; data: SubagentSelectedData; timestamp?: string }
| { type: 'session.shutdown'; data: SessionShutdownData; timestamp?: string }

type ChatJournalPathSegment = string | number
type ChatSessionRequest = Record<string, unknown>
Expand Down Expand Up @@ -722,13 +738,19 @@ function createJsonlParser(
if (!currentModel) return // no toolCallIds to infer model from
}

// Shutdown rollups may lack their own timestamp; remember the last
// stamped event so the supplementary call is never left with an empty
// timestamp, which the date-range filters silently drop.
let lastEventTimestamp = ''

for (const line of lines) {
let event: CopilotEvent
try {
event = JSON.parse(line) as CopilotEvent
} catch {
continue
}
if (typeof event.timestamp === 'string' && event.timestamp) lastEventTimestamp = event.timestamp

if (event.type === 'session.start') {
if (!isTranscript) {
Expand All @@ -752,6 +774,80 @@ function createJsonlParser(
continue
}

if (event.type === 'session.shutdown') {
// The Copilot CLI writes a per-model token/cost rollup here at
// shutdown: the only place a CLI session records input, cache-read
// and cache-write tokens (assistant.message events carry output
// only). VS Code transcripts never carry this rollup, so this path
// is gated to the CLI (non-transcript) format, leaving VS Code,
// JetBrains and OTel sources untouched.
//
// We emit one supplementary call per model carrying ONLY the
// input/cache tokens the per-turn events lack; output is excluded so
// the assistant.message output (and its cost) is not double-counted.
// Combined with the per-turn output cost, this yields the full,
// CLI-measured session cost.
if (isTranscript) continue
const shutdownData = event.data as SessionShutdownData
const modelMetrics = shutdownData.modelMetrics
if (!isRecord(modelMetrics)) continue

const shutdownTimestamp =
(event.timestamp ?? '') || timestampToISO(shutdownData.sessionStartTime) || lastEventTimestamp

for (const [model, metrics] of Object.entries(modelMetrics)) {
if (!model || !isRecord(metrics)) continue
const usage = metrics['usage']
if (!isRecord(usage)) continue

const cacheReadTokens = numberOrZero(usage['cacheReadTokens'])
const cacheWriteTokens = numberOrZero(usage['cacheWriteTokens'])
const reasoningTokens = numberOrZero(usage['reasoningTokens'])
// usage.inputTokens is cache-INCLUSIVE (input + cache_read +
// cache_write). calculateCost expects the uncached input alone with
// cache tokens billed separately, so subtract the cache components.
// Clamp at 0 in case a future schema reports input non-inclusively.
const inputTokens = Math.max(
0,
numberOrZero(usage['inputTokens']) - cacheReadTokens - cacheWriteTokens
)

// Nothing this call would add over the per-turn events, so skip it
// to avoid an empty $0 row (output is intentionally excluded).
if (inputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) continue

const dedupKey = `copilot:${sessionId}:shutdown:${model}`
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)

// Tokens are real counts written by the CLI, so this cost is
// measured, not char-estimated: costIsEstimated is false.
const costUSD = calculateCost(model, inputTokens, 0, cacheWriteTokens, cacheReadTokens, 0)

yield {
provider: 'copilot',
sessionId,
model,
inputTokens,
outputTokens: 0,
cacheCreationInputTokens: cacheWriteTokens,
cacheReadInputTokens: cacheReadTokens,
cachedInputTokens: 0,
reasoningTokens,
webSearchRequests: 0,
costUSD,
costIsEstimated: false,
tools: [],
bashCommands: [],
timestamp: shutdownTimestamp,
speed: 'standard' as const,
deduplicationKey: dedupKey,
userMessage: '',
}
}
continue
}

if (event.type === 'assistant.message') {
const msgData = event.data as AssistantMessageData
const { messageId, model: msgModel, outputTokens = 0 } = msgData
Expand Down
2 changes: 1 addition & 1 deletion src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
codex: 'mcp-attribution-v2',
cursor: 'composer-anchored-crediting-v1',
'cursor-agent': 'workspaceless-transcript-v1',
copilot: 'otel-durable-v1',
copilot: 'cli-shutdown-cost-v1',
hermes: 'reasoning-output-accounting-v1',
'lingtai-tui': 'token-ledger-registry-activity-v3',
'ibm-bob': 'worktree-project-grouping-v1',
Expand Down
43 changes: 43 additions & 0 deletions tests/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,46 @@ describe('(e) 90-day age-out for durable providers', () => {
expect(totalOutput(proj2)).toBe(7)
})
})

// ═══════════════════════════════════════════════════════════════════════════
// (f) Version-bump survival: a PROVIDER_PARSE_VERSIONS bump (or any env
// fingerprint change) must NOT erase durable orphans. The cache is the
// only remaining record of usage whose source was pruned; discarding the
// section wholesale on fingerprint mismatch permanently lost that history
// (caught in the #684 re-review).
// ═══════════════════════════════════════════════════════════════════════════
describe('(f) durable orphans survive a parse-version bump', () => {
it('keeps counting a pruned-source orphan after the provider fingerprint changes', async () => {
const sessionStateDir = join(tmpHome, 'session-state')
await mkdir(sessionStateDir, { recursive: true })
vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1')
vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpHome, 'no-ws'))

// Parse once so the session is cached, then prune the source: the cache
// entry becomes a durable orphan (its only record).
const eventsPath = await createJsonlSession(sessionStateDir, 'sess-bump', 200)
const before = totalOutput(await parseAllSessions(undefined, 'copilot'))
expect(before).toBe(200)
await unlink(eventsPath)
clearSessionCache()

// Simulate the fingerprint a PREVIOUS release computed (any mismatching
// value takes the same code path as a real parse-version bump).
const { readFile, writeFile: writeFileFs } = await import('fs/promises')
const cachePath = join(tmpCache, 'session-cache.json')
const disk = JSON.parse(await readFile(cachePath, 'utf-8')) as { providers: Record<string, { envFingerprint: string }> }
expect(disk.providers['copilot']).toBeDefined()
disk.providers['copilot']!.envFingerprint = '0000000000000000'
await writeFileFs(cachePath, JSON.stringify(disk), 'utf-8')

// First parse after the "upgrade": the orphan must still be counted and
// must survive in the rewritten cache, not be erased with the section.
const after = totalOutput(await parseAllSessions(undefined, 'copilot'))
expect(after).toBe(200)

clearSessionCache()
const again = totalOutput(await parseAllSessions(undefined, 'copilot'))
expect(again).toBe(200)
})
})
Loading