At 3:40 AM on September 10th, Claude Code hit its weekly limit and reset itself to 4 AM. I’d budgeted for that by wiring Codex CLI as a terminal fallback earlier that morning, same machine, same ChatGPT subscription, already authenticated. By early evening it was the primary engine running review passes against a stuck bug in our JIRA bot’s queue drain: a semaphore that was force-settling healthy wakes under load. Codex ran that review loop over and over that afternoon. QUEUE_TIMEOUT_SECONDS = 900 rejected. PRE_QUEUE_TIMEOUT_SECONDS = 3600 rejected. A recomputed budget, rejected. Each pass took two to eight minutes and burned anywhere from 60,000 to nearly 200,000 tokens. Nine “not approved” verdicts in a row before one finally cleared.
Somewhere around the fifth or sixth of those, I noticed something in the log line itself. My Claude Code entries look like this:
claude -p (sonnet/medium, 12t, 5.2s) - ...Model and effort, both there. My Codex entries looked like this:
codex exec (high, 97760t, 526.73s) - ...Effort, token count, duration. No model. Not “redacted,” not “unknown,” just absent, because nothing in the call chain had ever declared one. Every codex exec I’d dispatched all day, from the keepalive cron to the JIRA bot reviewer to the ad hoc terminal fallback, was pulling its model from whatever ~/.codex/config.toml happened to say at that moment. The logger couldn’t report a field nobody had set explicitly.
That’s a fine setup for a single interactive session. It’s a trap the second you have more than one caller sharing that file. Bump the global default to a heavier model because you want it for one foreground task, and every unattended job downstream shifts with it, silently, with no record of the change and no one deciding it on purpose. A cron job dispatched at 4 AM could run on a completely different model than the one that dispatched the same script the day before, and the only way to find out would be to go check the config file’s git history and cross-reference timestamps.
My first fix wasn’t to touch the callers. It was to patch the logger: resolve the effective model after the fact, same way Codex itself does, walk profile overrides, then environment, then global default, and stamp the result into the log line. I got it working, watched it fill in real model names for an hour of calls, and moved on. Then I hit a call site with a project-level config profile that overrode the global default, the one our JIRA bot’s own codex exec invocations were running under. My resolver walked the layers in the wrong order and printed the global default anyway. Confidently. It looked exactly like the fixed field I wanted, and it was wrong for every call from that profile going back to whenever I’d added it. That’s worse than a blank column. A missing field tells you to go check. A wrong one tells you not to.
So I threw the resolver out and enforced the opposite discipline: every dispatched Codex call has to name its own model at the call site, full stop. The shortcut I added that evening to open Codex in the ops repo, the same way our existing one opens Claude, always passes --model explicitly. The wrapper around codex exec now refuses to shell out without a model argument rather than falling back to config. Config still sets a default for genuinely interactive, ad hoc terminal use, the case it was always fine for, but nothing dispatched from a script or a cron trigger is allowed to inherit it anymore.
The log line for the fix I actually shipped that night reads codex exec (medium, 22327t, 109.61s), still missing a model name, because the change hadn’t landed yet when that call ran. The first call after it landed has one. That’s the whole test: not whether the code is correct, but whether you can point at any single dispatched call, unattended, hours later, and know exactly what ran without asking the config file to remember for you.