Skip to content
Merged
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
9 changes: 8 additions & 1 deletion docs/src/content/docs/providers/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ Claude Code summary compaction requests are capped at low effort by default beca
## Tools and multimodal input

- Claude function tools and tool results map to Responses API function calls and outputs.
- Claude Code hosted web search maps to Codex `web_search`, including supported domain filters and forced tool choice.
- Claude Code's forced `web_search_20250305` subrequest uses Codex's standalone
`/alpha/search` endpoint. It keeps the resolved model, omits search reasoning,
and preserves non-empty domain filters, so Luna searches do not require a Sol
Responses turn. Automatic hosted-search requests remain on the full Responses
API because the standalone endpoint cannot decide whether to invoke a tool.
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.
- 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 Down
290 changes: 290 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 @@ -180,6 +181,27 @@ pub fn build_native_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 @@ -190,6 +212,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 @@ -434,6 +464,68 @@ 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));
}
retries += 1;
sleep(delay.wait_ms).await;
continue;
}
if !(200..300).contains(&response.status) {
return Err(codex_status_error(response));
}
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 @@ -1076,6 +1168,107 @@ impl CodexHttpClient {
transport: ActualTransport::Http,
})
}

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,
transport: ActualTransport::Http,
})
}
}

fn write_codex_http_request_capture(
Expand Down Expand Up @@ -1506,6 +1699,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 native_responses_replaces_auth_and_preserves_json_body() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
Expand Down Expand Up @@ -1678,6 +1897,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 @@ -116,7 +116,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