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-mini and glm-5.2 clean 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

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