Files
orca/src/main/codex-usage/codex-usage-record-parser.ts
T
NeilandJio Kim 8e601e338a fix(codex-usage): count fork-copied Codex token events once (#33478)
Codex usage no longer double-counts forked sessions: forks copy the parent's token_count events with fresh timestamps, so cumulative events are now keyed on turn id + totals (timestamp fallback when no turn id); the turn id is kept in the incremental-scan checkpoint and the usage cache is rebuilt once.

Fixes #19139
Supersedes #19152

Co-authored-by: Jio Kim <26668612+MeroZemory@users.noreply.github.com>
2026-10-25 01:53:58 -07:00

173 lines
5.2 KiB
TypeScript

import { extractString } from '../usage/usage-record-coercion'
import {
buildCodexUsageEventKey,
normalizeRawUsage,
resolveCodexUsageDelta,
type CodexUsageRawUsage
} from './codex-usage-token-delta'
import { LONG_CONTEXT_THRESHOLD_TOKENS } from './codex-model-pricing'
import type { CodexUsageParsedEvent } from './types'
type CodexUsageRawRecord = {
timestamp?: string
type?: string
payload?: Record<string, unknown>
}
export type CodexUsageParseContext = {
sessionId: string
sessionCwd: string | null
currentCwd: string | null
currentModel: string | null
currentTurnId?: string | null
previousTotals: CodexUsageRawUsage | null
totalOnlyBaselinePending?: boolean
}
function extractModel(value: unknown): string | null {
if (value == null || typeof value !== 'object') {
return null
}
const record = value as Record<string, unknown>
const direct = [extractString(record.model), extractString(record.model_name)].find(
(candidate) => candidate !== null
)
if (direct) {
return direct
}
if (record.info && typeof record.info === 'object') {
const info = record.info as Record<string, unknown>
const infoDirect = [extractString(info.model), extractString(info.model_name)].find(
(candidate) => candidate !== null
)
if (infoDirect) {
return infoDirect
}
if (info.metadata && typeof info.metadata === 'object') {
const metadata = info.metadata as Record<string, unknown>
const metadataModel = extractString(metadata.model)
if (metadataModel) {
return metadataModel
}
}
}
if (record.metadata && typeof record.metadata === 'object') {
const metadata = record.metadata as Record<string, unknown>
return extractString(metadata.model)
}
return null
}
export function parseCodexUsageRecord(
line: string,
context: CodexUsageParseContext
): CodexUsageParsedEvent | null {
let parsed: CodexUsageRawRecord
try {
parsed = JSON.parse(line) as CodexUsageRawRecord
} catch {
return null
}
if (!parsed.type || !parsed.payload) {
return null
}
if (parsed.type === 'session_meta') {
context.sessionId = extractString(parsed.payload.id) ?? context.sessionId
context.sessionCwd = extractString(parsed.payload.cwd)
if (!context.currentCwd && context.sessionCwd) {
context.currentCwd = context.sessionCwd
}
return null
}
if (parsed.type === 'turn_context') {
context.currentTurnId = extractString(parsed.payload.turn_id)
context.currentCwd =
extractString(parsed.payload.cwd) ?? context.currentCwd ?? context.sessionCwd
context.currentModel = extractModel(parsed.payload) ?? context.currentModel
return null
}
if (parsed.type !== 'event_msg' || parsed.payload.type !== 'token_count' || !parsed.timestamp) {
return null
}
const info = parsed.payload.info
if (info == null || typeof info !== 'object') {
// Rate-limit updates legitimately emit token_count records with null info.
return null
}
const record = info as Record<string, unknown>
const totalUsage = normalizeRawUsage(record.total_token_usage)
const lastUsage = normalizeRawUsage(record.last_token_usage)
if (context.totalOnlyBaselinePending) {
context.totalOnlyBaselinePending = false
if (totalUsage && !lastUsage && !context.previousTotals) {
context.previousTotals = totalUsage
return null
}
}
const resolvedUsage = resolveCodexUsageDelta(totalUsage, lastUsage, context.previousTotals)
if (!resolvedUsage) {
return null
}
if (resolvedUsage.kind === 'baseline') {
context.previousTotals = resolvedUsage.nextTotals
return null
}
const delta = {
...resolvedUsage.delta,
cachedInputTokens: Math.min(
resolvedUsage.delta.cachedInputTokens,
resolvedUsage.delta.inputTokens
)
}
if (
delta.inputTokens === 0 &&
delta.cachedInputTokens === 0 &&
delta.outputTokens === 0 &&
delta.reasoningOutputTokens === 0 &&
delta.totalTokens === 0
) {
return null
}
context.previousTotals = resolvedUsage.nextTotals
// Price long context per request; total-only deltas may span several requests.
const isLongContext = lastUsage !== null && delta.inputTokens > LONG_CONTEXT_THRESHOLD_TOKENS
const resolvedModel = extractModel(parsed.payload) ?? context.currentModel
const hasInferredPricing = resolvedModel === null
return {
sessionId: context.sessionId,
timestamp: parsed.timestamp,
eventKey: buildCodexUsageEventKey(
// Fork copies rewrite timestamps but keep turn ids; last-only deltas need the timestamp to stay distinct.
totalUsage && context.currentTurnId ? context.currentTurnId : parsed.timestamp,
totalUsage,
lastUsage
),
cwd: context.currentCwd ?? context.sessionCwd,
model: resolvedModel,
hasInferredPricing,
inputTokens: delta.inputTokens,
cachedInputTokens: delta.cachedInputTokens,
outputTokens: delta.outputTokens,
reasoningOutputTokens: delta.reasoningOutputTokens,
totalTokens: delta.totalTokens,
longContextInputTokens: isLongContext ? delta.inputTokens : 0,
longContextCachedInputTokens: isLongContext ? delta.cachedInputTokens : 0,
longContextOutputTokens: isLongContext ? delta.outputTokens : 0
}
}