Repository navigation
Description
Summary
A tool call that takes no arguments is stored with arguments: "". "" is not valid JSON, so when that turn is replayed as history the upstream rejects the whole request:
{"error":{"code":null,"message":"`arguments` must be valid JSON",
"param":"arguments","type":"invalid_request_error"}}
Codex surfaces this as stream disconnected before completion: {…}.
The critical part is that it does not recover. The bad value is conversation history, so every subsequent turn replays it and fails identically. With CODEX_RELAY_HISTORY_STORE=disk it survives restarts too. One zero-argument tool call ends the session for good.
Seen on codex-relay 0.5.5 against Meta's /chat/completions (muse-spark-1.2), but nothing in the path is provider-specific — any upstream that validates arguments as JSON will do this.
Cause
src/stream.rs, accumulating streamed tool calls:
337: arguments: String::new(), // starts empty
350: if let Some(a) = &f.arguments { entry.arguments.push_str(a); } // only appends when sentA tool with no parameters never sends an arguments delta, so the accumulator stays "". That empty string is then written out verbatim in two places:
599: "arguments": &tc.arguments, // response.output_item.done → Codex stores it
670: "function": { "name": &tc.name, "arguments": &tc.arguments } // session storeNeither defaults to "{}". Line 632 shows the empty case was already anticipated one branch earlier — the delta emission is guarded with else if !tc.arguments.is_empty() — but the item and the store are not.
On the replay side, src/translate.rs:177:
cur.get("arguments").and_then(Value::as_str).unwrap_or("{}").to_string()unwrap_or("{}") only fires when the field is absent or not a string. "" is a perfectly good &str, so the default is skipped and "" goes upstream. src/translate.rs:733 has the same unwrap_or("{}") on the inbound direction.
Suggested fix
Default the empty accumulator at the point it becomes an item, so both the Codex-facing item and the session store carry valid JSON:
let arguments = if tc.arguments.is_empty() { "{}" } else { &tc.arguments };applied at stream.rs:599 and stream.rs:670.
Hardening translate.rs:177 / :733 to validate rather than only null-check would also catch any other route to a non-JSON arguments string:
cur.get("arguments")
.and_then(Value::as_str)
.filter(|a| serde_json::from_str::<Value>(a).is_ok())
.unwrap_or("{}")Repro sketch
- Point codex-relay at a provider that validates
arguments(Meta does; OpenRouter and DeepSeek appear more lenient). - Give the model a zero-parameter tool.
- Let it call that tool, then take one more turn so the call is replayed as history.
- The second request fails with
invalid_request_erroronparam: "arguments", and every retry after it fails the same way.
Note
0.5.6 does not change this — its changes are the double-bar DSML dialect, the n=1 guard, reasoning_effort, and think.rs. I checked both versions for any from_str on the replay path; the only one is custom_tool_input (translate.rs:488), which also falls back to the raw string.
Happy to send a PR if the approach above looks right to you.
Activity