From c2c26226ca133b1e55fd9d151416d882d544a5be Mon Sep 17 00:00:00 2001 From: Clement Dumas Date: Wed, 11 Nov 2026 12:48:22 -0800 Subject: [PATCH] feat(codex): opt-in end_turn mode for repeated empty completions CCP_CODEX_EMPTY_COMPLETION=end_turn (or codex.emptyCompletion) applies the empty-completion acceptance from #122 to every request: one retry, then a valid empty end_turn instead of up to 11 full-context upstream calls and a 503. The default, retry, keeps the resolved-tool-results-only behavior. Co-Authored-By: Claude Opus 5.5 --- docs/src/content/docs/providers/codex.md | 4 ++ .../content/docs/reference/configuration.md | 1 + src/config.rs | 54 +++++++++++++++++++ src/providers/codex/mod.rs | 12 ++++- 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/providers/codex.md b/docs/src/content/docs/providers/codex.md index cca0fc42..cf98b7ff 100644 --- a/docs/src/content/docs/providers/codex.md +++ b/docs/src/content/docs/providers/codex.md @@ -119,6 +119,10 @@ An absent, malformed, or ambiguous identity does not reject the HTTP request; th Detected auto-review classifier subrequests are intentionally stateless even when valid session and Agent headers are present. They neither consume nor publish continuation or WebSocket ownership. +### Empty completions + +When Codex finishes a turn without output, the proxy retries with full context, up to 11 calls, and then answers 503. After resolved tool results it retries once and then returns an empty `end_turn`. Set `CCP_CODEX_EMPTY_COMPLETION=end_turn` or `codex.emptyCompletion: "end_turn"` to apply that one-retry rule to every request. With Claude Code, a turn that answers a Monitor event, and some of its end-of-turn background requests, can end empty by design. + ## Server compaction Claude Code normally compacts a long conversation by asking the active model to write a portable text summary. Later turns contain that summary instead of the original transcript. This works across providers, but a prose summary can flatten details from a long, tool-heavy Codex session. diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 08514b10..b1707a6f 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -113,6 +113,7 @@ Proxy URLs may use `http`, `https`, `socks4`, `socks4a`, `socks5`, or `socks5h`. | `CCP_CODEX_HEADER_TIMEOUT_MS` | `codex.headerTimeoutMs` | `300000` | Sets how long an HTTP-transport request waits for the Codex response headers. Values below `1000` are ignored. | | `CCP_CODEX_PREVIOUS_RESPONSE_ID` | `codex.previousResponseId` | `false` | Enables append-only WebSocket continuation for `1`, `true`, or `yes`. | | `CCP_CODEX_SERVER_COMPACTION` | `codex.serverCompaction` | `false` | Enables or disables native compaction for standard boolean words. | +| `CCP_CODEX_EMPTY_COMPLETION` | `codex.emptyCompletion` | `retry` | `end_turn` turns a repeated empty Codex completion into an empty `end_turn` for any request, after one retry. `retry` does that only after resolved tool results and otherwise retries, then answers 503. | | `CCP_CODEX_RESPONSES_API` | `codex.responsesApi` | `false` | Enables `/v1/responses` and `/v1/chat/completions` for every registered provider. Accepts `1`, `true`, or `yes`. | | `CCP_CODEX_FULL_LANE` | `codex.fullLane` | `false` | Enables parallel tool calls on the six [supported full-lane models](/providers/codex/#responses-lanes-and-parallel-tools). Luna and catalog-only models keep their default lane. Accepts `1`, `true`, or `yes`. | | `CCP_CODEX_IMAGES_API` | `codex.imagesApi` | `false` | Enables `/v1/images/generations` and `/v1/images/edits` for `1`, `true`, or `yes`. | diff --git a/src/config.rs b/src/config.rs index ee1ff80a..707a735c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -61,6 +61,8 @@ struct CodexConfig { pub request_compression: Option, #[serde(rename = "serverCompaction")] pub server_compaction: Option, + #[serde(rename = "emptyCompletion")] + pub empty_completion: Option, #[serde(rename = "responsesApi")] pub responses_api: Option, #[serde(rename = "fullLane")] @@ -367,6 +369,9 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec { if let Some(enabled) = codex.server_compaction { out.push(format!("codex.serverCompaction: {enabled}")); } + if let Some(mode) = codex.empty_completion.as_deref() { + out.push(format!("codex.emptyCompletion: {mode}")); + } if let Some(enabled) = codex.request_compression { out.push(format!("codex.requestCompression: {enabled}")); } @@ -752,6 +757,32 @@ pub fn codex_server_compaction() -> bool { false } +/// `end_turn` accepts a repeated empty Codex completion as a valid empty end_turn for any +/// request, after one retry. The default, `retry`, accepts it only after resolved tool +/// results and otherwise retries, then answers 503. +pub fn codex_empty_completion_end_turn() -> bool { + fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "end_turn" | "end-turn" => Some(true), + "retry" => Some(false), + _ => None, + } + } + let env: HashMap<_, _> = std::env::vars().collect(); + if let Some(mode) = env + .get("CCP_CODEX_EMPTY_COMPLETION") + .and_then(|raw| parse(raw)) + { + return mode; + } + let config_dir = paths::config_dir(); + read_file_config(&config_dir) + .and_then(|file| file.codex) + .and_then(|codex| codex.empty_completion) + .and_then(|raw| parse(&raw)) + .unwrap_or(false) +} + pub fn codex_responses_api() -> bool { let env: HashMap<_, _> = std::env::vars().collect(); if let Some(raw) = env.get("CCP_CODEX_RESPONSES_API") { @@ -1077,6 +1108,7 @@ mod tests { EnvGuard::unset("CCP_CODEX_REASONING_SUMMARY"), EnvGuard::unset("CCP_CODEX_REQUEST_COMPRESSION"), EnvGuard::unset("CCP_CODEX_SERVER_COMPACTION"), + EnvGuard::unset("CCP_CODEX_EMPTY_COMPLETION"), EnvGuard::unset("CCP_CODEX_RESPONSES_API"), EnvGuard::unset("CCP_CODEX_FULL_LANE"), EnvGuard::unset("CCP_CODEX_IMAGES_API"), @@ -1223,6 +1255,28 @@ mod tests { assert_eq!(codex_transport(), CodexTransport::Auto); } + #[test] + fn codex_empty_completion_mode_reads_env_and_config() { + let _guard = ENV_LOCK.lock().unwrap(); + let config = tempfile::TempDir::new().unwrap(); + let _env = isolated_env(&config); + assert!(!codex_empty_completion_end_turn()); + std::fs::write( + config.path().join("config.json"), + r#"{"codex":{"emptyCompletion":"end_turn"}}"#, + ) + .unwrap(); + assert!(codex_empty_completion_end_turn()); + unsafe { + std::env::set_var("CCP_CODEX_EMPTY_COMPLETION", "retry"); + } + assert!(!codex_empty_completion_end_turn()); + unsafe { + std::env::set_var("CCP_CODEX_EMPTY_COMPLETION", "end_turn"); + } + assert!(codex_empty_completion_end_turn()); + } + #[test] fn codex_transport_reads_env() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/src/providers/codex/mod.rs b/src/providers/codex/mod.rs index 4e0f9d12..7bd14ceb 100644 --- a/src/providers/codex/mod.rs +++ b/src/providers/codex/mod.rs @@ -345,7 +345,7 @@ impl CodexProvider { attempt: compaction_attempt, }, configured_transport, - tail_is_successful_tool_result(&body), + empty_completion_may_end_turn(&body), ) .await; log.info( @@ -369,7 +369,7 @@ impl CodexProvider { let request_continuation = continuation.clone(); let mut continuation = Some(continuation); let mut attempt = 0_u32; - let successful_tool_tail = tail_is_successful_tool_result(&body); + let successful_tool_tail = empty_completion_may_end_turn(&body); let upstream = loop { let response = match client .post_codex_for_owner(&translated, &ctx, continuation.as_ref()) @@ -1185,6 +1185,14 @@ fn accept_empty_completion(successful_tool_tail: bool, empty_attempts: u32) -> b successful_tool_tail && empty_attempts >= 1 } +/// Whether a repeated empty completion may become a valid empty end_turn for this request: +/// after resolved tool results, or for any request when `CCP_CODEX_EMPTY_COMPLETION` / +/// `codex.emptyCompletion` is `end_turn`. Claude Code's end-of-turn side queries and turns +/// that answer a Monitor event often end empty by design. +fn empty_completion_may_end_turn(body: &MessagesRequest) -> bool { + tail_is_successful_tool_result(body) || config::codex_empty_completion_end_turn() +} + fn empty_live_completion_error() -> client::CodexError { client::CodexError { status: 503,