2026-07-27 Codex over OpenRouter - Four Stacked Bugs
What I set out to do
Get the OpenRouter provider working with Codex. Ten-odd commits had gone at
this already (ea13a66 through 9f9f636) and it still did not work. The
failure moved every time, which is what made it feel unfixable.
What I actually did
Worked in a codex-openrouter worktree, reproduced in one run, then cloned
openai/codex into /tmp so I could read the Rust instead of guessing at it.
That was the turn. Every remaining question had a definite answer in the
source.
It was four independent bugs stacked, which is why fixing any one never surfaced a working path.
1. apply_patch can never cross this bridge. It is a freeform tool:
declared {"type":"custom","format":{"syntax":"lark"}}, and its handler
accepts only ToolPayload::Custom, produced solely by a custom_tool_call
item (apply_patch.rs::matches_kind). Chat Completions, which every
OpenRouter route speaks since LiteLLM bridges Responses through it, has no
freeform concept. So the whole commit chain, converting it to a function
tool, was relocating the failure rather than fixing it: the model answers
with a function_call and Codex rejects the turn. ApplyPatchToolType has
exactly one variant, so there is no JSON form to fall back to. Serving
"function" makes Codex reject the entire models response. The off switch is
apply_patch_tool_type: None, since spec_plan.rs gates the tool on
.is_some(). Codex then edits via exec_command, which bridges fine.
2. The hook was deleting 13 of 14 tools. It matched only the nested Chat
Completions tool shape, but the Responses API declares function tools flat.
Every flat tool fell through and was dropped. The lone survivor was
web_search, converted into a same-named function tool Codex cannot route,
so the model was left calling the one tool guaranteed to fail.
3 and 4. Moonshot strictness. Codex narrates between calling a tool and
reporting its result, so the bridged messages put an assistant message
between tool_calls and its tool reply, which Moonshot rejects. And Codex
replays the empty assistant turn a tool-only reply produces, which it also
rejects. Both confirmed by direct A/B probe rather than inference.
Then a fifth, after the user pushed back on my claim that kimi-k3 was simply
flaky: LiteLLM streams output_text.delta and output_item.done for the
trailing message item without ever emitting its output_item.added, so Codex
has no active item to attach the delta to and aborts the turn. It only fires
when the model returns a tool call and prose, which is exactly why it read
as flakiness.
What was striking
Three times I trusted a measurement that was lying, and the third one I shipped.
The models cache faked a null result. Setting apply_patch_tool_type: null
appeared to do nothing, which nearly ruled out the correct fix. Codex was
serving ~/.codex/models_cache.json and never re-fetching. With the cache
removed the tool vanished immediately. A failed refresh logs a serde error
and then silently falls back to cached metadata, so a metadata change that
seems inert is the expected symptom, not an unlikely one.
The tests encoded the same wrong assumption as the code, twice. The original
hook’s whole suite used the nested tool shape, so a hook that destroyed the
real payload passed everything. I then wrote the SSE repair with bytes
fixtures, deployed it, and took down every Codex request with TypeError: can't concat str to bytes, because Starlette bodies may be str and LiteLLM
yields str. I had criticised exactly that failure mode an hour earlier and
then reproduced it. The suite is parametrized over both types now, with an
assertion that the chunk type survives.
I scored runs by grepping for ERROR. Codex prints
ERROR: Reconnecting... N/5 as a retry notice; responses_retry.rs
returns Ok(()) and retries, failing only once retries are exhausted. So a
recovered transient counted as a failure, and I reported kimi-k3 as
“5/6, residual upstream flakiness”. Rescoring by outcome, the run I called a
failure had v = 42 in its file. It had succeeded. Score by what changed on
disk, not by what appeared in the log.
Being wrong about a patch, in the useful direction. I predicted
litellm-responses-tools-name.patch was removable and mildly harmful. A
deterministic diff of the transform said otherwise: identical output for the
flat tools Codex sends, but pristine upstream emits name: "" for nested
tools, which is the exact 400 it was filed against. My “harmful” theory was
wrong too, since web_search is handled by an earlier branch in both builds.
End-to-end runs were far too noisy to bisect with. The control run settled it
in one shot: kimi failed 2/2 with the patch applied, so the patch was never
the variable.
Of the seven LiteLLM patches, four are corroborated by third-party upstream
issues and three have no issue at all. #25240 was closed NOT_PLANNED by
the stale bot with another user confirming the bug in the last real comment,
so “closed” would have been a trap. And the installCheckPhase only asserts
the patches still apply, never that the bug still reproduces.
Numbers
- 4 files changed for the tool fixes; 26 unit tests, plus 20 for the SSE repair
- SSE repair replayed against 8 captured live streams: exactly 1 injection on each of the 5 tool-call streams, 0 on all 3 well-formed ones
- kimi-k3: fails pre-fix, 0/6 during my broken deploy, then 12/12 correct outcomes with one recovered transient
gpt-5.4-miniandglm-5.2clean throughout- Commits:
11d767e(tool handling),3076f83(tool advertisement),916c50e(SSE repair),88b1ab3(the str fix) - Applied via
hm switch; verified against the live proxy with no overrides
Related
2026-07-18 SigNoz Alert Fires and LiteLLM Empty-Completion Deep-Dive 2026-06-17 DeepSeek Empty Replies via OpenRouter 2026-07-24 Claude Model Pins and the Dead Anthropic Route