From 7511f1253a5de805cb5cdc572d10af6f0a16f220 Mon Sep 17 00:00:00 2001 From: lostmartian Date: Mon, 19 Oct 2026 08:26:48 +0530 Subject: [PATCH] feat(sessions): read OpenAI Codex CLI sessions Parse Codex rollouts (~/.codex/sessions, archived_sessions, $CODEX_HOME) from the openai/codex rust-v0.163.0 schema: per-response usage from token_usage_record (token_count fallback), per-turn model from turn_context, OpenAI long-context and Fast tiers, tool calls with exit codes, fork de-duplication and subagent nesting. Add --agent and --codex-home, agent-aware report labels, and OpenAI list prices. --- CHANGELOG.md | 23 ++ README.md | 17 +- src/agentdiff/cli.py | 26 +- src/agentdiff/sessions/analyzer.py | 22 +- src/agentdiff/sessions/claude_code.py | 51 +--- src/agentdiff/sessions/codex.py | 381 ++++++++++++++++++++++++++ src/agentdiff/sessions/dedupe.py | 57 ++++ src/agentdiff/sessions/loader.py | 31 +++ src/agentdiff/sessions/models.py | 16 +- src/agentdiff/sessions/pricing.py | 42 ++- src/agentdiff/sessions/report.py | 32 ++- tests/conftest.py | 10 + tests/test_codex.py | 349 +++++++++++++++++++++++ tests/test_distribution.py | 2 +- tests/test_sessions.py | 8 +- 15 files changed, 986 insertions(+), 81 deletions(-) create mode 100644 src/agentdiff/sessions/codex.py create mode 100644 src/agentdiff/sessions/dedupe.py create mode 100644 src/agentdiff/sessions/loader.py create mode 100644 tests/test_codex.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 921027fe..740503bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **OpenAI Codex CLI support.** `agentdiff` now reads Codex rollouts from + `~/.codex/sessions` and `archived_sessions` (or `$CODEX_HOME`), alongside + Claude Code, and runs the same findings on them. Usage comes from + `token_usage_record` (one per API response, de-duplicated by response id), + falling back to `token_count` events in older logs. Each turn is priced with + the model from its `turn_context`, including OpenAI's long-context tier + above 272K tokens and the Fast tier. Tool calls pair with their outputs, and + exit codes are read from the output text or from paginated + `item_completed` events. Legacy forks that copy the parent's usage lines are + counted once, and subagent threads nest under their parent. Compressed + `.jsonl.zst` rollouts are read when a zstd module is available. +- New `--agent all|claude|codex` and `--codex-home` options. The report header + names the agents it read, adds an Agent column when there is more than one, + and notes that Codex figures are API-equivalent estimates. +- OpenAI model prices (gpt-6.x, gpt-5.x, codex, o3/o4-mini) at list rates as + of 2026-10-19. + +### Changed +- Fork de-duplication moved to a shared module and applies to every agent. +- Requests on models with no known price are now called out in the report + footer instead of silently counting as $0. + ## [0.6.2] - 2026-10-19 AgentDiff v0.6.2 ships the npm launcher under its working name: diff --git a/README.md b/README.md index 3c7e7fe9..494a846a 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ or `uvx --from agent-trajectory-diff agentdiff` · or `/agentdiff:report` in [Cl -AgentDiff reads the session logs Claude Code already keeps on your machine and -tells you **where the money went and what was avoidable**. No config, no API +AgentDiff reads the session logs Claude Code and the OpenAI Codex CLI already +keep on your machine and tells you **where the money went and what was avoidable**. No config, no API key, no account. Nothing leaves your machine. ```text @@ -82,7 +82,9 @@ agentdiff --since 30d # 24h, 7d, 30d, 12w … or all agentdiff --project api # only projects whose name contains "api" agentdiff --top 10 # list more findings and sessions agentdiff --json > spend.json # machine-readable +agentdiff --agent codex # only Codex (or: claude, all) agentdiff --path /other/.claude # a different Claude Code config dir +agentdiff --codex-home /other/.codex # a different Codex home ``` ## Install @@ -131,7 +133,14 @@ Without the plugin, prefix the command with `!` to run it in a session: ## How the numbers are computed - **Source:** `~/.claude/projects/**/*.jsonl` (or `$CLAUDE_CONFIG_DIR`), - including subagent transcripts. + including subagent transcripts, and Codex's + `~/.codex/sessions/**/rollout-*.jsonl` plus `archived_sessions/` (or + `$CODEX_HOME`), including subagent threads. +- **Codex:** usage is read per API response (`token_usage_record`, or + `token_count` in older logs) and priced at OpenAI list prices for the model + active in each turn, including the long-context tier above 272K tokens and + the Fast tier. Codex logs carry no cost of their own, so these are + API-equivalent estimates (on a ChatGPT plan you aren't billed per token). - **Counted once:** Claude Code repeats usage on every content block of a request, and resumed or forked sessions copy their parent's history. AgentDiff counts each billed request exactly once. @@ -153,7 +162,7 @@ network calls and has no telemetry. | Agent | Status | | :--- | :--- | | Claude Code | ✅ | -| OpenAI Codex CLI | Next | +| OpenAI Codex CLI | ✅ (new in 0.7) | | Gemini CLI, Cursor | Planned | --- diff --git a/src/agentdiff/cli.py b/src/agentdiff/cli.py index 2d266aa5..1d1a010c 100644 --- a/src/agentdiff/cli.py +++ b/src/agentdiff/cli.py @@ -322,23 +322,39 @@ def sessions( ), top: int = typer.Option(5, "--top", help="How many findings and sessions to list."), as_json: bool = typer.Option(False, "--json", help="Print machine-readable JSON."), + agent: str = typer.Option( + "all", "--agent", help="Which agent's logs to read: all, claude or codex." + ), path: str | None = typer.Option( None, "--path", help="Claude Code config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude).", ), + codex_home: str | None = typer.Option( + None, + "--codex-home", + help="Codex CLI home dir (default: $CODEX_HOME or ~/.codex).", + ), ): - """Explain where your Claude Code spend went and what was wasted. + """Explain where your coding-agent spend went and what was wasted. - Reads the session logs Claude Code already keeps on this machine. Nothing - is uploaded. Sessions are included when they were active in the period. + Reads the session logs Claude Code and the OpenAI Codex CLI already keep + on this machine. Nothing is uploaded. Sessions are included when they were + active in the period. """ from agentdiff.sessions.analyzer import analyze - from agentdiff.sessions.claude_code import load_sessions + from agentdiff.sessions.loader import load_sessions from agentdiff.sessions.report import render, to_json + agent = agent.strip().lower() + if agent not in ("all", "claude", "codex"): + raise typer.BadParameter("use all, claude or codex", param_hint="--agent") cutoff = _parse_since(since) - found = load_sessions(Path(path).expanduser() if path else None) + found = load_sessions( + agent, + claude_root=Path(path).expanduser() if path else None, + codex_home=Path(codex_home).expanduser() if codex_home else None, + ) if cutoff is not None: found = [s for s in found if s.ended_at and s.ended_at >= cutoff] if project: diff --git a/src/agentdiff/sessions/analyzer.py b/src/agentdiff/sessions/analyzer.py index 4968c2a2..e80c9b40 100644 --- a/src/agentdiff/sessions/analyzer.py +++ b/src/agentdiff/sessions/analyzer.py @@ -17,6 +17,8 @@ from agentdiff.sessions.pricing import price_for READ_TOOLS = {"Read", "Grep", "Glob", "LS", "NotebookRead"} +# Claude Code uses Bash; Codex CLI uses exec_command / shell / local_shell. +SHELL_TOOLS = {"Bash", "shell", "exec_command", "local_shell"} # Context above this size is re-sent every turn; long tails here are what # /clear or /compact between tasks would avoid. @@ -89,11 +91,14 @@ def describe_call(call: ToolCall, width: int = 70) -> str: "notebook_path", "path", "command", + "cmd", "pattern", "url", "query", ): value = data.get(key) + if isinstance(value, list) and value and all(isinstance(v, str) for v in value): + value = " ".join(value) if isinstance(value, str) and value.strip(): text = " ".join(value.split()) return text if len(text) <= width else text[: width - 1] + "…" @@ -107,9 +112,11 @@ def describe_call(call: ToolCall, width: int = 70) -> str: def _command_family(call: ToolCall) -> str: - """Groups Bash commands by their leading words (e.g. ``npm test``).""" - command = call.input.get("command") - if call.name != "Bash" or not isinstance(command, str): + """Groups shell commands by their leading words (e.g. ``npm test``).""" + command = call.input.get("command") or call.input.get("cmd") + if isinstance(command, list) and all(isinstance(c, str) for c in command): + command = " ".join(command) + if call.name not in SHELL_TOOLS or not isinstance(command, str): return f"{call.name}:{describe_call(call, 200)}" words = command.strip().split() # Skip env assignments and `cd x &&` prefixes. @@ -117,7 +124,14 @@ def _command_family(call: ToolCall) -> str: words = words[1:] if len(words) >= 3 and words[0] == "cd" and words[2] in ("&&", ";"): words = words[3:] - return "Bash:" + " ".join(words[:2]) + # Codex wraps commands as `bash -lc ''`; look inside. + if ( + len(words) >= 3 + and words[0] in ("bash", "sh", "zsh") + and words[1] in ("-lc", "-c") + ): + words = " ".join(words[2:]).strip("'\"").split() + return "shell:" + " ".join(words[:2]) def analyze_session(session: Session, t: Thresholds | None = None) -> list[Finding]: diff --git a/src/agentdiff/sessions/claude_code.py b/src/agentdiff/sessions/claude_code.py index 8024746f..317c58b8 100644 --- a/src/agentdiff/sessions/claude_code.py +++ b/src/agentdiff/sessions/claude_code.py @@ -20,11 +20,14 @@ from pathlib import Path from typing import Any +from agentdiff.sessions.dedupe import dedupe_forks from agentdiff.sessions.models import ModelRequest, Session, ToolCall SOURCE = "claude_code" _EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) +__all__ = ["dedupe_forks", "discover", "load_sessions", "read_session"] + def default_root() -> Path: """Claude Code's config dir, honouring ``CLAUDE_CONFIG_DIR``.""" @@ -49,54 +52,6 @@ def load_sessions(root: Path | None = None) -> list[Session]: return dedupe_forks(sessions) -def dedupe_forks(sessions: list[Session]) -> list[Session]: - """Counts each billed request once across sessions. - - Resumed and forked sessions copy the parent's history (same request ids) - into a new file. The earliest session keeps a request; later copies drop - it. A fork's own ``cost-state`` includes inherited spend, so it is not - trusted once anything was removed. - """ - seen: set[str] = set() - kept = [] - for session in sorted(sessions, key=_age_key): - if _drop_seen(session, seen): - session.reported_cost = None - if session.all_requests(): - kept.append(session) - return kept - - -def _age_key(session: Session) -> tuple[datetime, float]: - # Copied history carries the parent's timestamps, so break ties by which - # file was created first. - try: - stat = os.stat(session.path) - created = getattr(stat, "st_birthtime", stat.st_mtime) - except OSError: - created = 0.0 - return (session.started_at or _EPOCH, created) - - -def _drop_seen(session: Session, seen: set[str]) -> bool: - removed = False - fresh = [] - for request in session.requests: - if request.request_id in seen: - removed = True - continue - seen.add(request.request_id) - fresh.append(request) - if removed: - live = {cid for r in fresh for cid in r.tool_call_ids} - session.requests = fresh - session.tool_calls = [c for c in session.tool_calls if c.call_id in live] - for sub in session.subagents: - removed = _drop_seen(sub, seen) or removed - session.subagents = [s for s in session.subagents if s.requests] - return removed - - def read_session(path: Path) -> Session | None: """Parses one session file plus its subagent transcripts.""" session = _parse_file(path) diff --git a/src/agentdiff/sessions/codex.py b/src/agentdiff/sessions/codex.py new file mode 100644 index 00000000..04f5928c --- /dev/null +++ b/src/agentdiff/sessions/codex.py @@ -0,0 +1,381 @@ +"""Reads OpenAI Codex CLI session logs (``~/.codex/sessions/**/rollout-*.jsonl``). + +Format notes, from the openai/codex source (rust-v0.163.0). Tolerant by +design; unknown entry types are ignored: + +* Each line is ``{"timestamp", "type", "payload"}`` (wrapped format, Codex + 0.33+). Older unwrapped files carry no token usage and are skipped. +* ``session_meta`` gives the thread id, cwd, and fork/subagent links + (``forked_from_id``, ``parent_thread_id``). It has no model. +* ``turn_context`` carries the model for the turns that follow; it can change + mid-session. ``event_msg``/``thread_settings_applied`` can also set the model + and ``service_tier`` (``"priority"`` = Fast tier). +* Usage: ``token_usage_record`` is one record per API response (dedupe by + ``response_id``). Older files only have ``event_msg``/``token_count`` with a + cumulative ``total_token_usage`` and a per-response ``last_token_usage``. + ``input_tokens`` includes ``cached_input_tokens``; ``output_tokens`` + includes reasoning tokens. +* Tools: ``response_item`` ``function_call`` / ``custom_tool_call`` / + ``local_shell_call`` pair with ``*_output`` items by ``call_id``. Exit codes + only appear inside the output text (``Process exited with code N`` or + ``Exit code: N``). Paginated-mode files also record + ``event_msg``/``item_completed`` command executions with ``exit_code``. +* Legacy-mode forks copy the parent's usage lines into the new file; shared + request ids let :func:`dedupe_forks` count them once. +* Logs carry no cost, so all Codex figures are API-equivalent estimates. +""" + +import hashlib +import json +import os +import re +from collections.abc import Iterator +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from agentdiff.sessions.dedupe import dedupe_forks +from agentdiff.sessions.models import ModelRequest, Session, ToolCall + +SOURCE = "codex" +_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) +_EXIT_RE = re.compile(r"(?:Process exited with code|Exit code:)\s*(-?\d+)") +_TOOL_ITEMS = {"function_call", "custom_tool_call", "local_shell_call"} +_TOOL_OUTPUTS = {"function_call_output", "custom_tool_call_output"} + + +def default_home() -> Path: + """Codex's home dir, honouring ``CODEX_HOME``.""" + configured = os.environ.get("CODEX_HOME") + return Path(configured).expanduser() if configured else Path.home() / ".codex" + + +def discover(home: Path | None = None) -> list[Path]: + """Rollout files under ``sessions/`` and ``archived_sessions/``.""" + home = home or default_home() + found: list[Path] = [] + for sub in ("sessions", "archived_sessions"): + root = home / sub + if root.is_dir(): + found.extend(p for p in root.rglob("rollout-*.jsonl*") if p.is_file()) + return sorted(p for p in found if p.suffix in (".jsonl", ".zst")) + + +def load_sessions(home: Path | None = None) -> list[Session]: + sessions = [s for s in (read_session(p) for p in discover(home)) if s] + return dedupe_forks(_attach_subagents(sessions)) + + +def _attach_subagents(sessions: list[Session]) -> list[Session]: + """Nests subagent threads under their parent thread when it is present.""" + by_id = {s.session_id: s for s in sessions} + top = [] + for session in sessions: + parent_id = session.parent_id + parent = by_id.get(parent_id) if parent_id else None + if parent is not None and parent is not session: + session.agent_id = session.agent_id or session.session_id + parent.subagents.append(session) + else: + top.append(session) + return top + + +def _open_lines(path: Path) -> Iterator[str]: + if path.suffix == ".zst": + data = _decompress_zst(path) + if data is None: + return + yield from data.decode("utf-8", "replace").splitlines() + return + with open(path, encoding="utf-8", errors="replace") as fh: + yield from fh + + +def _decompress_zst(path: Path) -> bytes | None: + """Reads a zstd-compressed rollout if a zstd module is available.""" + try: + from compression import zstd # Python 3.14+ + + return zstd.decompress(path.read_bytes()) + except ImportError: + pass + try: + import zstandard # optional third-party + + with open(path, "rb") as fh: + return zstandard.ZstdDecompressor().stream_reader(fh).read() + except ImportError: + return None + except Exception: + return None + + +def _iter_entries(path: Path) -> Iterator[dict[str, Any]]: + for line in _open_lines(path): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(entry, dict) and "type" in entry and "payload" in entry: + yield entry + + +def _parse_time(value: Any) -> datetime | None: + if not isinstance(value, str): + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def _int(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _output_text(output: Any) -> str: + if isinstance(output, str): + return output + if isinstance(output, list): + parts = [] + for item in output: + if isinstance(item, dict) and isinstance(item.get("text"), str): + parts.append(item["text"]) + else: + parts.append(json.dumps(item, sort_keys=True, default=str)) + return "\n".join(parts) + if isinstance(output, dict): + if isinstance(output.get("output"), str): + return output["output"] + return json.dumps(output, sort_keys=True, default=str) + return "" if output is None else str(output) + + +def _exit_code(text: str, output: Any) -> int | None: + match = _EXIT_RE.search(text) + if match: + return int(match.group(1)) + if isinstance(output, dict): # 2025-era JSON output with metadata + meta = output.get("metadata") + if isinstance(meta, dict) and isinstance(meta.get("exit_code"), int): + return meta["exit_code"] + if isinstance(output, str) and output.startswith("{"): + try: + return _exit_code("", json.loads(output)) + except json.JSONDecodeError: + return None + return None + + +def _tool_input(item: dict[str, Any]) -> dict[str, Any]: + kind = item.get("type") + if kind == "function_call": + raw = item.get("arguments") + if isinstance(raw, str): + try: + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {"arguments": parsed} + except json.JSONDecodeError: + return {"arguments": raw} + return raw if isinstance(raw, dict) else {} + if kind == "custom_tool_call": + return {"input": item.get("input")} + action = item.get("action") + return action if isinstance(action, dict) else {} + + +class _Parser: + def __init__(self, path: Path): + self.path = path + self.session_id = path.stem + self.cwd: str | None = None + self.branch: str | None = None + self.parent_id: str | None = None + self.model: str | None = None + self.fast = False + self.requests: list[ModelRequest] = [] + self.calls: dict[str, ToolCall] = {} + self.pending: list[str] = [] + self.has_records = False + self.count_requests: list[ModelRequest] = [] + self.last_total: str | None = None + self.n = 0 + + def feed(self, entry: dict[str, Any]) -> None: + kind = entry["type"] + payload = entry.get("payload") + if not isinstance(payload, dict): + return + stamp = _parse_time(entry.get("timestamp")) + if kind == "session_meta": + self.session_id = str(payload.get("id") or self.session_id) + self.cwd = payload.get("cwd") or self.cwd + self.parent_id = payload.get("parent_thread_id") or self.parent_id + git = payload.get("git") + if isinstance(git, dict): + self.branch = git.get("branch") or self.branch + elif kind == "turn_context": + self.model = payload.get("model") or self.model + self.cwd = self.cwd or payload.get("cwd") + elif kind == "token_usage_record": + usage = payload.get("usage") + rid = payload.get("response_id") + if isinstance(usage, dict): + self.has_records = True + self._add_request( + f"codex:{rid}" if rid else None, usage, stamp, self.requests + ) + elif kind == "event_msg": + self._event(payload, stamp) + elif kind == "response_item": + self._response_item(payload, stamp) + + def _event(self, payload: dict[str, Any], stamp: datetime | None) -> None: + etype = payload.get("type") + if etype == "token_count": + info = payload.get("info") + if not isinstance(info, dict): + return + total = info.get("total_token_usage") + last = info.get("last_token_usage") + key = json.dumps(total, sort_keys=True) + if not isinstance(last, dict) or key == self.last_total: + return # rate-limit-only or repeated event + self.last_total = key + digest = hashlib.sha1(key.encode()).hexdigest()[:16] + self._add_request(f"codex-tc:{digest}", last, stamp, self.count_requests) + elif etype == "thread_settings_applied": + settings = payload.get("thread_settings") + if isinstance(settings, dict): + self.model = settings.get("model") or self.model + tier = settings.get("service_tier") + if tier is not None: + self.fast = tier == "priority" + elif etype == "item_completed": + item = payload.get("item") + if isinstance(item, dict) and item.get("type") == "CommandExecution": + self._command_execution(item, stamp) + + def _response_item(self, item: dict[str, Any], stamp: datetime | None) -> None: + kind = item.get("type") + if kind in _TOOL_ITEMS: + call_id = item.get("call_id") or item.get("id") + if not call_id or call_id in self.calls: + return + name = item.get("name") or ("shell" if kind == "local_shell_call" else kind) + self.calls[call_id] = ToolCall( + call_id=call_id, + name=str(name), + input=_tool_input(item), + request_id="", + timestamp=stamp, + ) + self.pending.append(call_id) + elif kind in _TOOL_OUTPUTS: + call = self.calls.get(item.get("call_id")) + if call is None: + return + output = item.get("output") + text = _output_text(output) + code = _exit_code(text, output) + call.has_result = True + call.is_error = code is not None and code != 0 + call.result_chars = len(text) + call.result_hash = hashlib.sha1(text.encode("utf-8", "replace")).hexdigest() + + def _command_execution(self, item: dict[str, Any], stamp: datetime | None) -> None: + """Paginated mode: a completed command with a structured exit code.""" + call_id = item.get("id") + if not call_id or call_id in self.calls: + return + command = item.get("command") + text = str(item.get("aggregated_output") or "") + call = ToolCall( + call_id=call_id, + name="shell", + input={ + "command": " ".join(command) if isinstance(command, list) else command + }, + request_id="", + timestamp=stamp, + has_result=True, + is_error=isinstance(item.get("exit_code"), int) and item["exit_code"] != 0, + result_chars=len(text), + result_hash=hashlib.sha1(text.encode("utf-8", "replace")).hexdigest(), + ) + self.calls[call_id] = call + self.pending.append(call_id) + + def _add_request( + self, + rid: str | None, + usage: dict[str, Any], + stamp: datetime | None, + into: list[ModelRequest], + ) -> None: + self.n += 1 + total_in = _int(usage.get("input_tokens")) + cached = _int(usage.get("cached_input_tokens")) + written = _int(usage.get("cache_write_input_tokens")) + request = ModelRequest( + request_id=rid or f"codex:{self.session_id}:{self.n}", + model=self.model, + timestamp=stamp, + input_tokens=max(0, total_in - cached - written), + output_tokens=_int(usage.get("output_tokens")), + cache_read_tokens=cached, + cache_write_5m_tokens=written, + fast=self.fast, + ) + into.append(request) + + def build(self) -> Session | None: + requests = self.requests if self.has_records else self.count_requests + if not requests: + return None + self._assign_calls(requests) + project = Path(self.cwd).name if self.cwd else self.path.parent.name + session = Session( + session_id=self.session_id, + source=SOURCE, + path=str(self.path), + project=project, + git_branch=self.branch, + requests=requests, + tool_calls=list(self.calls.values()), + parent_id=self.parent_id, + ) + return session + + def _assign_calls(self, requests: list[ModelRequest]) -> None: + """Attributes each tool call to the first response logged after it. + + A response's usage is recorded when it completes, after the tool + calls it emitted, so the next usage line after a call is the request + that issued it. + """ + ordered = sorted(requests, key=lambda r: r.timestamp or _EPOCH) + calls = sorted(self.calls.values(), key=lambda c: c.timestamp or _EPOCH) + i = 0 + for call in calls: + while i < len(ordered) and (ordered[i].timestamp or _EPOCH) < ( + call.timestamp or _EPOCH + ): + i += 1 + target = ordered[min(i, len(ordered) - 1)] + call.request_id = target.request_id + target.tool_call_ids.append(call.call_id) + + +def read_session(path: Path) -> Session | None: + parser = _Parser(path) + for entry in _iter_entries(path): + parser.feed(entry) + return parser.build() diff --git a/src/agentdiff/sessions/dedupe.py b/src/agentdiff/sessions/dedupe.py new file mode 100644 index 00000000..f3872fe1 --- /dev/null +++ b/src/agentdiff/sessions/dedupe.py @@ -0,0 +1,57 @@ +"""Counts each billed request once across sessions, for every agent. + +Resumed and forked sessions can copy their parent's history (same request +ids) into a new file: Claude Code always does on resume/fork, Codex does on +legacy-mode forks. The earliest session keeps a request; later copies drop +it. A fork's own reported total includes inherited spend, so it is not +trusted once anything was removed. +""" + +import os +from datetime import datetime, timezone + +from agentdiff.sessions.models import Session + +_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) + + +def dedupe_forks(sessions: list[Session]) -> list[Session]: + """Drops requests already counted in an earlier session.""" + seen: set[str] = set() + kept = [] + for session in sorted(sessions, key=_age_key): + if _drop_seen(session, seen): + session.reported_cost = None + if session.all_requests(): + kept.append(session) + return kept + + +def _age_key(session: Session) -> tuple[datetime, float]: + # Copied history carries the parent's timestamps, so break ties by which + # file was created first. + try: + stat = os.stat(session.path) + created = getattr(stat, "st_birthtime", stat.st_mtime) + except OSError: + created = 0.0 + return (session.started_at or _EPOCH, created) + + +def _drop_seen(session: Session, seen: set[str]) -> bool: + removed = False + fresh = [] + for request in session.requests: + if request.request_id in seen: + removed = True + continue + seen.add(request.request_id) + fresh.append(request) + if removed: + live = {cid for r in fresh for cid in r.tool_call_ids} + session.requests = fresh + session.tool_calls = [c for c in session.tool_calls if c.call_id in live] + for sub in session.subagents: + removed = _drop_seen(sub, seen) or removed + session.subagents = [s for s in session.subagents if s.requests] + return removed diff --git a/src/agentdiff/sessions/loader.py b/src/agentdiff/sessions/loader.py new file mode 100644 index 00000000..fb4eb5b6 --- /dev/null +++ b/src/agentdiff/sessions/loader.py @@ -0,0 +1,31 @@ +"""Loads sessions from every supported coding agent.""" + +from pathlib import Path + +from agentdiff.sessions import claude_code, codex +from agentdiff.sessions.models import Session + +AGENTS = ("claude", "codex") +LABELS = {claude_code.SOURCE: "Claude Code", codex.SOURCE: "Codex"} + + +def load_sessions( + agent: str = "all", + *, + claude_root: Path | None = None, + codex_home: Path | None = None, +) -> list[Session]: + """Sessions for ``agent`` ("all", "claude" or "codex").""" + if agent not in ("all", *AGENTS): + raise ValueError(f"unknown agent {agent!r}; use all, claude or codex") + sessions: list[Session] = [] + if agent in ("all", "claude"): + sessions.extend(claude_code.load_sessions(claude_root)) + if agent in ("all", "codex"): + sessions.extend(codex.load_sessions(codex_home)) + return sessions + + +def label_for(sources: set[str]) -> str: + names = [LABELS[s] for s in (claude_code.SOURCE, codex.SOURCE) if s in sources] + return " + ".join(names) or "Claude Code" diff --git a/src/agentdiff/sessions/models.py b/src/agentdiff/sessions/models.py index 42ad329f..9cf56414 100644 --- a/src/agentdiff/sessions/models.py +++ b/src/agentdiff/sessions/models.py @@ -39,16 +39,23 @@ def cost_breakdown(self) -> dict[str, float]: if price is None: return {"input": 0.0, "output": 0.0, "cache_read": 0.0, "cache_write": 0.0} mult = FAST_MODE_MULTIPLIER if self.fast else 1.0 + in_mult = out_mult = mult + if ( + price.long_context_tokens + and self.context_tokens > price.long_context_tokens + ): + in_mult *= price.long_input_mult + out_mult *= price.long_output_mult per = 1_000_000 return { - "input": self.input_tokens * price.input * mult / per, - "output": self.output_tokens * price.output * mult / per, - "cache_read": self.cache_read_tokens * price.cache_read * mult / per, + "input": self.input_tokens * price.input * in_mult / per, + "output": self.output_tokens * price.output * out_mult / per, + "cache_read": self.cache_read_tokens * price.cache_read * in_mult / per, "cache_write": ( self.cache_write_5m_tokens * price.cache_write_5m + self.cache_write_1h_tokens * price.cache_write_1h ) - * mult + * in_mult / per, } @@ -91,6 +98,7 @@ class Session: subagents: list["Session"] = field(default_factory=list) reported_cost: float | None = None agent_id: str | None = None + parent_id: str | None = None @property def started_at(self) -> datetime | None: diff --git a/src/agentdiff/sessions/pricing.py b/src/agentdiff/sessions/pricing.py index c67c238e..72dae00d 100644 --- a/src/agentdiff/sessions/pricing.py +++ b/src/agentdiff/sessions/pricing.py @@ -1,11 +1,17 @@ """Per-model token prices used to reconstruct coding-agent session cost. -Prices are USD per million tokens (Anthropic first-party API rates). Cache -writes are priced at 1.25x input (5-minute TTL) and 2x input (1-hour TTL); -cache reads at the model's published cache-read rate. +Prices are USD per million tokens at first-party list rates (Anthropic API, +OpenAI API Standard tier, as published 2026-10-19). Cache writes are priced +at 1.25x input (5-minute TTL) and 2x input (1-hour TTL); cache reads at the +model's published cached-input rate. -Reconstructed totals are cross-checked against Claude Code's own -``cost-state`` counter when a session log carries one. +OpenAI bills prompts above 272K input tokens at a long-context tier (2x input +and cached input, 1.5x output); ``long_context_tokens`` marks the models it +applies to. + +Reconstructed Claude Code totals are cross-checked against its own +``cost-state`` counter when a session log carries one. Codex logs carry no +cost, so Codex figures are API-equivalent estimates. """ from dataclasses import dataclass @@ -16,6 +22,9 @@ class ModelPrice: input: float output: float cache_read: float + long_context_tokens: int | None = None + long_input_mult: float = 2.0 + long_output_mult: float = 1.5 @property def cache_write_5m(self) -> float: @@ -45,9 +54,30 @@ def cache_write_1h(self) -> float: "claude-haiku-5-5": ModelPrice(0.10, 0.50, 0.01), "claude-haiku-4-5": ModelPrice(1.0, 5.0, 0.10), "claude-3-5-haiku": ModelPrice(0.80, 4.0, 0.08), + # OpenAI (Codex CLI). developers.openai.com/api/docs/pricing, 2026-10-19. + "gpt-6.1-sol": ModelPrice(2.0, 10.0, 0.10, long_context_tokens=272_000), + "gpt-6-astra": ModelPrice(10.0, 50.0, 1.0, long_context_tokens=272_000), + "gpt-6-sol": ModelPrice(2.0, 10.0, 0.20, long_context_tokens=272_000), + "gpt-6-luna": ModelPrice(0.10, 0.50, 0.01, long_context_tokens=272_000), + "gpt-5.6-sol": ModelPrice(4.0, 20.0, 0.40, long_context_tokens=272_000), + "gpt-5.6-terra": ModelPrice(2.0, 12.0, 0.20, long_context_tokens=272_000), + "gpt-5.6-luna": ModelPrice(0.20, 1.20, 0.02, long_context_tokens=272_000), + "gpt-5.5": ModelPrice(5.0, 30.0, 0.50), + "gpt-5.3-codex": ModelPrice(1.75, 14.0, 0.175), + "gpt-5.2-codex": ModelPrice(1.75, 14.0, 0.175), + "gpt-5.1-codex-mini": ModelPrice(0.25, 2.0, 0.025), + "gpt-5.1-codex": ModelPrice(1.25, 10.0, 0.125), + "gpt-5-codex": ModelPrice(1.25, 10.0, 0.125), + "gpt-5.1": ModelPrice(1.25, 10.0, 0.125), + "gpt-5-mini": ModelPrice(0.25, 2.0, 0.025), + "gpt-5": ModelPrice(1.25, 10.0, 0.125), + "codex-mini-latest": ModelPrice(1.50, 6.0, 0.375), + "o4-mini": ModelPrice(1.10, 4.40, 0.275), + "o3": ModelPrice(2.0, 8.0, 0.50), } -# Fast mode bills the same model at a premium. +# Fast mode (Anthropic) / Fast tier (OpenAI, formerly Priority) bills the same +# model at a premium: 2x on the current models of both vendors. FAST_MODE_MULTIPLIER = 2.0 diff --git a/src/agentdiff/sessions/report.py b/src/agentdiff/sessions/report.py index 5bcee2cb..e69d9b81 100644 --- a/src/agentdiff/sessions/report.py +++ b/src/agentdiff/sessions/report.py @@ -8,6 +8,7 @@ from rich.text import Text from agentdiff.sessions.analyzer import Analysis, Finding +from agentdiff.sessions.loader import LABELS, label_for ADVICE = { "context_bloat": "Start a fresh session per task; /compact before the context gets huge.", @@ -48,17 +49,19 @@ def render( sessions = analysis.sessions if not sessions: console.print( - "[yellow]No Claude Code sessions found[/yellow] in this period. " - "Logs are read from ~/.claude/projects (or $CLAUDE_CONFIG_DIR)." + "[yellow]No Claude Code or Codex sessions found[/yellow] in this period. " + "Logs are read from ~/.claude/projects ($CLAUDE_CONFIG_DIR) and " + "~/.codex/sessions ($CODEX_HOME)." ) return projects = {s.session.project for s in sessions} + sources = {s.session.source for s in sessions} console.print() console.print( Text.assemble( ("AgentDiff", "bold"), - (" · Claude Code · ", "dim"), + (f" · {label_for(sources)} · ", "dim"), (period, "bold"), ) ) @@ -126,6 +129,8 @@ def render( table.add_column( "Project", style="dim", no_wrap=True, overflow="ellipsis", max_width=16 ) + if len(sources) > 1: + table.add_column("Agent", style="dim", no_wrap=True) table.add_column("When", style="dim") table.add_column("Cost", justify="right") table.add_column("Avoidable", justify="right") @@ -135,9 +140,11 @@ def render( session = summary.session when = session.started_at.strftime("%b %d") if session.started_at else "?" label = session.title or session.session_id[:8] + agent = [LABELS.get(session.source, session.source)] if len(sources) > 1 else [] table.add_row( label, session.project, + *agent, when, _usd(summary.cost), _usd(summary.waste) if summary.waste >= 0.01 else "-", @@ -146,13 +153,23 @@ def render( ) console.print(table) + notes = ["Costs rebuilt from logged token usage at list prices."] reported = [s for s in sessions if s.session.reported_cost is not None] if reported: - console.print( - f"\n[dim]Costs rebuilt from logged token usage at list prices; " - f"{len(reported)} sessions cross-checked against Claude Code's own counter. " - f"Nothing left your machine.[/dim]" + notes.append( + f"{len(reported)} Claude Code sessions cross-checked against its own counter." + ) + if "codex" in sources: + notes.append( + "Codex logs carry no cost, so Codex figures are API-equivalent estimates." + ) + if spend.unpriced_requests: + notes.append( + f"{spend.unpriced_requests} requests used models without a known price " + "and are not counted." ) + notes.append("Nothing left your machine.") + console.print("\n[dim]" + " ".join(notes) + "[/dim]") console.print() @@ -184,6 +201,7 @@ def iso(value: datetime | None) -> str | None: "sessions": [ { "session_id": s.session.session_id, + "agent": s.session.source, "title": s.session.title, "project": s.session.project, "started_at": iso(s.session.started_at), diff --git a/tests/conftest.py b/tests/conftest.py index 8b0e7501..9be5810e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +import pytest + from agentdiff.models.step import StepStatus, StepType, TokenUsage, TraceStep from agentdiff.models.trace import AgentTrace @@ -68,3 +70,11 @@ def make_trace( total_tokens=total_tokens, metadata=kwargs.get("metadata", {}), ) + + +@pytest.fixture(autouse=True) +def _isolate_agent_homes(tmp_path_factory, monkeypatch): + """Keeps tests from reading the developer's real Codex / Claude logs.""" + empty = tmp_path_factory.mktemp("empty-agent-home") + monkeypatch.setenv("CODEX_HOME", str(empty)) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(empty)) diff --git a/tests/test_codex.py b/tests/test_codex.py new file mode 100644 index 00000000..dd885008 --- /dev/null +++ b/tests/test_codex.py @@ -0,0 +1,349 @@ +"""Codex CLI reader: synthetic rollouts built from the openai/codex schema. + +No real Codex sessions are used; every line follows the wrapped +``{"timestamp", "type", "payload"}`` format of Codex 0.33+ (checked against +the rust-v0.163.0 source). +""" + +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from agentdiff.cli import app +from agentdiff.sessions import codex +from agentdiff.sessions.analyzer import analyze, analyze_session +from agentdiff.sessions.loader import label_for, load_sessions + +T0 = datetime(2026, 10, 1, 9, 0, tzinfo=timezone.utc) + + +def _ts(minutes: float) -> str: + return ( + (T0 + timedelta(minutes=minutes)) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z") + ) + + +class Rollout: + def __init__(self, thread_id="th-1", cwd="/work/shop", **meta): + self.thread_id = thread_id + self.lines: list[dict] = [] + self.add( + 0, "session_meta", {"id": thread_id, "cwd": cwd, "source": "cli", **meta} + ) + self.n = 0 + + def add(self, minutes, kind, payload): + self.lines.append({"timestamp": _ts(minutes), "type": kind, "payload": payload}) + + def turn(self, minutes, model): + self.add( + minutes, + "turn_context", + {"turn_id": f"t{minutes}", "cwd": "/work/shop", "model": model}, + ) + + def tool(self, minutes, cmd, output, call_id=None): + self.n += 1 + call_id = call_id or f"call_{self.n}" + self.add( + minutes, + "response_item", + { + "type": "function_call", + "name": "exec_command", + "arguments": json.dumps({"cmd": cmd}), + "call_id": call_id, + }, + ) + self.add( + minutes + 0.01, + "response_item", + {"type": "function_call_output", "call_id": call_id, "output": output}, + ) + return call_id + + def usage(self, minutes, rid, input_tokens=0, cached=0, output=0, reasoning=0): + self.add( + minutes, + "token_usage_record", + { + "thread_id": self.thread_id, + "turn_id": "t", + "response_id": rid, + "usage": { + "input_tokens": input_tokens, + "cached_input_tokens": cached, + "cache_write_input_tokens": 0, + "output_tokens": output, + "reasoning_output_tokens": reasoning, + "total_tokens": input_tokens + output, + }, + }, + ) + + def token_count(self, minutes, total, last): + self.add( + minutes, + "event_msg", + { + "type": "token_count", + "info": None + if total is None + else { + "total_token_usage": total, + "last_token_usage": last, + "model_context_window": 272000, + }, + }, + ) + + def write(self, home: Path, sub="sessions", day="2026/10/01") -> Path: + folder = home / sub / day + folder.mkdir(parents=True, exist_ok=True) + path = folder / f"rollout-2026-10-01T09-00-00-{self.thread_id}.jsonl" + path.write_text("\n".join(json.dumps(x) for x in self.lines) + "\n") + return path + + +def _usage(i, c, o): + return { + "input_tokens": i, + "cached_input_tokens": c, + "output_tokens": o, + "reasoning_output_tokens": 0, + "total_tokens": i + o, + } + + +# --- reading & pricing --------------------------------------------------------- + + +def test_usage_records_are_priced_per_model(tmp_path): + r = Rollout() + r.turn(0, "gpt-6.1-sol") + r.usage( + 1, "resp_1", input_tokens=100_000, cached=80_000, output=10_000, reasoning=4_000 + ) + session = codex.read_session(r.write(tmp_path)) + + assert session.source == "codex" + assert session.project == "shop" + (req,) = session.requests + assert req.input_tokens == 20_000 # input includes cached + assert req.cache_read_tokens == 80_000 + assert req.output_tokens == 10_000 # output includes reasoning + assert req.cost == pytest.approx( + (20_000 * 2.0 + 80_000 * 0.10 + 10_000 * 10.0) / 1e6 + ) + + +def test_model_can_change_mid_session(tmp_path): + r = Rollout() + r.turn(0, "gpt-5") + r.usage(1, "resp_a", input_tokens=200_000) + r.turn(2, "gpt-6.1-sol") + r.usage(3, "resp_b", input_tokens=200_000) + session = codex.read_session(r.write(tmp_path)) + assert [q.model for q in session.requests] == ["gpt-5", "gpt-6.1-sol"] + assert [round(q.cost, 4) for q in session.requests] == [0.25, 0.4] + + +def test_long_context_tier_and_fast_tier(tmp_path): + r = Rollout() + r.turn(0, "gpt-6.1-sol") + r.usage(1, "resp_short", input_tokens=200_000, output=1_000_000) + r.usage(2, "resp_long", input_tokens=300_000, output=1_000_000) + r.add( + 3, + "event_msg", + { + "type": "thread_settings_applied", + "thread_settings": {"model": "gpt-6.1-sol", "service_tier": "priority"}, + }, + ) + r.usage(4, "resp_fast", output=1_000_000) + short, long_, fast = codex.read_session(r.write(tmp_path)).requests + assert short.cost == pytest.approx(0.2 * 2.0 + 10.0) + assert long_.cost == pytest.approx(0.3 * 2.0 * 2 + 10.0 * 1.5) # >272K tier + assert fast.fast and fast.cost == pytest.approx(20.0) + + +def test_token_count_fallback_skips_repeats_and_rate_limit_events(tmp_path): + r = Rollout() + r.turn(0, "gpt-5") + r.token_count(1, _usage(1000, 0, 100), _usage(1000, 0, 100)) + r.token_count(1.5, _usage(1000, 0, 100), _usage(1000, 0, 100)) # repeat + r.token_count(2, None, None) # rate-limit only + r.token_count(3, _usage(3000, 500, 300), _usage(2000, 500, 200)) + session = codex.read_session(r.write(tmp_path)) + assert len(session.requests) == 2 + assert sum(q.output_tokens for q in session.requests) == 300 + + +def test_usage_records_win_over_token_count(tmp_path): + r = Rollout() + r.turn(0, "gpt-5") + r.token_count(1, _usage(1000, 0, 100), _usage(1000, 0, 100)) + r.usage(1, "resp_1", input_tokens=1000, output=100) + assert len(codex.read_session(r.write(tmp_path)).requests) == 1 + + +def test_files_without_usage_or_wrapper_are_skipped(tmp_path): + folder = tmp_path / "sessions" / "2025" / "08" / "01" + folder.mkdir(parents=True) + (folder / "rollout-old.jsonl").write_text( + json.dumps({"id": "x", "timestamp": "2025-08-01T00:00:00Z"}) + "\n{broken\n" + ) + assert codex.load_sessions(tmp_path) == [] + + +def test_discovers_nested_and_archived_rollouts(tmp_path): + for i, sub in enumerate(("sessions", "archived_sessions")): + r = Rollout(thread_id=f"th-{i}") + r.turn(0, "gpt-5") + r.usage(1, f"resp_{i}", input_tokens=10) + r.write(tmp_path, sub=sub) + assert len(codex.load_sessions(tmp_path)) == 2 + + +# --- forks & subagents ------------------------------------------------------ + + +def test_legacy_fork_copies_are_counted_once(tmp_path): + parent = Rollout(thread_id="parent") + parent.turn(0, "gpt-5") + parent.usage(1, "resp_1", input_tokens=1_000_000) + parent.write(tmp_path) + child = Rollout(thread_id="child", forked_from_id="parent") + child.turn(0, "gpt-5") + child.usage(1, "resp_1", input_tokens=1_000_000) # copied line + child.usage(5, "resp_2", input_tokens=1_000_000) + child.write(tmp_path, day="2026/10/02") + + sessions = codex.load_sessions(tmp_path) + assert sum(s.cost for s in sessions) == pytest.approx(2.5) + + +def test_subagent_threads_nest_under_parent(tmp_path): + parent = Rollout(thread_id="root") + parent.turn(0, "gpt-5") + parent.usage(1, "resp_p", input_tokens=1_000_000) + parent.write(tmp_path) + sub = Rollout(thread_id="worker", parent_thread_id="root") + sub.turn(0, "gpt-5-mini") + sub.usage(1, "resp_s", input_tokens=1_000_000) + sub.write(tmp_path, day="2026/10/02") + + (session,) = codex.load_sessions(tmp_path) + assert session.session_id == "root" + assert [s.session_id for s in session.subagents] == ["worker"] + assert session.cost == pytest.approx(1.25 + 0.25) + + +# --- tool calls & findings ------------------------------------------------- + + +def test_exit_codes_mark_errors_and_feed_stuck_retry(tmp_path): + r = Rollout() + r.turn(0, "gpt-6.1-sol") + for i in range(3): + r.tool( + i, + "pytest tests/api", + "Chunk ID: 1\nWall time: 0.4 seconds\nProcess exited with code 1\nOutput:\nE ImportError", + ) + r.usage(i + 0.5, f"resp_{i}", input_tokens=10_000, output=50_000) + r.tool(5, "ls", "Exit code: 0\nWall time: 0.0 seconds\nOutput:\nsrc") + r.usage(5.5, "resp_ok", input_tokens=1_000, output=100) + session = codex.read_session(r.write(tmp_path)) + + errors = [c.is_error for c in session.tool_calls] + assert errors == [True, True, True, False] + assert all(c.request_id for c in session.tool_calls) + stuck = [f for f in analyze_session(session) if f.kind == "stuck_retry"] + assert len(stuck) == 1 and "pytest tests/api" in stuck[0].title + assert stuck[0].usd > 0 + + +def test_paginated_command_executions_carry_exit_codes(tmp_path): + r = Rollout(history_mode="paginated") + r.turn(0, "gpt-6.1-sol") + r.add( + 1, + "event_msg", + { + "type": "item_completed", + "thread_id": "th-1", + "turn_id": "t", + "item": { + "type": "CommandExecution", + "id": "item_1", + "command": ["bash", "-lc", "npm test"], + "cwd": "/work/shop", + "status": "failed", + "aggregated_output": "1 failing", + "exit_code": 1, + "duration": 2.0, + }, + }, + ) + r.usage(2, "resp_1", input_tokens=100) + (call,) = codex.read_session(r.write(tmp_path)).tool_calls + assert call.is_error and call.input["command"] == "bash -lc npm test" + + +# --- multi-agent loading & CLI -------------------------------------------------- + + +def test_labels_and_agent_filter(tmp_path): + r = Rollout() + r.turn(0, "gpt-5") + r.usage(1, "resp_1", input_tokens=10) + r.write(tmp_path) + assert label_for({"codex"}) == "Codex" + assert label_for({"claude_code", "codex"}) == "Claude Code + Codex" + assert len(load_sessions("codex", codex_home=tmp_path)) == 1 + assert load_sessions("claude", codex_home=tmp_path) == [] + with pytest.raises(ValueError): + load_sessions("cursor") + + +def test_cli_codex_report_and_json(tmp_path): + r = Rollout() + r.turn(0, "gpt-6.1-sol") + r.usage(1, "resp_1", input_tokens=500_000, cached=400_000, output=20_000) + r.write(tmp_path) + + shown = CliRunner().invoke( + app, ["--agent", "codex", "--codex-home", str(tmp_path), "--since", "all"] + ) + assert shown.exit_code == 0, shown.output + assert "Codex" in shown.output + assert "API-equivalent estimates" in shown.output + + data = json.loads( + CliRunner() + .invoke(app, ["--codex-home", str(tmp_path), "--since", "all", "--json"]) + .output + ) + assert [s["agent"] for s in data["sessions"]] == ["codex"] + assert data["total_usd"] > 0 + + bad = CliRunner().invoke(app, ["--agent", "cursor"]) + assert bad.exit_code != 0 + + +def test_whole_analysis_runs_on_codex_sessions(tmp_path): + r = Rollout() + r.turn(0, "gpt-6.1-sol") + for i in range(30): + r.usage(i, f"resp_{i}", input_tokens=700_000, cached=650_000, output=2_000) + r.write(tmp_path) + analysis = analyze(codex.load_sessions(tmp_path)) + assert analysis.total > 0 + assert any(f.kind == "context_bloat" for f in analysis.findings) diff --git a/tests/test_distribution.py b/tests/test_distribution.py index 6c345492..6a4eec4c 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -82,7 +82,7 @@ def test_report_script_runs_and_never_fails(tmp_path): [str(SCRIPT), "--since", "all"], env=env, capture_output=True, text=True ) assert ok.returncode == 0 - assert "No Claude Code sessions found" in ok.stdout + assert "No Claude Code or Codex sessions found" in ok.stdout bad = subprocess.run( [str(SCRIPT), "--since", "lots"], env=env, capture_output=True, text=True diff --git a/tests/test_sessions.py b/tests/test_sessions.py index c35522c6..a3d8035d 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -126,7 +126,11 @@ def test_price_lookup_prefers_most_specific_prefix(): assert price_for("claude-opus-5").input == 5.0 assert price_for("claude-opus-4-1-20250805").input == 15.0 assert price_for("anthropic.claude-sonnet-5-5").output == 10.0 - assert price_for("gpt-5") is None + assert price_for("gpt-5").input == 1.25 + assert price_for("gpt-5-mini").input == 0.25 + assert price_for("gpt-5.1-codex-mini").output == 2.0 + assert price_for("gpt-6.1-sol").long_context_tokens == 272_000 + assert price_for("llama-9") is None assert price_for(None) is None @@ -330,7 +334,7 @@ def test_cli_since_filters_old_sessions(tmp_path): root = _demo_root(tmp_path) result = CliRunner().invoke(app, ["sessions", "--path", str(root), "--since", "1d"]) assert result.exit_code == 0 - assert "No Claude Code sessions found" in result.output + assert "No Claude Code or Codex sessions found" in result.output def test_cli_rejects_bad_since(tmp_path):