use serde_json::Value;
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use crate::{
apps::{fresh_input, normalize_model},
error::AppError,
io::{load, progress::Progress},
model::{AppKind, UsageEntry},
};
const SESSIONS_MAX_DEPTH: usize = 3;
const TOKEN_FIELDS: [&str; 7] = [
"input_tokens",
"cached_input_tokens",
"cache_read_input_tokens",
"cache_write_input_tokens",
"output_tokens",
"reasoning_output_tokens",
"total_tokens",
];
pub fn collect() -> Result<Vec<UsageEntry>, AppError> {
let base = load::home_dir()?.join(".codex");
if !base.is_dir() {
return Ok(Vec::new());
}
collect_from(&base)
}
pub fn collect_from(base: &Path) -> Result<Vec<UsageEntry>, AppError> {
let mut files = load::discover_files(&base.join("sessions"), "jsonl", SESSIONS_MAX_DEPTH);
files.extend(load::discover_files(
&base.join("archived_sessions"),
"jsonl",
0,
));
files.retain(|f| is_rollout_filename(f));
let files = dedupe_by_filename(files);
let mut parsed: Vec<ParsedFile> = Vec::with_capacity(files.len());
let mut progress = Progress::start("codex", load::total_bytes(&files));
for file in &files {
progress.set_file(load::file_name_str(file));
match parse_file(file, &mut progress) {
Ok(parsed_file) => parsed.push(parsed_file),
Err(e) => load::warn_file(&e),
}
}
progress.finish();
let timelines = build_timelines(&parsed);
let mut entries = Vec::new();
for file in &parsed {
emit_entries(file, &timelines, &mut entries);
}
Ok(entries)
}
fn dedupe_by_filename(files: Vec<PathBuf>) -> Vec<PathBuf> {
let mut best: HashMap<String, (u64, bool, PathBuf)> = HashMap::new();
for path in files {
let Some(name) = path
.file_name()
.and_then(|n| n.to_str())
.map(str::to_string)
else {
continue;
};
let Ok(size) = std::fs::metadata(&path).map(|m| m.len()) else {
continue;
};
let is_live = path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
!= Some("archived_sessions");
let should_replace = match best.get(&name) {
Some((kept_size, kept_live, _)) => (size, is_live) > (*kept_size, *kept_live),
None => true,
};
if should_replace {
best.insert(name, (size, is_live, path));
}
}
let mut paths: Vec<PathBuf> = best.into_values().map(|(_, _, path)| path).collect();
paths.sort();
paths
}
fn is_rollout_filename(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| name.starts_with("rollout-") && name.ends_with(".jsonl"))
}
#[derive(Clone, Copy, PartialEq, Eq)]
struct Signature([u64; 12]);
#[derive(Clone, Copy, Default)]
struct Counters {
input: u64,
cached: u64,
cache_write: u64,
output: u64,
reasoning: u64,
total: u64,
}
impl Counters {
fn parse(info: &Value, key: &str) -> Option<Self> {
let obj = info.get(key)?.as_object()?;
if !TOKEN_FIELDS.iter().any(|f| obj.contains_key(*f)) {
return None;
}
Some(Self {
input: obj.get("input_tokens").and_then(Value::as_u64).unwrap_or(0),
cached: obj
.get("cached_input_tokens")
.and_then(Value::as_u64)
.or_else(|| obj.get("cache_read_input_tokens").and_then(Value::as_u64))
.unwrap_or(0),
cache_write: obj
.get("cache_write_input_tokens")
.and_then(Value::as_u64)
.unwrap_or(0),
output: obj
.get("output_tokens")
.and_then(Value::as_u64)
.unwrap_or(0),
reasoning: obj
.get("reasoning_output_tokens")
.and_then(Value::as_u64)
.unwrap_or(0),
total: obj.get("total_tokens").and_then(Value::as_u64).unwrap_or(0),
})
}
fn billable(&self) -> [u64; 4] {
[self.input, self.cached, self.cache_write, self.output]
}
fn fields(self) -> [u64; 6] {
[
self.input,
self.cached,
self.cache_write,
self.output,
self.reasoning,
self.total,
]
}
}
fn signature(total: Option<Counters>, last: Option<Counters>) -> Signature {
let mut fields = [0u64; 12];
if let Some(t) = total {
fields[..6].copy_from_slice(&t.fields());
}
if let Some(l) = last {
fields[6..].copy_from_slice(&l.fields());
}
Signature(fields)
}
enum ParentLink {
None,
Parent(String),
Conflicted,
}
struct MetaInfo {
thread_id: Option<String>,
parent: ParentLink,
ts: Option<i64>,
}
struct TokenEvent {
signature: Signature,
delta: Option<[u64; 4]>,
ts: Option<i64>,
model: String,
created_at: i64,
}
struct ParsedFile {
file: PathBuf,
meta: Option<MetaInfo>,
identity_conflict: bool,
events: Vec<TokenEvent>,
}
#[derive(Default)]
struct ParseState {
model: Option<String>,
high_water: Option<[u64; 4]>,
last_by_source: HashMap<Option<String>, Signature>,
previous: Option<Signature>,
}
const CODEX_LINE_NEEDLES: [&str; 3] = ["\"session_meta\"", "\"turn_context\"", "\"token_count\""];
fn parse_file(file: &Path, progress: &mut Progress) -> Result<ParsedFile, AppError> {
let mut meta: Option<MetaInfo> = None;
let mut state = ParseState::default();
let mut events = Vec::new();
load::for_each_jsonl_progress(file, &CODEX_LINE_NEEDLES, progress, |line| {
match load::str_get(&line, &["type"]) {
Some("session_meta") if meta.is_none() => {
meta = Some(MetaInfo {
thread_id: ["id", "thread_id", "threadId", "session_id"]
.iter()
.find_map(|k| load::str_get(&line, &["payload", k]))
.map(normalize_thread_id),
parent: line
.get("payload")
.map_or(ParentLink::None, explicit_parent_from_meta),
ts: line.get("timestamp").and_then(load::timestamp_to_epoch),
});
}
Some("turn_context") => {
if let Some(m) = load::str_get(&line, &["payload", "model"])
.or_else(|| load::str_get(&line, &["payload", "info", "model"]))
{
state.model = Some(normalize_model(m));
}
}
Some("event_msg") => parse_token_count(&line, &mut state, &mut events),
_ => {}
}
true
})?;
let tail = thread_id_from_filename(file);
let leading = leading_thread_id_from_filename(file);
let identity_conflict = meta
.as_ref()
.and_then(|m| m.thread_id.as_deref())
.is_some_and(|id| {
(tail.is_some() || leading.is_some())
&& tail.as_deref() != Some(id)
&& leading.as_deref() != Some(id)
});
Ok(ParsedFile {
file: file.to_path_buf(),
meta,
identity_conflict,
events,
})
}
fn parse_token_count(line: &Value, state: &mut ParseState, events: &mut Vec<TokenEvent>) {
if load::str_get(line, &["payload", "type"]) != Some("token_count") {
return;
}
let Some(info) = line.pointer("/payload/info") else {
return;
};
let total = Counters::parse(info, "total_token_usage");
let last = Counters::parse(info, "last_token_usage");
if total.is_none() && last.is_none() {
return;
}
let signature = signature(total, last);
if let Some(m) = load::str_get(info, &["model"])
.or_else(|| load::str_get(info, &["model_name"]))
.or_else(|| load::str_get(line, &["payload", "model"]))
{
state.model = Some(normalize_model(m));
}
let source = load::str_get(line, &["payload", "rate_limits", "limit_id"])
.or_else(|| load::str_get(info, &["rate_limits", "limit_id"]))
.filter(|s| !s.is_empty())
.map(str::to_string);
let duplicate = total.is_some()
&& (state.last_by_source.get(&source) == Some(&signature)
|| state.previous.as_ref() == Some(&signature));
if total.is_some() {
state.last_by_source.insert(source, signature);
}
state.previous = Some(signature);
let delta = if duplicate {
None
} else {
match last {
Some(l) => Some(l.billable()),
None => Some(delta_from_total(
state.high_water,
total.as_ref().unwrap_or(&Counters::default()),
)),
}
};
if let Some(t) = &total {
advance_high_water(&mut state.high_water, t);
}
let delta = delta.map(|mut d| {
d[1] = d[1].min(d[0]);
d
});
let ts = line.get("timestamp").and_then(load::timestamp_to_epoch);
events.push(TokenEvent {
signature,
delta,
ts,
model: state.model.clone().unwrap_or_else(|| "unknown".to_string()),
created_at: ts.unwrap_or_else(load::now_epoch),
});
}
fn delta_from_total(high_water: Option<[u64; 4]>, counters: &Counters) -> [u64; 4] {
let current = counters.billable();
let base = high_water.unwrap_or([0; 4]);
std::array::from_fn(|i| current[i].saturating_sub(base[i]))
}
fn advance_high_water(high_water: &mut Option<[u64; 4]>, counters: &Counters) {
let current = counters.billable();
let base = high_water.get_or_insert([0; 4]);
*base = std::array::from_fn(|i| base[i].max(current[i]));
}
fn explicit_parent_from_meta(payload: &Value) -> ParentLink {
let forked = payload
.get("forked_from_id")
.and_then(nonempty_str)
.map(normalize_thread_id);
let spawned = payload
.get("source")
.and_then(|s| s.get("subagent"))
.and_then(|s| s.get("thread_spawn"))
.and_then(|s| s.get("parent_thread_id"))
.and_then(nonempty_str)
.map(normalize_thread_id);
match (forked, spawned) {
(None, None) => ParentLink::None,
(Some(parent), None) | (None, Some(parent)) => ParentLink::Parent(parent),
(Some(forked), Some(spawned)) if forked == spawned => ParentLink::Parent(forked),
_ => ParentLink::Conflicted,
}
}
fn nonempty_str(value: &Value) -> Option<&str> {
value.as_str().filter(|s| !s.is_empty())
}
#[derive(Default)]
struct ParentTimeline {
events: Vec<TimelineEvent>,
has_untimed: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
struct TimelineEvent {
ts: i64,
signature: Signature,
}
fn build_timelines(parsed: &[ParsedFile]) -> HashMap<String, Vec<ParentTimeline>> {
let mut timelines: HashMap<String, Vec<ParentTimeline>> = HashMap::new();
for file in parsed {
let Some(uuid) = thread_id_from_filename(&file.file) else {
continue;
};
let mut timeline = ParentTimeline::default();
for event in &file.events {
match event.ts {
Some(ts) => timeline.events.push(TimelineEvent {
ts,
signature: event.signature,
}),
None => timeline.has_untimed = true,
}
}
timelines.entry(uuid).or_default().push(timeline);
}
timelines
}
#[derive(Clone, Copy)]
enum Replay {
Skip,
Prefix(usize),
}
fn replay_prefix(
meta: &MetaInfo,
events: &[TokenEvent],
timelines: &HashMap<String, Vec<ParentTimeline>>,
) -> Replay {
let parent_id = match &meta.parent {
ParentLink::None => return Replay::Prefix(0),
ParentLink::Parent(id) if meta.thread_id.as_deref() == Some(id.as_str()) => {
return Replay::Skip;
}
ParentLink::Parent(id) => id,
ParentLink::Conflicted => return Replay::Skip,
};
let Some(cutoff) = meta.ts else {
return Replay::Skip;
};
let Some(candidates) = timelines.get(parent_id) else {
return Replay::Skip;
};
for timeline in candidates {
if timeline.has_untimed {
return Replay::Skip;
}
let max_ts = timeline.events.iter().map(|e| e.ts).max();
if max_ts.is_none_or(|max| max < cutoff) {
return Replay::Skip;
}
}
let filtered = |timeline: &ParentTimeline| {
timeline
.events
.iter()
.filter(|e| e.ts <= cutoff)
.map(|e| e.signature)
.collect::<Vec<_>>()
};
let Some(first) = candidates.first() else {
return Replay::Skip;
};
let signatures = filtered(first);
if candidates
.iter()
.any(|timeline| filtered(timeline) != signatures)
{
return Replay::Skip;
}
Replay::Prefix(matching_replay_prefix(events, &signatures))
}
fn matching_replay_prefix(child: &[TokenEvent], parent: &[Signature]) -> usize {
let mut offset = 0usize;
let mut matched = 0usize;
for event in child {
let Some(relative) = parent[offset..]
.iter()
.position(|signature| *signature == event.signature)
else {
break;
};
offset += relative + 1;
matched += 1;
}
matched
}
fn emit_entries(
parsed: &ParsedFile,
timelines: &HashMap<String, Vec<ParentTimeline>>,
entries: &mut Vec<UsageEntry>,
) {
let Some(meta) = &parsed.meta else {
return;
};
if parsed.identity_conflict {
return;
}
let Replay::Prefix(prefix) = replay_prefix(meta, &parsed.events, timelines) else {
return;
};
for event in &parsed.events[prefix..] {
let Some([input, cached, write, output]) = event.delta else {
continue; };
if input == 0 && cached == 0 && write == 0 && output == 0 {
continue;
}
let input = fresh_input(input, cached, write);
entries.push(UsageEntry::new(
AppKind::Codex,
event.model.clone(),
meta.thread_id.clone(),
event.created_at,
input,
output,
cached,
write,
None,
));
}
}
fn thread_id_from_filename(path: &Path) -> Option<String> {
let stem = path.file_stem()?.to_str()?;
let candidate = stem.get(stem.len().checked_sub(36)?..)?;
is_uuid_like(candidate).then(|| normalize_thread_id(candidate))
}
fn leading_thread_id_from_filename(path: &Path) -> Option<String> {
let stem = path.file_stem()?.to_str()?;
let len = stem.len();
if !stem.get(len.checked_sub(37)?..)?.starts_with('_') {
return None;
}
let candidate = stem.get(len.checked_sub(73)?..len.checked_sub(37)?)?;
is_uuid_like(candidate).then(|| normalize_thread_id(candidate))
}
fn normalize_thread_id(s: &str) -> String {
if is_uuid_like(s) {
s.to_ascii_lowercase()
} else {
s.to_string()
}
}
fn is_uuid_like(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 36
&& b.iter().enumerate().all(|(i, &c)| match i {
8 | 13 | 18 | 23 => c == b'-',
_ => c.is_ascii_hexdigit(),
})
}
#[cfg(test)]
#[path = "tests/codex_test.rs"]
mod tests;