From ed42e3478521d27ac0a6b9f816449d79be9b1f23 Mon Sep 17 00:00:00 2001 From: su-record Date: Thu, 23 Jul 2026 00:02:18 +0000 Subject: [PATCH] codex: add fullLane config to escape Responses Lite for sol/terra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gpt-5.6 family is pinned to the Responses Lite lane, and the Lite lane hard-requires `parallel_tool_calls: false` — sending `true` is rejected with 400 unsupported_value ("X-OpenAI-Internal-Codex- Responses-Lite requires `parallel_tool_calls` to be false"). As a result these models emit at most one tool call per assistant turn, so Claude Code's parallel tool_use batching degrades into serialized round-trips on tool-heavy agentic work. gpt-5.6-sol and gpt-5.6-terra also exist on the full Responses lane (the same lane hosted web_search already upgrades luna to), where parallel tool calls work: with the full lane, a prompt asking for three independent file reads consistently produces three tool_use blocks in a single response, and prompt caching / continuation behave the same as on Lite in testing. Add `codex.fullLane` / `CCP_CODEX_FULL_LANE` to opt sol and terra into the full lane. The default is unchanged (Lite). gpt-5.6-luna ignores the override because the full lane resolves it to a `-free` variant and returns 404, matching the existing full_lane_web_search_model note. Answers the open question in #36: the Lite restriction is enforced by the backend, so flipping the default inside Lite is not possible — lane selection is the available lever. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TWQpX1wPzyQNa544xWuoiY --- README.md | 11 ++++++ src/config.rs | 37 +++++++++++++++++++ .../codex/translate/model_allowlist.rs | 37 ++++++++++++++++++- src/providers/codex/translate/request.rs | 3 ++ 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 08592e03..0aca89f9 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,16 @@ the UI as `◐ medium · /effort`) is forwarded as Codex `reasoning.effort` (`lo / `medium` / `high` / `xhigh` / `max`). An explicit `codex.effort` / `CCP_CODEX_EFFORT` override still takes precedence and can also force `none`. +Responses lanes and parallel tool calls: the gpt-5.6 family (`luna` / `sol` / +`terra`) is served through the Responses **Lite** lane by default. The Lite +lane rejects `parallel_tool_calls: true` (400 `unsupported_value`), so through +it a model emits at most one tool call per assistant turn — Claude Code's +parallel `tool_use` batching degrades into serialized round-trips. `gpt-5.6-sol` +and `gpt-5.6-terra` also exist on the full Responses lane, where parallel tool +calls work; set `codex.fullLane` / `CCP_CODEX_FULL_LANE` to opt them into it. +`gpt-5.6-luna` is Lite-only (the full lane resolves it to a `-free` variant and +returns 404) and ignores the override. + Reasoning summaries: when a Codex request has reasoning effort, the proxy asks Codex for `reasoning.summary: "auto"` and translates returned summary deltas into Anthropic `thinking` content blocks. Codex decides when a summary is useful, @@ -734,6 +744,7 @@ Windows, and at | `CCP_CODEX_BASE_URL` | `codex.baseUrl` | `https://chatgpt.com/backend-api/codex/responses` | Override the Codex Responses endpoint | | `CCP_CODEX_TRANSPORT` | `codex.transport` | `websocket` | Codex transport: `websocket`, `http`, or `auto` | | `CCP_CODEX_PREVIOUS_RESPONSE_ID` | `codex.previousResponseId` | `false` | Enable WebSocket continuation with `previous_response_id` when the request is append-only | +| `CCP_CODEX_FULL_LANE` | `codex.fullLane` | `false` | Route `gpt-5.6-sol` / `gpt-5.6-terra` through the full Responses lane instead of Responses Lite, enabling parallel tool calls (`gpt-5.6-luna` stays on Lite — full lane 404s it) | | `CCP_CODEX_ORIGINATOR` | `codex.originator` | `claude-code-proxy` | Override the `originator` header sent to Codex | | `CCP_CODEX_USER_AGENT` | `codex.userAgent` | `claude-code-proxy/` | Override the `User-Agent` header sent to Codex | | `CCP_KIMI_USER_AGENT` | `kimi.userAgent` | `KimiCLI/1.37.0` | Override the `User-Agent` header sent to Kimi | diff --git a/src/config.rs b/src/config.rs index df1b0288..353b1f82 100644 --- a/src/config.rs +++ b/src/config.rs @@ -54,6 +54,8 @@ struct CodexConfig { pub user_agent: Option, #[serde(rename = "previousResponseId")] pub previous_response_id: Option, + #[serde(rename = "fullLane")] + pub full_lane: Option, #[serde(rename = "serviceTier")] pub service_tier: Option, #[serde(rename = "reasoningSummary")] @@ -448,6 +450,20 @@ pub fn codex_effort() -> Option { None } +pub fn codex_full_lane() -> bool { + let env: HashMap<_, _> = std::env::vars().collect(); + if let Some(raw) = env.get("CCP_CODEX_FULL_LANE") { + return raw == "1" || raw == "true"; + } + let config_dir = paths::config_dir(); + if let Some(file) = read_file_config(&config_dir) + && let Some(codex) = file.codex + { + return codex.full_lane.unwrap_or(false); + } + false +} + pub fn codex_reasoning_summary() -> Option { let env: HashMap<_, _> = std::env::vars().collect(); if let Some(raw) = env @@ -592,6 +608,7 @@ mod tests { std::env::remove_var("CCP_LOG_VERBOSE"); std::env::remove_var("CCP_LOG_STDERR"); std::env::remove_var("CCP_CODEX_REASONING_SUMMARY"); + std::env::remove_var("CCP_CODEX_FULL_LANE"); } } @@ -605,6 +622,26 @@ mod tests { assert_eq!(load_config().bind_address, "127.0.0.1"); } + #[test] + fn codex_full_lane_defaults_off_reads_config_and_env_takes_precedence() { + let _guard = ENV_LOCK.lock().unwrap(); + clear_env(); + let config = tempfile::TempDir::new().unwrap(); + let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path()); + + assert!(!codex_full_lane()); + + std::fs::write( + config.path().join("config.json"), + r#"{"codex":{"fullLane":true}}"#, + ) + .unwrap(); + assert!(codex_full_lane()); + + let _env = EnvGuard::set("CCP_CODEX_FULL_LANE", "0"); + assert!(!codex_full_lane()); + } + #[test] fn bind_address_reads_config_and_env_takes_precedence() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/src/providers/codex/translate/model_allowlist.rs b/src/providers/codex/translate/model_allowlist.rs index 08df37d0..095c5dd6 100644 --- a/src/providers/codex/translate/model_allowlist.rs +++ b/src/providers/codex/translate/model_allowlist.rs @@ -108,8 +108,25 @@ pub fn assert_allowed_model(model: &str) -> Result<(), ModelNotAllowedError> { } } +/// The gpt-5.6 family defaults to the Responses Lite lane. The lite lane +/// requires `parallel_tool_calls: false` (the backend rejects the request +/// with 400 `unsupported_value` otherwise), so every tool call is serialized +/// into its own assistant turn there. `gpt-5.6-sol` and `gpt-5.6-terra` also +/// exist on the full Responses lane, where parallel tool calls work; +/// `codex.fullLane` / `CCP_CODEX_FULL_LANE` opts them into it. `gpt-5.6-luna` +/// stays on the lite lane unconditionally — see [`full_lane_web_search_model`]. pub fn uses_responses_lite(model: &str) -> bool { - matches!(model, "gpt-5.6-luna" | "gpt-5.6-sol" | "gpt-5.6-terra") + uses_responses_lite_with_full_lane(model, config::codex_full_lane()) +} + +fn uses_responses_lite_with_full_lane(model: &str, full_lane: bool) -> bool { + if model == "gpt-5.6-luna" { + return true; + } + if full_lane { + return false; + } + matches!(model, "gpt-5.6-sol" | "gpt-5.6-terra") } /// `gpt-5.6-luna` exists only behind the Responses Lite lane; the full @@ -145,6 +162,24 @@ mod tests { assert_eq!(r.model, "gpt-5.6-luna"); } + #[test] + fn responses_lite_defaults_for_56_family_only() { + for model in ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"] { + assert!(uses_responses_lite_with_full_lane(model, false)); + } + for model in ["gpt-5.3-codex", "gpt-5.4", "gpt-5.5"] { + assert!(!uses_responses_lite_with_full_lane(model, false)); + } + } + + #[test] + fn full_lane_opts_sol_and_terra_out_of_lite_but_never_luna() { + assert!(!uses_responses_lite_with_full_lane("gpt-5.6-sol", true)); + assert!(!uses_responses_lite_with_full_lane("gpt-5.6-terra", true)); + assert!(uses_responses_lite_with_full_lane("gpt-5.6-luna", true)); + assert!(!uses_responses_lite_with_full_lane("gpt-5.4", true)); + } + #[test] fn web_search_upgrades_luna_to_full_lane_sibling() { assert_eq!(full_lane_web_search_model("gpt-5.6-luna"), "gpt-5.6-sol"); diff --git a/src/providers/codex/translate/request.rs b/src/providers/codex/translate/request.rs index 6c8674eb..58dfb4ea 100644 --- a/src/providers/codex/translate/request.rs +++ b/src/providers/codex/translate/request.rs @@ -366,6 +366,9 @@ pub fn translate_request( "ws_request_header_x_openai_internal_codex_responses_lite".to_string(), "true".to_string(), )])); + // The lite lane hard-requires this: sending `true` is rejected with + // 400 unsupported_value ("X-OpenAI-Internal-Codex-Responses-Lite + // requires `parallel_tool_calls` to be false"). out.parallel_tool_calls = false; let mut prefix = Vec::new();