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
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,13 +279,17 @@ so simple prompts can emit no thinking block. Set `codex.reasoningSummary` /
`CCP_CODEX_REASONING_SUMMARY` to `off` or `none` to suppress summaries while
keeping `reasoning.effort` and encrypted continuation content.

Claude Code's hosted `web_search_20250305` tool is translated to Codex's native
Responses `web_search` tool with live external web access and non-empty native
domain filters. Forced searches use Codex's required `allowed_tools` form so
structured filters remain active. Codex does not expose a hosted-search limit,
so the Claude tool's `max_uses` value is not enforced. Codex hosted search calls
are emitted back to Claude Code as Anthropic `server_tool_use` and
`web_search_tool_result` blocks with
Claude Code's forced `web_search_20250305` subrequest is translated to Codex's
standalone `/alpha/search` endpoint, matching the current Codex CLI. The proxy
passes the resolved request model through unchanged, sends no search reasoning,
and preserves non-empty domain filters, so a Haiku/Luna search no longer needs
an extra Sol Responses turn. Automatic hosted-search requests stay on the full
Responses API because the standalone endpoint cannot decide whether to invoke a
tool. Codex does not expose a standalone-search limit, so the Claude tool's
`max_uses` value is not enforced. Structured result DTOs from Codex are emitted
as Anthropic `server_tool_use` and `web_search_tool_result` blocks, while the
standalone tool output is returned as text. The proxy locally estimates input
and output tokens and reports
`usage.server_tool_use.web_search_requests` so Claude Code can account for
completed searches.

Expand Down
295 changes: 295 additions & 0 deletions src/providers/codex/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use crate::traffic::TrafficCapture;
use super::auth::constants::{CODEX_API_ENDPOINT, ORIGINATOR, RESPONSES_LITE_ORIGINATOR};
use super::auth::manager::CodexAuthManager;
use super::auth::token_store::{DefaultCodexAuthStore, StoredAuth, file_store};
use super::search::{SearchRequest, SearchResponse};
use super::translate::request::ResponsesRequest;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -155,6 +156,27 @@ pub fn build_codex_headers(
Ok(headers)
}

pub fn build_codex_search_headers(
auth: &StoredAuth,
ctx: &RequestContext,
) -> Result<http::HeaderMap, CodexError> {
let mut headers = build_codex_headers(auth, ctx, false)?;
headers.insert(
http::header::ACCEPT,
header_value("accept", "application/json")?,
);
let originator = config::codex_originator(RESPONSES_LITE_ORIGINATOR);
headers.insert("originator", header_value("originator", &originator)?);
let user_agent = config::codex_user_agent(RESPONSES_LITE_ORIGINATOR);
if !user_agent.is_empty() {
headers.insert(
http::header::USER_AGENT,
header_value("user-agent", &user_agent)?,
);
}
Ok(headers)
}

fn header_value(name: &str, value: &str) -> Result<http::HeaderValue, CodexError> {
http::HeaderValue::from_str(value).map_err(|e| CodexError {
status: 500,
Expand All @@ -165,6 +187,14 @@ fn header_value(name: &str, value: &str) -> Result<http::HeaderValue, CodexError
})
}

fn search_endpoint(base_url: &str) -> String {
let base_url = base_url.trim_end_matches('/');
match base_url.strip_suffix("/responses") {
Some(api_root) => format!("{api_root}/alpha/search"),
None => format!("{base_url}/alpha/search"),
}
}

// ---------------------------------------------------------------------------
// WebSocket request shaping
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -296,6 +326,74 @@ impl CodexHttpClient {
.await
}

pub async fn post_search(
&self,
body: &SearchRequest,
ctx: &RequestContext,
) -> Result<SearchResponse, CodexError> {
let mut auth = self.auth_manager.get_auth().await.map_err(|e| CodexError {
status: 401,
message: "Auth error".to_string(),
detail: Some(e.to_string()),
retry_after: None,
origin: CodexErrorOrigin::Auth,
})?;
let body_json = serde_json::to_string(body).map_err(|e| CodexError {
status: 500,
message: "Failed to serialize search request".to_string(),
detail: Some(e.to_string()),
retry_after: None,
origin: CodexErrorOrigin::Http,
})?;
let mut auth_refresh_attempted = false;
let mut retries = 0_u32;

loop {
let response = self.attempt_post_search(&auth, &body_json, ctx).await?;
if response.status == 401 && !auth_refresh_attempted {
auth_refresh_attempted = true;
auth = self
.auth_manager
.force_refresh(&auth.access)
.await
.map_err(auth_refresh_error)?;
continue;
}
if should_retry_codex_status(response.status)
&& retries < MAX_BUFFERED_TRANSPORT_RETRIES
{
let retry_after = response
.headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case("retry-after"))
.map(|(_, value)| value.as_str());
let delay = compute_backoff_delay(retries, retry_after);
if delay.exceeds_budget {
return Err(codex_status_error(
response,
crate::config::CodexTransport::Http,
));
}
retries += 1;
sleep(delay.wait_ms).await;
continue;
}
if !(200..300).contains(&response.status) {
return Err(codex_status_error(
response,
crate::config::CodexTransport::Http,
));
}
return serde_json::from_slice(&response.body).map_err(|e| CodexError {
status: 502,
message: "Failed to decode Codex search response".to_string(),
detail: Some(e.to_string()),
retry_after: None,
origin: CodexErrorOrigin::Http,
});
}
}

async fn post_codex_with_transport(
&self,
body: &ResponsesRequest,
Expand Down Expand Up @@ -934,6 +1032,106 @@ impl CodexHttpClient {
headers,
})
}

async fn attempt_post_search(
&self,
auth: &StoredAuth,
body_json: &str,
ctx: &RequestContext,
) -> Result<CodexResponse, CodexError> {
let url = search_endpoint(&self.base_url);
let headers = build_codex_search_headers(auth, ctx)?;

if let Some(traffic) = ctx.traffic.as_deref() {
write_codex_http_request_capture(traffic, &url, &headers, body_json);
}

let mut request = self.client.post(&url);
for (key, value) in headers.iter() {
request = request.header(key.as_str(), value.as_bytes());
}
let started_at = Instant::now();
let mut response = tokio::time::timeout(
Duration::from_millis(self.header_timeout_ms),
request.body(body_json.to_string()).send(),
)
.await
.map_err(|_| CodexError {
status: 0,
message: format!(
"Timed out waiting {}ms for Codex search response headers",
self.header_timeout_ms
),
detail: None,
retry_after: None,
origin: CodexErrorOrigin::Http,
})?
.map_err(|e| CodexError {
status: 0,
message: format!("Codex search network error: {e}"),
detail: None,
retry_after: None,
origin: CodexErrorOrigin::Http,
})?;

let status = response.status().as_u16();
let headers: Vec<(String, String)> = response
.headers()
.iter()
.map(|(key, value)| {
(
key.to_string(),
value.to_str().unwrap_or_default().to_string(),
)
})
.collect();
let mut body = Vec::new();
let mut response_started = false;
loop {
let chunk = tokio::time::timeout(
Duration::from_millis(self.body_idle_timeout_ms),
response.chunk(),
)
.await
.map_err(|_| CodexError {
status: 0,
message: format!(
"Timed out waiting {}ms for the next Codex search response body chunk",
self.body_idle_timeout_ms
),
detail: Some("http_response_body".to_string()),
retry_after: None,
origin: CodexErrorOrigin::Http,
})?
.map_err(|e| CodexError {
status: 0,
message: format!("Transport error reading Codex search response body: {e}"),
detail: Some("http_response_body".to_string()),
retry_after: None,
origin: CodexErrorOrigin::Http,
})?;
let Some(chunk) = chunk else {
break;
};
if !response_started {
if let Some(monitor) = ctx.monitor.as_ref() {
monitor.generation_started(&ctx.req_id);
}
response_started = true;
}
body.extend_from_slice(&chunk);
}

if let Some(traffic) = ctx.traffic.as_deref() {
write_upstream_response_capture(traffic, status, started_at.elapsed(), &headers, &body);
}

Ok(CodexResponse {
body,
status,
headers,
})
}
}

fn write_codex_http_request_capture(
Expand Down Expand Up @@ -1358,6 +1556,32 @@ mod tests {
client
}

async fn read_http_request(stream: &mut tokio::net::TcpStream) -> Vec<u8> {
let mut request = Vec::new();
let mut chunk = [0_u8; 4096];
loop {
let read = stream.read(&mut chunk).await.unwrap();
assert!(read > 0, "request ended before its body was complete");
request.extend_from_slice(&chunk[..read]);
let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n") else {
continue;
};
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
if request.len() >= header_end + 4 + content_length {
return request;
}
}
}

#[tokio::test]
async fn buffered_http_retries_retryable_status() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
Expand Down Expand Up @@ -1395,6 +1619,77 @@ mod tests {
assert_eq!(response.body, b"data: keep\n\n");
}

#[tokio::test]
async fn standalone_search_posts_json_to_alpha_endpoint() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
let header_end = request
.windows(4)
.position(|part| part == b"\r\n\r\n")
.unwrap();
let headers = String::from_utf8_lossy(&request[..header_end]);
assert!(headers.starts_with("POST /alpha/search HTTP/1.1"));
assert!(
headers
.to_ascii_lowercase()
.contains("accept: application/json")
);
assert!(headers.contains("authorization: Bearer test"));
let body: serde_json::Value =
serde_json::from_slice(&request[header_end + 4..]).unwrap();
assert_eq!(body["model"], "gpt-5.6-luna");
assert!(body.get("reasoning").is_none());
assert_eq!(body["commands"]["search_query"][0]["q"], "find Codex");

let response = serde_json::to_vec(&serde_json::json!({
"encrypted_output": "opaque",
"output": "search output",
"results": [{
"type": "text_result",
"ref_id": "turn0search0",
"url": "https://example.com",
"title": "Example"
}]
}))
.unwrap();
let response_headers = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
response.len()
);
stream.write_all(response_headers.as_bytes()).await.unwrap();
stream.write_all(&response).await.unwrap();
});

let client = authenticated_http_test_client(format!("http://{addr}/responses"));
let request = super::super::search::SearchRequest {
id: "session".to_string(),
model: "gpt-5.6-luna".to_string(),
reasoning: None,
input: None,
commands: super::super::search::SearchCommands {
search_query: vec![super::super::search::SearchQuery {
q: "find Codex".to_string(),
}],
},
settings: super::super::search::SearchSettings {
filters: None,
allowed_callers: vec!["direct"],
external_web_access: true,
},
max_output_tokens: 2_500,
};
let response = client
.post_search(&request, &http_test_context())
.await
.unwrap();
server.await.unwrap();
assert_eq!(response.output, "search output");
assert_eq!(response.results.unwrap().len(), 1);
}

#[tokio::test]
async fn auto_falls_back_to_http_after_statusful_websocket_handshake_failure() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
Expand Down
2 changes: 1 addition & 1 deletion src/providers/codex/count_tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ fn count_tool_tokens(tools: &[ResponsesTool]) -> u64 {
total
}

fn approx_token_count(text: &str) -> u64 {
pub(crate) fn approx_token_count(text: &str) -> u64 {
if text.is_empty() {
return 0;
}
Expand Down
Loading