Skip to content
Open
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/<version>` | 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 |
Expand Down
37 changes: 37 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ struct CodexConfig {
pub user_agent: Option<String>,
#[serde(rename = "previousResponseId")]
pub previous_response_id: Option<bool>,
#[serde(rename = "fullLane")]
pub full_lane: Option<bool>,
#[serde(rename = "serviceTier")]
pub service_tier: Option<String>,
#[serde(rename = "reasoningSummary")]
Expand Down Expand Up @@ -448,6 +450,20 @@ pub fn codex_effort() -> Option<String> {
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<String> {
let env: HashMap<_, _> = std::env::vars().collect();
if let Some(raw) = env
Expand Down Expand Up @@ -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");
}
}

Expand All @@ -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();
Expand Down
37 changes: 36 additions & 1 deletion src/providers/codex/translate/model_allowlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down
3 changes: 3 additions & 0 deletions src/providers/codex/translate/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down