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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@ title: Changelog
description: Release notes for claude-code-proxy.
---

## Unreleased

- Codex WebSocket connection pacing is adaptive: connections start without
spacing while the origin accepts upgrades, widen after a rejected upgrade,
and relax again after sustained success. A fixed 1s spacing previously
capped each process near one generation per second. Set a floor with
`CCP_CODEX_WS_CONNECT_SPACING_MS` to restore fixed pacing.
- Codex standalone searches keep stable per-Agent sessions without colliding
with sibling Agents that share a Claude Code session, and align all upstream
search identity headers with the request body owner.

## v0.1.35 (2026-08-19)

- Grok web search works reliably with Claude Code, preserves other tools, and
Expand Down
21 changes: 21 additions & 0 deletions docs/src/content/docs/providers/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ Claude Code summary compaction requests are capped at low effort by default beca
Structured result DTOs map back to Anthropic `server_tool_use` and
`web_search_tool_result` blocks, while standalone text output remains text.
The proxy locally estimates input and output tokens and reports search usage.
Standalone search sessions use the same ownership identity as continuation:
Main keeps its Claude Code session ID, while each direct Agent gets a stable,
opaque owner derived from its session and Agent IDs. The parent Agent ID is
validation-only. Missing, malformed, or ambiguous identity headers use a fresh
random search ID instead of sharing state. The search body ID and upstream
`session_id`, `x-client-request-id`, and `x-codex-window-id` headers all carry
that same search owner (with the window header's required `:0` suffix).
- Top-level base64 user images map to `input_image`.
- Supported base64 images nested in tool results also map to `input_image`.
- Remote image URLs, malformed images, and unsupported tool-result image forms remain textual placeholders.
Expand All @@ -58,6 +65,20 @@ Claude Code summary compaction requests are capped at low effort by default beca

WebSocket is the default transport. Set `CCP_CODEX_TRANSPORT=http` for HTTP SSE, or `auto` to use WebSocket with HTTP fallback only when setup fails before a request is sent.

### Connection pacing

Fresh WebSocket connections are paced adaptively. While the origin accepts upgrades the
proxy opens connections without spacing them; each rejected upgrade widens the spacing
(1s, then doubling up to 8s), and a run of successful connections narrows it back down.

Because continuation is off by default, every request opens a fresh connection, so a
fixed spacing would cap a single process at roughly one generation per second no matter
how healthy the origin is. Pacing is therefore the price of an observed rejection rather
than a standing tax.

Set `CCP_CODEX_WS_CONNECT_SPACING_MS` (or `codex.websocketConnectSpacingMs`) to impose a
floor the proxy never relaxes below. The default is `0`.

WebSocket setup honors `HTTP_PROXY` for `ws://`, `HTTPS_PROXY` for the default `wss://` endpoint, `ALL_PROXY` as a fallback, and `NO_PROXY` exclusions. A normal HTTP proxy can therefore carry the default WebSocket connection with CONNECT; TUN mode is not required. Set proxy variables before starting the process and restart after changing them. For example, setting `HTTPS_PROXY` to `http://127.0.0.1:7890` sends HTTPS/WSS destinations through the HTTP proxy at port 7890; it does not require an `https://` proxy URL.

`CCP_CODEX_PREVIOUS_RESPONSE_ID=1` enables append-only WebSocket continuation. A valid identity containing only a Claude Code session ID owns the Main continuation for that session. Each valid direct Agent ID owns an independent continuation and reusable WebSocket within the same session. Nested Agents are keyed by their direct child ID; the parent ID is validated but does not become part of the owner key. The proxy sends `previous_response_id` only when the translated request shape and transcript extension are safe, and only on the exact live WebSocket that produced that response.
Expand Down
24 changes: 24 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ struct CodexConfig {
#[serde(rename = "model")]
pub model: Option<String>,
pub transport: Option<String>,
#[serde(rename = "websocketConnectSpacingMs")]
pub websocket_connect_spacing_ms: Option<u64>,
}

#[derive(Deserialize, Clone)]
Expand Down Expand Up @@ -875,6 +877,28 @@ fn parse_codex_transport(raw: &str) -> Option<CodexTransport> {
}
}

/// Plancher d'espacement des ouvertures de WebSocket Codex, en millisecondes.
///
/// Zéro (le défaut) laisse le processus ouvrir ses connexions sans attente tant que
/// l'origine n'a refusé aucun upgrade ; l'espacement s'élargit alors tout seul. Un
/// opérateur qui préfère un rythme garanti peut imposer un plancher.
pub fn codex_websocket_connect_spacing() -> std::time::Duration {
let env: HashMap<_, _> = std::env::vars().collect();
if let Some(raw) = env.get("CCP_CODEX_WS_CONNECT_SPACING_MS")
&& let Ok(ms) = raw.trim().parse::<u64>()
{
return std::time::Duration::from_millis(ms);
}
let config_dir = paths::config_dir();
if let Some(file) = read_file_config(&config_dir)
&& let Some(codex) = file.codex
&& let Some(ms) = codex.websocket_connect_spacing_ms
{
return std::time::Duration::from_millis(ms);
}
std::time::Duration::ZERO
}

pub fn codex_transport() -> CodexTransport {
let env: HashMap<_, _> = std::env::vars().collect();
if let Some(raw) = env.get("CCP_CODEX_TRANSPORT")
Expand Down
18 changes: 16 additions & 2 deletions src/providers/codex/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,19 @@ pub fn build_native_codex_headers(
pub fn build_codex_search_headers(
auth: &StoredAuth,
ctx: &RequestContext,
search_session_id: &str,
) -> Result<http::HeaderMap, CodexError> {
let mut headers = build_codex_headers(auth, ctx, false)?;
headers.insert("session_id", header_value("session_id", search_session_id)?);
headers.insert(
"x-client-request-id",
header_value("x-client-request-id", search_session_id)?,
);
let window_id = format!("{search_session_id}:0");
headers.insert(
"x-codex-window-id",
header_value("x-codex-window-id", &window_id)?,
);
headers.insert(
http::header::ACCEPT,
header_value("accept", "application/json")?,
Expand Down Expand Up @@ -1082,7 +1093,9 @@ impl CodexHttpClient {
let mut retries = 0_u32;

loop {
let response = self.attempt_post_search(&auth, &body_json, ctx).await?;
let response = self
.attempt_post_search(&auth, &body_json, ctx, &body.id)
.await?;
if response.status == 401 && !auth_refresh_attempted {
auth_refresh_attempted = true;
auth = self
Expand Down Expand Up @@ -2419,9 +2432,10 @@ impl CodexHttpClient {
auth: &StoredAuth,
body_json: &str,
ctx: &RequestContext,
search_session_id: &str,
) -> Result<CodexResponse, CodexError> {
let url = search_endpoint(&self.base_url);
let headers = build_codex_search_headers(auth, ctx)?;
let headers = build_codex_search_headers(auth, ctx, search_session_id)?;

if let Some(traffic) = ctx.traffic.as_deref() {
write_codex_http_request_capture(traffic, &url, &headers, body_json);
Expand Down
2 changes: 1 addition & 1 deletion src/providers/codex/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ impl CodexProvider {
let (search_request, query) = match search::build_search_request(
&body,
&resolved.model,
ctx.session_id.as_deref(),
conversation_identity.as_ref(),
) {
Ok(request) => request,
Err(error) => {
Expand Down
60 changes: 55 additions & 5 deletions src/providers/codex/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use serde_json::{Value, json};

use crate::anthropic::schema::MessagesRequest;
use crate::anthropic::sse::encode_sse_event;
use crate::request_identity::ConversationIdentity;
use crate::traffic::TrafficCapture;

use super::count_tokens::{approx_token_count, truncate_to_token_budget};
Expand Down Expand Up @@ -91,14 +92,15 @@ pub fn is_standalone_search_request(req: &MessagesRequest) -> bool {
pub fn build_search_request(
req: &MessagesRequest,
model: &str,
session_id: Option<&str>,
owner: Option<&ConversationIdentity>,
) -> Result<(SearchRequest, String), anyhow::Error> {
let query = extract_search_query(req)
.ok_or_else(|| anyhow::anyhow!("web_search request does not contain a text query"))?;
let input = search_input(req);
let filters = search_filters(req);
let id = session_id
.map(str::to_owned)
let id = owner
.map(ConversationIdentity::search_session_id)
.map(Into::into)
.unwrap_or_else(|| format!("search-{}", uuid::Uuid::new_v4()));

Ok((
Expand Down Expand Up @@ -504,6 +506,7 @@ fn emit(out: &mut Vec<u8>, traffic: Option<&TrafficCapture>, event: &str, data:
mod tests {
use super::*;
use crate::anthropic::sse::parse_sse_events;
use crate::request_identity::ConversationIdentity;

fn request() -> MessagesRequest {
serde_json::from_value(json!({
Expand All @@ -528,9 +531,10 @@ mod tests {
}

#[test]
fn request_preserves_luna_and_omits_reasoning() {
fn request_preserves_luna_and_main_search_owner() {
let owner = ConversationIdentity::Main("session-1".into());
let (request, query) =
build_search_request(&request(), "gpt-5.6-luna", Some("session-1")).unwrap();
build_search_request(&request(), "gpt-5.6-luna", Some(&owner)).unwrap();
assert_eq!(request.model, "gpt-5.6-luna");
assert_eq!(request.reasoning, None);
assert_eq!(request.id, "session-1");
Expand All @@ -543,6 +547,36 @@ mod tests {
);
}

#[test]
fn request_uses_stable_isolated_agent_search_owners() {
let agent = ConversationIdentity::Agent("session-a".into(), "agent-a".into());
let same_agent = ConversationIdentity::Agent("session-a".into(), "agent-a".into());
let sibling = ConversationIdentity::Agent("session-a".into(), "agent-b".into());
let other_session = ConversationIdentity::Agent("session-b".into(), "agent-a".into());

let id = build_search_request(&request(), "gpt-5.6-luna", Some(&agent))
.unwrap()
.0
.id;
let same_id = build_search_request(&request(), "gpt-5.6-luna", Some(&same_agent))
.unwrap()
.0
.id;
let sibling_id = build_search_request(&request(), "gpt-5.6-luna", Some(&sibling))
.unwrap()
.0
.id;
let other_session_id =
build_search_request(&request(), "gpt-5.6-luna", Some(&other_session))
.unwrap()
.0
.id;

assert_eq!(id, same_id);
assert_ne!(id, sibling_id);
assert_ne!(id, other_session_id);
}

#[test]
fn only_forced_claude_search_uses_standalone_endpoint() {
let forced = request();
Expand All @@ -555,6 +589,22 @@ mod tests {
assert!(!is_standalone_search_request(&automatic));
}

#[test]
fn absent_owner_uses_a_fresh_stateless_search_id() {
let first = build_search_request(&request(), "gpt-5.6-luna", None)
.unwrap()
.0
.id;
let second = build_search_request(&request(), "gpt-5.6-luna", None)
.unwrap()
.0
.id;

assert!(first.starts_with("search-"));
assert!(second.starts_with("search-"));
assert_ne!(first, second);
}

#[test]
fn search_input_uses_role_specific_content_and_recent_context() {
let mut req = request();
Expand Down
Loading