Codex Handbook
codex-api/tests/clients.rs 589 lines
#![allow(clippy::expect_used)]use std::sync::Arc;use std::sync::Mutex;use std::time::Duration;use anyhow::Result;use bytes::Bytes;use codex_api::ApiError;use codex_api::AuthError;use codex_api::AuthProvider;use codex_api::Compression;use codex_api::Provider;use codex_api::ResponsesApiRequest;use codex_api::ResponsesClient;use codex_api::ResponsesOptions;use codex_client::HttpTransport;use codex_client::Request;use codex_client::RequestBody;use codex_client::Response;use codex_client::StreamResponse;use codex_client::TransportError;use codex_protocol::models::ContentItem;use codex_protocol::models::ResponseItem;use codex_protocol::protocol::SessionSource;use codex_protocol::protocol::SubAgentSource;use http::HeaderMap;use http::HeaderValue;use http::StatusCode;use pretty_assertions::assert_eq;fn assert_path_ends_with(requests: &[Request], suffix: &str) {    assert_eq!(requests.len(), 1);    let url = &requests[0].url;    assert!(        url.ends_with(suffix),        "expected url to end with {suffix}, got {url}"    );}fn request_body_bytes(request: &Request) -> &[u8] {    let Some(RequestBody::EncodedJson(body)) = request.body.as_ref() else {        panic!("expected a prepared request body");    };    body.as_bytes()}#[derive(Debug, Default, Clone)]struct RecordingState {    stream_requests: Arc<Mutex<Vec<Request>>>,}impl RecordingState {    fn record(&self, req: Request) {        let mut guard = self            .stream_requests            .lock()            .expect("stream requests mutex should not be poisoned");        guard.push(req);    }    fn take_stream_requests(&self) -> Vec<Request> {        let mut guard = self            .stream_requests            .lock()            .expect("stream requests mutex should not be poisoned");        std::mem::take(&mut *guard)    }}#[derive(Clone)]struct RecordingTransport {    state: RecordingState,}impl RecordingTransport {    fn new(state: RecordingState) -> Self {        Self { state }    }}impl HttpTransport for RecordingTransport {    async fn execute(&self, _req: Request) -> Result<Response, TransportError> {        Err(TransportError::Build("execute should not run".to_string()))    }    async fn stream(&self, req: Request) -> Result<StreamResponse, TransportError> {        self.state.record(req);        let stream = futures::stream::iter(Vec::<Result<Bytes, TransportError>>::new());        Ok(StreamResponse {            status: StatusCode::OK,            headers: HeaderMap::new(),            bytes: Box::pin(stream),        })    }}#[derive(Clone, Default)]struct NoAuth;impl AuthProvider for NoAuth {    fn add_auth_headers(&self, _headers: &mut HeaderMap) {}}#[derive(Clone)]struct StaticAuth {    token: String,    account_id: String,}impl StaticAuth {    fn new(token: &str, account_id: &str) -> Self {        Self {            token: token.to_string(),            account_id: account_id.to_string(),        }    }}impl AuthProvider for StaticAuth {    fn add_auth_headers(&self, headers: &mut HeaderMap) {        let token = &self.token;        if let Ok(header) = HeaderValue::from_str(&format!("Bearer {token}")) {            headers.insert(http::header::AUTHORIZATION, header);        }        if let Ok(header) = HeaderValue::from_str(&self.account_id) {            headers.insert("ChatGPT-Account-ID", header);        }    }}fn provider(name: &str) -> Provider {    Provider {        name: name.to_string(),        base_url: "https://example.com/v1".to_string(),        query_params: None,        headers: HeaderMap::new(),        retry: codex_api::RetryConfig {            max_attempts: 1,            base_delay: Duration::from_millis(1),            retry_429: false,            retry_5xx: false,            retry_transport: true,        },        stream_idle_timeout: Duration::from_millis(10),    }}#[derive(Debug, Default)]struct FlakyTransportState {    attempts: i64,    requests: Vec<(RequestBody, HeaderMap, codex_client::RequestCompression)>,}#[derive(Clone)]struct FlakyTransport {    state: Arc<Mutex<FlakyTransportState>>,}impl Default for FlakyTransport {    fn default() -> Self {        Self::new()    }}impl FlakyTransport {    fn new() -> Self {        Self {            state: Arc::new(Mutex::new(FlakyTransportState::default())),        }    }    fn attempts(&self) -> i64 {        self.state            .lock()            .expect("flaky transport state mutex should not be poisoned")            .attempts    }    fn requests(&self) -> Vec<(RequestBody, HeaderMap, codex_client::RequestCompression)> {        self.state            .lock()            .expect("flaky transport state mutex should not be poisoned")            .requests            .clone()    }}#[derive(Clone)]struct FailsOnceAuth {    attempts: Arc<Mutex<i64>>,    error: Arc<AuthError>,}impl FailsOnceAuth {    fn transient() -> Self {        Self {            attempts: Arc::new(Mutex::new(0)),            error: Arc::new(AuthError::Transient(                "sts temporarily unavailable".to_string(),            )),        }    }    fn build() -> Self {        Self {            attempts: Arc::new(Mutex::new(0)),            error: Arc::new(AuthError::Build("invalid auth configuration".to_string())),        }    }    fn attempts(&self) -> i64 {        *self            .attempts            .lock()            .expect("auth attempts mutex should not be poisoned")    }    async fn apply_auth(&self, request: Request) -> Result<Request, AuthError> {        let mut attempts = self            .attempts            .lock()            .expect("auth attempts mutex should not be poisoned");        *attempts += 1;        if *attempts == 1 {            return match self.error.as_ref() {                AuthError::Build(message) => Err(AuthError::Build(message.clone())),                AuthError::Transient(message) => Err(AuthError::Transient(message.clone())),            };        }        Ok(request)    }}impl AuthProvider for FailsOnceAuth {    fn add_auth_headers(&self, _headers: &mut HeaderMap) {}    fn apply_auth(&self, request: Request) -> codex_api::AuthProviderFuture<'_> {        Box::pin(FailsOnceAuth::apply_auth(self, request))    }}impl HttpTransport for FlakyTransport {    async fn execute(&self, _req: Request) -> Result<Response, TransportError> {        Err(TransportError::Build("execute should not run".to_string()))    }    async fn stream(&self, req: Request) -> Result<StreamResponse, TransportError> {        let Some(body) = req.body.clone() else {            panic!("request should have a body");        };        let mut state = self            .state            .lock()            .expect("flaky transport state mutex should not be poisoned");        state.attempts += 1;        state            .requests            .push((body, req.headers.clone(), req.compression));        if state.attempts == 1 {            return Err(TransportError::Network("first attempt fails".to_string()));        }        let stream = futures::stream::iter(vec![Ok(Bytes::from(            r#"event: messagedata: {"id":"resp-1","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]}]}"#,        ))]);        Ok(StreamResponse {            status: StatusCode::OK,            headers: HeaderMap::new(),            bytes: Box::pin(stream),        })    }}#[tokio::test]async fn responses_client_uses_responses_path() -> Result<()> {    let state = RecordingState::default();    let transport = RecordingTransport::new(state.clone());    let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth));    let body = serde_json::json!({ "echo": true });    let _stream = client        .stream(            body,            HeaderMap::new(),            Compression::None,            /*turn_state*/ None,        )        .await?;    let requests = state.take_stream_requests();    assert_path_ends_with(&requests, "/responses");    Ok(())}#[tokio::test]async fn responses_client_stream_request_preserves_exact_json_body() -> Result<()> {    let state = RecordingState::default();    let transport = RecordingTransport::new(state.clone());    let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth));    let request = ResponsesApiRequest {        model: "gpt-test".into(),        instructions: "Say hi".into(),        input: vec![ResponseItem::Message {            id: Some("msg_1".into()),            role: "user".into(),            content: vec![ContentItem::InputText { text: "hi".into() }],            phase: None,            metadata: None,        }],        tools: Vec::new(),        tool_choice: "auto".into(),        parallel_tool_calls: false,        reasoning: None,        store: false,        stream: true,        include: Vec::new(),        service_tier: None,        prompt_cache_key: None,        text: None,        client_metadata: None,    };    let expected = serde_json::to_vec(&request)?;    let _stream = client        .stream_request(request, ResponsesOptions::default())        .await?;    let requests = state.take_stream_requests();    assert_eq!(requests.len(), 1);    let prepared = requests[0]        .prepare_body_for_send()        .expect("body should prepare");    assert_eq!(prepared.body.as_deref(), Some(expected.as_slice()));    assert_eq!(        prepared.headers.get(http::header::CONTENT_TYPE),        Some(&HeaderValue::from_static("application/json"))    );    Ok(())}#[tokio::test]async fn streaming_client_adds_auth_headers() -> Result<()> {    let state = RecordingState::default();    let transport = RecordingTransport::new(state.clone());    let auth = Arc::new(StaticAuth::new("secret-token", "acct-1"));    let client = ResponsesClient::new(transport, provider("openai"), auth);    let body = serde_json::json!({ "model": "gpt-test" });    let _stream = client        .stream(            body,            HeaderMap::new(),            Compression::None,            /*turn_state*/ None,        )        .await?;    let requests = state.take_stream_requests();    assert_eq!(requests.len(), 1);    let req = &requests[0];    let auth_header = req.headers.get(http::header::AUTHORIZATION);    assert!(auth_header.is_some(), "missing auth header");    assert_eq!(        auth_header.unwrap().to_str().ok(),        Some("Bearer secret-token")    );    let account_header = req.headers.get("ChatGPT-Account-ID");    assert!(account_header.is_some(), "missing account header");    assert_eq!(account_header.unwrap().to_str().ok(), Some("acct-1"));    let accept_header = req.headers.get(http::header::ACCEPT);    assert!(accept_header.is_some(), "missing Accept header");    assert_eq!(        accept_header.unwrap().to_str().ok(),        Some("text/event-stream")    );    Ok(())}#[tokio::test]async fn streaming_client_retries_on_transport_error() -> Result<()> {    let transport = FlakyTransport::new();    let mut provider = provider("openai");    provider.retry.max_attempts = 2;    let request = ResponsesApiRequest {        model: "gpt-test".into(),        instructions: "Say hi".into(),        input: Vec::new(),        tools: Vec::new(),        tool_choice: "auto".into(),        parallel_tool_calls: false,        reasoning: None,        store: false,        stream: true,        include: Vec::new(),        service_tier: None,        prompt_cache_key: None,        text: None,        client_metadata: None,    };    let client = ResponsesClient::new(transport.clone(), provider, Arc::new(NoAuth));    let _stream = client        .stream_request(            request,            ResponsesOptions {                compression: Compression::Zstd,                ..Default::default()            },        )        .await?;    assert_eq!(transport.attempts(), 2);    let requests = transport.requests();    assert_eq!(requests.len(), 2);    assert_eq!(requests[0], requests[1]);    let RequestBody::EncodedJson(first_body) = &requests[0].0 else {        panic!("expected an encoded JSON body");    };    let RequestBody::EncodedJson(second_body) = &requests[1].0 else {        panic!("expected an encoded JSON body");    };    assert_eq!(        first_body.as_bytes().as_ptr(),        second_body.as_bytes().as_ptr()    );    assert_eq!(        requests[0].1.get(http::header::CONTENT_ENCODING),        Some(&HeaderValue::from_static("zstd"))    );    assert_eq!(requests[0].2, codex_client::RequestCompression::None);    Ok(())}#[tokio::test]async fn streaming_client_retries_on_transient_auth_error() -> Result<()> {    let state = RecordingState::default();    let transport = RecordingTransport::new(state.clone());    let auth = FailsOnceAuth::transient();    let mut provider = provider("openai");    provider.retry.max_attempts = 2;    let client = ResponsesClient::new(transport, provider, Arc::new(auth.clone()));    let body = serde_json::json!({ "model": "gpt-test" });    let _stream = client        .stream(            body,            HeaderMap::new(),            Compression::None,            /*turn_state*/ None,        )        .await?;    assert_eq!(auth.attempts(), 2);    assert_eq!(state.take_stream_requests().len(), 1);    Ok(())}#[tokio::test]async fn streaming_client_does_not_retry_auth_build_error() -> Result<()> {    let state = RecordingState::default();    let transport = RecordingTransport::new(state.clone());    let auth = FailsOnceAuth::build();    let mut provider = provider("openai");    provider.retry.max_attempts = 2;    let client = ResponsesClient::new(transport, provider, Arc::new(auth.clone()));    let body = serde_json::json!({ "model": "gpt-test" });    let result = client        .stream(            body,            HeaderMap::new(),            Compression::None,            /*turn_state*/ None,        )        .await;    let err = result        .err()        .expect("auth build errors should fail without retry");    assert!(matches!(        err,        ApiError::Transport(TransportError::Build(message))            if message == "invalid auth configuration"    ));    assert_eq!(auth.attempts(), 1);    assert_eq!(state.take_stream_requests().len(), 0);    Ok(())}#[tokio::test]async fn azure_default_store_attaches_ids_and_headers() -> Result<()> {    let state = RecordingState::default();    let transport = RecordingTransport::new(state.clone());    let client = ResponsesClient::new(transport, provider("azure"), Arc::new(NoAuth));    let request = ResponsesApiRequest {        model: "gpt-test".into(),        instructions: "Say hi".into(),        input: vec![ResponseItem::Message {            id: Some("msg_1".into()),            role: "user".into(),            content: vec![ContentItem::InputText { text: "hi".into() }],            phase: None,            metadata: None,        }],        tools: Vec::new(),        tool_choice: "auto".into(),        parallel_tool_calls: false,        reasoning: None,        store: true,        stream: true,        include: Vec::new(),        service_tier: None,        prompt_cache_key: None,        text: None,        client_metadata: None,    };    let mut extra_headers = HeaderMap::new();    extra_headers.insert("x-test-header", HeaderValue::from_static("present"));    let _stream = client        .stream_request(            request,            ResponsesOptions {                session_id: Some("sess_123".into()),                thread_id: Some("thread_123".into()),                session_source: Some(SessionSource::SubAgent(SubAgentSource::Review)),                extra_headers,                compression: Compression::None,                turn_state: None,            },        )        .await?;    let requests = state.take_stream_requests();    assert_eq!(requests.len(), 1);    let req = &requests[0];    assert_eq!(        req.headers.get("session-id").and_then(|v| v.to_str().ok()),        Some("sess_123")    );    assert_eq!(        req.headers.get("thread-id").and_then(|v| v.to_str().ok()),        Some("thread_123")    );    assert_eq!(        req.headers            .get("x-client-request-id")            .and_then(|v| v.to_str().ok()),        Some("thread_123")    );    assert_eq!(        req.headers            .get("x-openai-subagent")            .and_then(|v| v.to_str().ok()),        Some("review")    );    assert_eq!(        req.headers            .get("x-test-header")            .and_then(|v| v.to_str().ok()),        Some("present")    );    let body: serde_json::Value = serde_json::from_slice(request_body_bytes(req))?;    let input_id = body        .get("input")        .and_then(|input| input.get(0))        .and_then(|item| item.get("id"))        .and_then(|id| id.as_str());    assert_eq!(input_id, Some("msg_1"));    Ok(())}