Skip to content

Commit b25e217

Browse files
committed
feat(cli): add gemini backfill support
1 parent aaccc4c commit b25e217

9 files changed

Lines changed: 1083 additions & 26 deletions

File tree

packages/cli/src/adapters/codex.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ async function parseCodexSessionFile(
4848
let cwd: string | undefined
4949
let project: string | undefined
5050
let model: string | undefined
51+
// Headless `codex exec` files have no session_meta; emit a synthetic
52+
// session.started on the first usage line so rollups still get a boundary.
53+
let sessionStartEmitted = false
5154
// Per-turn service tier. Updated whenever a turn_context (or future per-turn
5255
// event) carries an explicit service_tier; never inferred from config.toml.
5356
let serviceTier: string | undefined
@@ -104,6 +107,7 @@ async function parseCodexSessionFile(
104107
model,
105108
confidence: 'exact',
106109
}, { filePath, sourcePathHash, lineNumber, topType, payloadType: 'session_meta', options }))
110+
sessionStartEmitted = true
107111
continue
108112
}
109113

@@ -123,6 +127,40 @@ async function parseCodexSessionFile(
123127
continue
124128
}
125129

130+
// Headless `codex exec` output: turn.completed / result / bare {data:{usage}}
131+
// lines carry usage directly, with no session_meta/event_msg wrapper.
132+
// Mirrors ccusage's headless parser (adapter/codex/parser.rs).
133+
if (topType === 'turn.completed' || topType === 'result' || topType === undefined) {
134+
const usage = headlessCodexUsage(raw)
135+
if (usage) {
136+
const parsedModel = headlessCodexModel(raw)
137+
if (parsedModel) {
138+
model = parsedModel
139+
}
140+
const eventModel = parsedModel || model || 'gpt-5'
141+
const headlessTs = headlessCodexTimestamp(raw) || ts
142+
if (!sessionStartEmitted) {
143+
events.push(withBackfillRefs(baseCodexEvent({
144+
ts: headlessTs,
145+
type: 'session.started',
146+
sessionId,
147+
model: eventModel,
148+
confidence: 'derived',
149+
}), { filePath, sourcePathHash, lineNumber, topType: topType || 'result', payloadType: 'session', options }))
150+
sessionStartEmitted = true
151+
}
152+
events.push(withBackfillRefs(baseCodexEvent({
153+
ts: headlessTs,
154+
type: 'model.usage',
155+
sessionId,
156+
model: eventModel,
157+
confidence: 'partial',
158+
metrics: usage,
159+
}), { filePath, sourcePathHash, lineNumber, topType: topType || 'result', payloadType: 'headless', options }))
160+
}
161+
continue
162+
}
163+
126164
if (topType !== 'event_msg' && topType !== 'response_item') {
127165
continue
128166
}
@@ -442,6 +480,74 @@ function baseCodexEvent(
442480
}
443481
}
444482

483+
// ── Headless codex exec (turn.completed / result / bare data.usage) ──
484+
485+
// Pull usage from the top-level object or a nested data/result/response wrapper,
486+
// honoring the field aliases ccusage accepts (prompt/completion/cached_tokens).
487+
function headlessCodexUsage(raw: Record<string, unknown>) {
488+
const usage = [
489+
objectField(raw, 'usage'),
490+
objectField(objectField(raw, 'data'), 'usage'),
491+
objectField(objectField(raw, 'result'), 'usage'),
492+
objectField(objectField(raw, 'response'), 'usage'),
493+
].find(candidate => Object.keys(candidate).length > 0)
494+
if (!usage) {
495+
return
496+
}
497+
498+
const input = numberField(usage, 'input_tokens') ?? numberField(usage, 'prompt_tokens') ?? 0
499+
const output = numberField(usage, 'output_tokens') ?? numberField(usage, 'completion_tokens') ?? 0
500+
const cached = numberField(usage, 'cached_input_tokens')
501+
?? numberField(usage, 'cache_read_input_tokens')
502+
?? numberField(usage, 'cached_tokens')
503+
?? 0
504+
const reasoning = numberField(usage, 'reasoning_output_tokens') ?? numberField(usage, 'reasoning_tokens') ?? 0
505+
const totalField = numberField(usage, 'total_tokens')
506+
// ccusage keeps total_tokens only when positive (or everything is zero),
507+
// otherwise recomputes from input+output+reasoning — cache is NOT added.
508+
const total = totalField !== undefined && (totalField > 0 || input + output + reasoning === 0)
509+
? totalField
510+
: input + output + reasoning
511+
512+
if (input === 0 && cached === 0 && output === 0 && reasoning === 0 && total === 0) {
513+
return
514+
}
515+
516+
return {
517+
tokensInput: input || undefined,
518+
tokensCachedInput: cached || undefined,
519+
tokensOutput: output || undefined,
520+
tokensReasoningOutput: reasoning || undefined,
521+
tokensTotal: total,
522+
modelCalls: 1,
523+
}
524+
}
525+
526+
function headlessCodexModel(raw: Record<string, unknown>): string | undefined {
527+
for (const source of [raw, objectField(raw, 'data'), objectField(raw, 'result'), objectField(raw, 'response')]) {
528+
const model = stringField(source, 'model') ?? stringField(source, 'model_name')
529+
if (model) {
530+
return model
531+
}
532+
}
533+
return undefined
534+
}
535+
536+
function headlessCodexTimestamp(raw: Record<string, unknown>): string | undefined {
537+
const data = objectField(raw, 'data')
538+
const result = objectField(raw, 'result')
539+
const response = objectField(raw, 'response')
540+
for (const source of [raw, data, result, response]) {
541+
const ts = timestampFrom(source.timestamp)
542+
?? timestampFrom(source.created_at)
543+
?? timestampFrom(source.createdAt)
544+
if (ts) {
545+
return ts
546+
}
547+
}
548+
return undefined
549+
}
550+
445551
export function tokenUsageFromPayload(payload: Record<string, unknown>) {
446552
const info = objectField(payload, 'info')
447553
const usage = objectField(info, 'last_token_usage')

0 commit comments

Comments
 (0)