Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/src/content/docs/providers/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
54 changes: 54 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ struct CodexConfig {
pub request_compression: Option<bool>,
#[serde(rename = "serverCompaction")]
pub server_compaction: Option<bool>,
#[serde(rename = "emptyCompletion")]
pub empty_completion: Option<String>,
#[serde(rename = "responsesApi")]
pub responses_api: Option<bool>,
#[serde(rename = "fullLane")]
Expand Down Expand Up @@ -367,6 +369,9 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec<String> {
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}"));
}
Expand Down Expand Up @@ -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<bool> {
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") {
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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();
Expand Down
12 changes: 10 additions & 2 deletions src/providers/codex/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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())
Expand Down Expand Up @@ -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,
Expand Down