1use crate::attachments::validate_request_attachments;
8use crate::provider::LlmProvider;
9use crate::streaming::{
10 SseLineBuffer, StreamBox, StreamDelta, StreamErrorKind, reqwest_body_error_delta,
11 reqwest_error_delta,
12};
13use agent_sdk_foundation::llm::{
14 ChatOutcome, ChatRequest, ChatResponse, Content, ContentBlock, Effort, ResponseFormat,
15 StopReason, ThinkingConfig, ThinkingMode, ToolChoice, Usage,
16};
17use anyhow::{Context, Result};
18use async_trait::async_trait;
19use base64::Engine;
20use futures::{SinkExt, StreamExt};
21use reqwest::StatusCode;
22use serde::{Deserialize, Serialize};
23use std::collections::{HashMap, HashSet};
24use std::sync::Arc;
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::time::{Duration, Instant};
27use tokio::net::TcpStream;
28use tokio::sync::Mutex;
29use tokio::time::timeout;
30use tokio_tungstenite::connect_async;
31use tokio_tungstenite::tungstenite::Message as WebSocketMessage;
32use tokio_tungstenite::tungstenite::client::IntoClientRequest;
33use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
34
35const DEFAULT_BASE_URL: &str = "https://chatgpt.com/backend-api";
36
37const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
39const WEBSOCKET_IO_TIMEOUT: Duration = Duration::from_mins(2);
43const MAX_WEBSOCKET_SESSIONS: usize = 512;
45const OPENAI_CODEX_DISABLE_WEBSOCKETS_ENV: &str = "OPENAI_CODEX_DISABLE_WEBSOCKETS";
51
52fn parse_disable_websockets_value(value: Option<&str>) -> bool {
58 value.is_some_and(|value| {
59 matches!(
60 value.trim().to_ascii_lowercase().as_str(),
61 "1" | "true" | "yes" | "on"
62 )
63 })
64}
65
66fn websockets_disabled_from_env() -> bool {
68 parse_disable_websockets_value(
69 std::env::var(OPENAI_CODEX_DISABLE_WEBSOCKETS_ENV)
70 .ok()
71 .as_deref(),
72 )
73}
74
75fn build_http_client() -> reqwest::Client {
79 reqwest::Client::builder()
80 .connect_timeout(CONNECT_TIMEOUT)
81 .tcp_keepalive(CONNECT_TIMEOUT)
82 .build()
83 .unwrap_or_default()
84}
85const OPENAI_CODEX_JWT_CLAIM_PATH: &str = "https://api.openai.com/auth";
86const OPENAI_CODEX_ORIGINATOR: &str = "codex_cli_rs";
87const OPENAI_CODEX_RESPONSES_BETA_HEADER: &str = "responses=experimental";
88const OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER: &str = "responses_websockets=2026-02-06";
89const OPENAI_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
90const OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE: &str =
91 "websocket_connection_limit_reached";
92const OPENAI_RESPONSES_REASONING_PROVIDER: &str = "openai-responses";
93const OPENAI_MESSAGE_ITEM_TYPE: &str = "message";
94
95pub const MODEL_GPT54: &str = "gpt-5.4";
97
98pub const MODEL_GPT53_CODEX: &str = "gpt-5.3-codex";
100
101pub const MODEL_GPT52_CODEX: &str = "gpt-5.2-codex";
103
104#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
106#[serde(rename_all = "lowercase")]
107pub enum ReasoningEffort {
108 Low,
109 #[default]
110 Medium,
111 High,
112 #[serde(rename = "xhigh")]
114 XHigh,
115}
116
117#[derive(Clone)]
122pub struct OpenAICodexResponsesProvider {
123 client: reqwest::Client,
124 api_key: String,
125 model: String,
126 base_url: String,
127 thinking: Option<ThinkingConfig>,
128 account_id: Option<String>,
129 websocket_sessions: Arc<Mutex<HashMap<String, Arc<Mutex<WebsocketSessionState>>>>>,
130 websockets_disabled: bool,
135 websockets_unhealthy: Arc<AtomicBool>,
147}
148
149type CodexWebSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
150
151#[derive(Default)]
152struct WebsocketSessionState {
153 connection: Option<CodexWebSocket>,
154 last_request: Option<ApiStreamingRequest>,
155 last_response_id: Option<String>,
156 last_response_items: Vec<ApiInputItem>,
157 turn_state: Option<String>,
158 prewarmed: bool,
159 websocket_disabled: bool,
160 in_flight: bool,
166 last_used: Option<Instant>,
168}
169
170impl OpenAICodexResponsesProvider {
171 #[must_use]
173 pub fn new(api_key: String, model: String) -> Self {
174 Self {
175 client: build_http_client(),
176 api_key,
177 model,
178 base_url: DEFAULT_BASE_URL.to_owned(),
179 thinking: None,
180 account_id: None,
181 websocket_sessions: Arc::new(Mutex::new(HashMap::new())),
182 websockets_disabled: websockets_disabled_from_env(),
183 websockets_unhealthy: Arc::new(AtomicBool::new(false)),
184 }
185 }
186
187 #[must_use]
189 pub fn with_base_url(api_key: String, model: String, base_url: String) -> Self {
190 Self {
191 client: build_http_client(),
192 api_key,
193 model,
194 base_url,
195 thinking: None,
196 account_id: None,
197 websocket_sessions: Arc::new(Mutex::new(HashMap::new())),
198 websockets_disabled: websockets_disabled_from_env(),
199 websockets_unhealthy: Arc::new(AtomicBool::new(false)),
200 }
201 }
202
203 #[must_use]
205 pub fn gpt53_codex(api_key: String) -> Self {
206 Self::new(api_key, MODEL_GPT53_CODEX.to_owned())
207 }
208
209 #[must_use]
211 pub fn codex(api_key: String) -> Self {
212 Self::gpt53_codex(api_key)
213 }
214
215 #[must_use]
217 pub fn gpt54(api_key: String) -> Self {
218 Self::new(api_key, MODEL_GPT54.to_owned())
219 }
220
221 #[must_use]
223 pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
224 self.thinking = Some(thinking);
225 self
226 }
227
228 #[must_use]
230 pub fn with_account_id(mut self, account_id: impl Into<String>) -> Self {
231 self.account_id = Some(account_id.into());
232 self
233 }
234
235 #[must_use]
237 pub fn with_reasoning_effort(self, effort: ReasoningEffort) -> Self {
238 self.with_thinking(ThinkingConfig::default().with_effort(map_reasoning_effort(effort)))
239 }
240
241 #[must_use]
251 pub const fn with_websockets_disabled(mut self, disabled: bool) -> Self {
252 self.websockets_disabled = disabled;
253 self
254 }
255
256 fn skip_websocket(&self) -> bool {
260 self.websockets_disabled || self.websockets_unhealthy.load(Ordering::Relaxed)
261 }
262
263 const fn max_output_tokens(_request: &ChatRequest) -> Option<u32> {
275 None
276 }
277
278 fn build_headers(
279 &self,
280 streaming: bool,
281 session_id: Option<&str>,
282 turn_state: Option<&str>,
283 ) -> Result<reqwest::header::HeaderMap> {
284 self.build_headers_with_beta(
285 streaming,
286 session_id,
287 OPENAI_CODEX_RESPONSES_BETA_HEADER,
288 turn_state,
289 )
290 }
291
292 fn build_websocket_headers(
293 &self,
294 session_id: Option<&str>,
295 turn_state: Option<&str>,
296 ) -> Result<reqwest::header::HeaderMap> {
297 self.build_headers_with_beta(
298 false,
299 session_id,
300 OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER,
301 turn_state,
302 )
303 }
304
305 fn build_headers_with_beta(
306 &self,
307 streaming: bool,
308 session_id: Option<&str>,
309 beta_header: &'static str,
310 turn_state: Option<&str>,
311 ) -> Result<reqwest::header::HeaderMap> {
312 use reqwest::header::{
313 ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT,
314 };
315
316 let account_id = self
317 .account_id
318 .clone()
319 .map_or_else(|| extract_account_id(&self.api_key), Ok)
320 .context("failed to extract chatgpt account id from OpenAI Codex OAuth token")?;
321
322 let mut headers = HeaderMap::new();
323 headers.insert(
324 AUTHORIZATION,
325 HeaderValue::from_str(&format!("Bearer {}", self.api_key))?,
326 );
327 headers.insert("chatgpt-account-id", HeaderValue::from_str(&account_id)?);
328 headers.insert("OpenAI-Beta", HeaderValue::from_static(beta_header));
329 headers.insert(
330 "originator",
331 HeaderValue::from_static(OPENAI_CODEX_ORIGINATOR),
332 );
333 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
334 headers.insert(
335 USER_AGENT,
336 HeaderValue::from_str(&format!(
337 "{OPENAI_CODEX_ORIGINATOR}/{} ({} {})",
338 env!("CARGO_PKG_VERSION"),
339 std::env::consts::OS,
340 std::env::consts::ARCH,
341 ))?,
342 );
343 if streaming {
344 headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
345 }
346 if let Some(session_id) = session_id {
347 let session_id_header = HeaderValue::from_str(session_id)?;
348 headers.insert("session_id", session_id_header.clone());
349 headers.insert("x-client-request-id", session_id_header);
350 }
351 if let Some(turn_state) = turn_state {
352 headers.insert(
353 OPENAI_CODEX_TURN_STATE_HEADER,
354 HeaderValue::from_str(turn_state)?,
355 );
356 }
357
358 Ok(headers)
359 }
360
361 async fn websocket_session(&self, session_id: &str) -> Arc<Mutex<WebsocketSessionState>> {
362 let mut sessions = self.websocket_sessions.lock().await;
363 if !sessions.contains_key(session_id) && sessions.len() >= MAX_WEBSOCKET_SESSIONS {
364 evict_idle_sessions(&mut sessions);
365 }
366 sessions
367 .entry(session_id.to_string())
368 .or_insert_with(|| Arc::new(Mutex::new(WebsocketSessionState::default())))
369 .clone()
370 }
371
372 async fn connect_websocket(
373 &self,
374 session_id: Option<&str>,
375 turn_state: Option<&str>,
376 ) -> Result<(CodexWebSocket, Option<String>)> {
377 let headers = self.build_websocket_headers(session_id, turn_state)?;
378 let url = codex_websocket_url(&self.base_url)
379 .context("failed to build OpenAI Codex websocket URL")?;
380 let mut request = url
381 .as_str()
382 .into_client_request()
383 .context("failed to build OpenAI Codex websocket request")?;
384 request.headers_mut().extend(headers);
385
386 let (stream, response) = timeout(CONNECT_TIMEOUT, connect_async(request))
387 .await
388 .context("OpenAI Codex websocket connect timed out")?
389 .context("failed to connect OpenAI Codex websocket")?;
390 let turn_state = response
391 .headers()
392 .get(OPENAI_CODEX_TURN_STATE_HEADER)
393 .and_then(|value| value.to_str().ok())
394 .map(ToOwned::to_owned);
395 Ok((stream, turn_state))
396 }
397
398 fn map_response(api_response: ApiResponse) -> ChatResponse {
399 let refused = output_contains_refusal(&api_response.output);
400 let mut content = build_content_blocks(&api_response.output);
401 let has_tool_calls = content
402 .iter()
403 .any(|block| matches!(block, ContentBlock::ToolUse { .. }));
404 let stop_reason = if matches!(api_response.status, Some(ApiStatus::Incomplete)) {
405 Some(
406 api_response
407 .incomplete_details
408 .as_ref()
409 .and_then(|details| details.reason.as_deref())
410 .map_or(StopReason::Unknown, incomplete_stop_reason),
411 )
412 } else if refused {
413 Some(StopReason::Refusal)
414 } else if has_tool_calls {
415 Some(StopReason::ToolUse)
416 } else {
417 api_response.status.map(|status| match status {
418 ApiStatus::Completed => StopReason::EndTurn,
419 ApiStatus::Incomplete
424 | ApiStatus::Failed
425 | ApiStatus::InProgress
426 | ApiStatus::Queued
427 | ApiStatus::Cancelled
428 | ApiStatus::Other => StopReason::Unknown,
429 })
430 };
431
432 if stop_reason != Some(StopReason::ToolUse) {
433 content.retain(|block| !matches!(block, ContentBlock::ToolUse { .. }));
434 }
435
436 ChatResponse {
437 id: api_response.id,
438 content,
439 model: api_response.model,
440 stop_reason,
441 usage: api_response.usage.map_or(
442 Usage {
443 input_tokens: 0,
444 output_tokens: 0,
445 cached_input_tokens: 0,
446 cache_creation_input_tokens: 0,
447 },
448 |usage| usage_from_api_usage(&usage),
449 ),
450 }
451 }
452}
453
454#[async_trait]
455impl LlmProvider for OpenAICodexResponsesProvider {
456 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
457 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
458 Ok(thinking) => thinking,
459 Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
460 };
461 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
462 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
463 }
464 let reasoning = build_api_reasoning(thinking_config.as_ref());
465 let input = build_api_input(&request);
466 let max_output_tokens = Self::max_output_tokens(&request);
467 let prompt_cache_key = request.session_id.as_deref();
468 let tools: Option<Vec<ApiTool>> = request
469 .tools
470 .as_ref()
471 .map(|ts| ts.iter().cloned().map(convert_tool).collect());
472 let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
473 let text_format = request
474 .response_format
475 .as_ref()
476 .map(ApiResponseTextFormat::from);
477 let tool_choice = codex_tool_choice(request.tool_choice.as_ref());
478
479 let api_request = ApiResponsesRequest {
480 model: &self.model,
481 instructions: request.system.as_str(),
482 input: &input,
483 tools: tools.as_deref(),
484 max_output_tokens,
485 reasoning,
486 tool_choice: Some(tool_choice),
487 parallel_tool_calls: parallel_tool_calls.then_some(true),
488 store: false,
489 text: Some(ApiTextSettings {
490 verbosity: "medium",
491 format: text_format,
492 }),
493 include: Some(&["reasoning.encrypted_content"]),
494 prompt_cache_key,
495 };
496
497 log::debug!(
498 "OpenAI Codex request model={} max_tokens={}",
499 self.model,
500 request.max_tokens
501 );
502
503 let response = self
504 .client
505 .post(codex_url(&self.base_url))
506 .headers(self.build_headers(false, request.session_id.as_deref(), None)?)
507 .json(&api_request)
508 .send()
509 .await
510 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
511
512 let status = response.status();
513 let retry_after = if status == StatusCode::TOO_MANY_REQUESTS {
515 crate::http::retry_after_from_headers(response.headers())
516 } else {
517 None
518 };
519 let bytes = response
520 .bytes()
521 .await
522 .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
523
524 log::debug!(
525 "OpenAI Codex response status={} body_len={}",
526 status,
527 bytes.len()
528 );
529
530 if status == StatusCode::TOO_MANY_REQUESTS {
531 let body = String::from_utf8_lossy(&bytes);
532 let retry_after = retry_after
533 .or_else(|| codex_http_reset_after(&body))
534 .or_else(|| crate::retry_hints::openai_retry_delay(&body));
535 return Ok(ChatOutcome::RateLimited(retry_after));
536 }
537
538 if status.is_server_error() {
539 let body = String::from_utf8_lossy(&bytes);
540 log::error!("OpenAI Codex server error status={status} body={body}");
541 return Ok(ChatOutcome::ServerError(body.into_owned()));
542 }
543
544 if status.is_client_error() {
545 let body = String::from_utf8_lossy(&bytes);
546 log::warn!("OpenAI Codex client error status={status} body={body}");
547 return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
548 }
549
550 let api_response: ApiResponse = serde_json::from_slice(&bytes)
551 .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
552
553 if matches!(api_response.status, Some(ApiStatus::Failed)) {
558 let message = api_response
559 .error
560 .and_then(|error| error.message)
561 .unwrap_or_else(|| "OpenAI Codex reported status=failed".to_owned());
562 log::error!("OpenAI Codex generation failed: {message}");
563 return Ok(ChatOutcome::ServerError(message));
564 }
565
566 Ok(ChatOutcome::Success(Self::map_response(api_response)))
567 }
568
569 #[allow(clippy::too_many_lines)]
570 fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
571 Box::pin(async_stream::stream! {
572 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
573 Ok(thinking) => thinking,
574 Err(error) => {
575 yield Ok(StreamDelta::Error {
576 message: error.to_string(),
577 kind: StreamErrorKind::InvalidRequest,
578 });
579 return;
580 }
581 };
582 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
583 yield Ok(StreamDelta::Error {
584 message: error.to_string(),
585 kind: StreamErrorKind::InvalidRequest,
586 });
587 return;
588 }
589
590 let reasoning = build_api_reasoning(thinking_config.as_ref());
591 let input = build_api_input(&request);
592 let max_output_tokens = Self::max_output_tokens(&request);
593 let tools: Option<Vec<ApiTool>> = request
594 .tools
595 .as_ref()
596 .map(|ts| ts.iter().cloned().map(convert_tool).collect());
597 let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
598 let text_format = request.response_format.as_ref().map(ApiResponseTextFormat::from);
599 let tool_choice = codex_tool_choice(request.tool_choice.as_ref());
600 let api_request = ApiStreamingRequest {
601 model: self.model.clone(),
602 instructions: request.system.clone(),
603 input,
604 tools,
605 max_output_tokens,
606 reasoning,
607 tool_choice: Some(tool_choice),
608 parallel_tool_calls: parallel_tool_calls.then_some(true),
609 store: false,
610 text: Some(ApiTextSettings { verbosity: "medium", format: text_format }),
611 include: Some(vec!["reasoning.encrypted_content".to_string()]),
612 prompt_cache_key: request.session_id.clone(),
613 stream: true,
614 };
615
616 log::debug!("OpenAI Codex streaming request model={} max_tokens={}", self.model, request.max_tokens);
617
618 let mut sse_turn_state: Option<String> = None;
619
620 if let Some(session_id) = request.session_id.as_deref().filter(|_| !self.skip_websocket()) {
626 let session = self.websocket_session(session_id).await;
627 let mut websocket_session = session.lock().await;
628
629 let mark_websocket_transport_unhealthy = || {
636 self.websockets_unhealthy.store(true, Ordering::Relaxed);
637 };
638
639 if websocket_session.in_flight {
645 log::warn!(
646 "OpenAI Codex session {session_id} had an abandoned in-flight turn; resetting websocket state"
647 );
648 reset_websocket_connection(&mut websocket_session);
649 websocket_session.last_request = None;
650 websocket_session.last_response_id = None;
651 websocket_session.last_response_items.clear();
652 }
653 websocket_session.in_flight = true;
654 websocket_session.last_used = Some(Instant::now());
655
656 if !websocket_session.websocket_disabled {
657 'websocket_attempts: for attempt in 0..2 {
658 if websocket_session.connection.is_none() {
659 match self
660 .connect_websocket(
661 Some(session_id),
662 websocket_session.turn_state.as_deref(),
663 )
664 .await
665 {
666 Ok((connection, turn_state)) => {
667 websocket_session.connection = Some(connection);
668 if let Some(turn_state) = turn_state {
669 websocket_session.turn_state = Some(turn_state);
670 }
671 websocket_session.prewarmed = false;
672 }
673 Err(error) => {
674 log::warn!(
675 "OpenAI Codex websocket connect failed on attempt {}: {error:#}",
676 attempt + 1,
677 );
678 if attempt == 1 {
679 websocket_session.websocket_disabled = true;
680 mark_websocket_transport_unhealthy();
681 }
682 continue;
683 }
684 }
685 }
686
687 if websocket_session.connection.is_some()
688 && websocket_session.last_request.is_none()
689 && !websocket_session.prewarmed
690 {
691 let mut warmup_request = ApiWebsocketRequest::from(&api_request);
692 warmup_request.generate = Some(false);
693 let warmup_payload = match serde_json::to_string(&warmup_request) {
694 Ok(payload) => payload,
695 Err(error) => {
696 yield Ok(StreamDelta::Error {
697 message: format!(
698 "failed to encode websocket warmup request: {error}"
699 ),
700 kind: StreamErrorKind::InvalidRequest,
701 });
702 return;
703 }
704 };
705
706 let warmup_send_result = if let Some(connection) =
707 websocket_session.connection.as_mut()
708 {
709 timeout(
710 WEBSOCKET_IO_TIMEOUT,
711 connection.send(WebSocketMessage::Text(warmup_payload.into())),
712 )
713 .await
714 .unwrap_or(Err(
715 tokio_tungstenite::tungstenite::Error::ConnectionClosed,
716 ))
717 } else {
718 Err(tokio_tungstenite::tungstenite::Error::ConnectionClosed)
719 };
720
721 if let Err(error) = warmup_send_result {
722 log::warn!(
723 "OpenAI Codex websocket warmup send failed on attempt {}: {error}",
724 attempt + 1,
725 );
726 reset_websocket_connection(&mut websocket_session);
727 if attempt == 1 {
728 websocket_session.websocket_disabled = true;
729 mark_websocket_transport_unhealthy();
730 }
731 continue;
732 }
733
734 let mut warmup_response_id: Option<String> = None;
735 let mut warmup_response_items = Vec::new();
736
737 loop {
738 let message_result = if let Some(connection) =
739 websocket_session.connection.as_mut()
740 {
741 timeout(WEBSOCKET_IO_TIMEOUT, connection.next()).await.unwrap_or_else(|_| {
742 log::warn!("OpenAI Codex websocket warmup read timed out");
743 None
744 })
745 } else {
746 None
747 };
748 let Some(message_result) = message_result else {
749 log::warn!(
750 "OpenAI Codex websocket warmup closed before completion on attempt {}",
751 attempt + 1,
752 );
753 reset_websocket_connection(&mut websocket_session);
754 if attempt == 1 {
755 websocket_session.websocket_disabled = true;
756 mark_websocket_transport_unhealthy();
757 }
758 continue 'websocket_attempts;
759 };
760
761 let message = match message_result {
762 Ok(message) => message,
763 Err(error) => {
764 log::warn!(
765 "OpenAI Codex websocket warmup failed on attempt {}: {error}",
766 attempt + 1,
767 );
768 reset_websocket_connection(&mut websocket_session);
769 if attempt == 1 {
770 websocket_session.websocket_disabled = true;
771 mark_websocket_transport_unhealthy();
772 }
773 continue 'websocket_attempts;
774 }
775 };
776
777 match message {
778 WebSocketMessage::Text(text) => {
779 if let Some(error) =
780 parse_wrapped_websocket_error_event(&text)
781 {
782 log::warn!(
783 "OpenAI Codex websocket warmup wrapped error on attempt {} status={} message={}",
784 attempt + 1,
785 error.status,
786 error.message,
787 );
788 if is_websocket_quota_rejection(&error) {
796 let kind = websocket_error_kind(&error);
797 end_websocket_turn(&mut websocket_session);
798 yield Ok(StreamDelta::Error {
799 message: error.message,
800 kind,
801 });
802 return;
803 }
804 if error.status == StatusCode::UNAUTHORIZED
805 || error.status == StatusCode::UPGRADE_REQUIRED
806 || error.status.is_client_error()
807 {
808 websocket_session.websocket_disabled = true;
809 }
810 reset_websocket_connection(&mut websocket_session);
811 continue 'websocket_attempts;
812 }
813 let event = match decode_stream_event(&text) {
814 Ok(event) => event,
815 Err(error) => {
816 end_websocket_turn(
817 &mut websocket_session,
818 );
819 yield Ok(StreamDelta::Error {
820 message: error.to_string(),
821 kind: StreamErrorKind::ServerError,
822 });
823 return;
824 }
825 };
826 match event.r#type.as_str() {
827 "response.output_item.done" => {
828 let item = match decode_output_item(event.item) {
829 Ok(item) => item,
830 Err(error) => {
831 end_websocket_turn(
832 &mut websocket_session,
833 );
834 yield Ok(StreamDelta::Error {
835 message: error.to_string(),
836 kind: StreamErrorKind::ServerError,
837 });
838 return;
839 }
840 };
841 if let Some(item) = output_item_to_input_item(item) {
842 warmup_response_items.push(item);
843 }
844 }
845 "response.completed" | "response.done" => {
846 if let Some(resp) = event.response
847 && let Some(id) = resp.id
848 {
849 warmup_response_id = Some(id);
850 }
851 websocket_session.last_request =
852 Some(api_request.clone());
853 websocket_session.last_response_id =
854 warmup_response_id;
855 websocket_session.last_response_items =
856 warmup_response_items;
857 websocket_session.prewarmed = true;
858 break;
859 }
860 "response.incomplete" | "response.failed" => {
861 log::warn!(
862 "OpenAI Codex websocket warmup returned {} on attempt {}",
863 event.r#type,
864 attempt + 1,
865 );
866 reset_websocket_connection(&mut websocket_session);
867 if attempt == 1 {
868 websocket_session.websocket_disabled = true;
869 mark_websocket_transport_unhealthy();
870 }
871 continue 'websocket_attempts;
872 }
873 _ => {}
874 }
875 }
876 WebSocketMessage::Binary(bytes) => {
877 let text = match String::from_utf8(bytes.to_vec()) {
878 Ok(text) => text,
879 Err(error) => {
880 end_websocket_turn(
881 &mut websocket_session,
882 );
883 yield Ok(StreamDelta::Error {
884 message: format!(
885 "invalid OpenAI Codex websocket UTF-8: {error}"
886 ),
887 kind: StreamErrorKind::ServerError,
888 });
889 return;
890 }
891 };
892 if let Some(error) =
893 parse_wrapped_websocket_error_event(&text)
894 {
895 log::warn!(
896 "OpenAI Codex websocket warmup wrapped error on attempt {} status={} message={}",
897 attempt + 1,
898 error.status,
899 error.message,
900 );
901 if is_websocket_quota_rejection(&error) {
905 let kind = websocket_error_kind(&error);
906 end_websocket_turn(&mut websocket_session);
907 yield Ok(StreamDelta::Error {
908 message: error.message,
909 kind,
910 });
911 return;
912 }
913 if error.status == StatusCode::UNAUTHORIZED
914 || error.status == StatusCode::UPGRADE_REQUIRED
915 || error.status.is_client_error()
916 {
917 websocket_session.websocket_disabled = true;
918 }
919 reset_websocket_connection(&mut websocket_session);
920 continue 'websocket_attempts;
921 }
922
923 let event = match decode_stream_event(&text) {
924 Ok(event) => event,
925 Err(error) => {
926 end_websocket_turn(
927 &mut websocket_session,
928 );
929 yield Ok(StreamDelta::Error {
930 message: error.to_string(),
931 kind: StreamErrorKind::ServerError,
932 });
933 return;
934 }
935 };
936 match event.r#type.as_str() {
937 "response.output_item.done" => {
938 let item = match decode_output_item(event.item) {
939 Ok(item) => item,
940 Err(error) => {
941 end_websocket_turn(
942 &mut websocket_session,
943 );
944 yield Ok(StreamDelta::Error {
945 message: error.to_string(),
946 kind: StreamErrorKind::ServerError,
947 });
948 return;
949 }
950 };
951 if let Some(item) = output_item_to_input_item(item) {
952 warmup_response_items.push(item);
953 }
954 }
955 "response.completed" | "response.done" => {
956 if let Some(resp) = event.response
957 && let Some(id) = resp.id
958 {
959 warmup_response_id = Some(id);
960 }
961 websocket_session.last_request =
962 Some(api_request.clone());
963 websocket_session.last_response_id =
964 warmup_response_id;
965 websocket_session.last_response_items =
966 warmup_response_items;
967 websocket_session.prewarmed = true;
968 break;
969 }
970 "response.incomplete" | "response.failed" => {
971 log::warn!(
972 "OpenAI Codex websocket warmup returned {} on attempt {}",
973 event.r#type,
974 attempt + 1,
975 );
976 reset_websocket_connection(&mut websocket_session);
977 if attempt == 1 {
978 websocket_session.websocket_disabled = true;
979 mark_websocket_transport_unhealthy();
980 }
981 continue 'websocket_attempts;
982 }
983 _ => {}
984 }
985 }
986 WebSocketMessage::Ping(payload) => {
987 if let Some(connection) =
988 websocket_session.connection.as_mut()
989 && let Err(error) = connection
990 .send(WebSocketMessage::Pong(payload))
991 .await
992 {
993 log::warn!(
994 "OpenAI Codex websocket warmup pong failed on attempt {}: {error}",
995 attempt + 1,
996 );
997 reset_websocket_connection(&mut websocket_session);
998 if attempt == 1 {
999 websocket_session.websocket_disabled = true;
1000 mark_websocket_transport_unhealthy();
1001 }
1002 continue 'websocket_attempts;
1003 }
1004 }
1005 WebSocketMessage::Pong(_) | WebSocketMessage::Frame(_) => {}
1006 WebSocketMessage::Close(_) => {
1007 log::warn!(
1008 "OpenAI Codex websocket warmup closed on attempt {}",
1009 attempt + 1,
1010 );
1011 reset_websocket_connection(&mut websocket_session);
1012 if attempt == 1 {
1013 websocket_session.websocket_disabled = true;
1014 mark_websocket_transport_unhealthy();
1015 }
1016 continue 'websocket_attempts;
1017 }
1018 }
1019 }
1020 }
1021
1022 let websocket_request = prepare_websocket_request(
1023 &api_request,
1024 &websocket_session,
1025 websocket_session.prewarmed,
1026 );
1027 let request_payload = match serde_json::to_string(&websocket_request) {
1028 Ok(payload) => payload,
1029 Err(error) => {
1030 yield Ok(StreamDelta::Error {
1031 message: format!(
1032 "failed to encode websocket request: {error}"
1033 ),
1034 kind: StreamErrorKind::InvalidRequest,
1035 });
1036 return;
1037 }
1038 };
1039
1040 let send_result = if let Some(connection) = websocket_session.connection.as_mut() {
1041 timeout(
1042 WEBSOCKET_IO_TIMEOUT,
1043 connection.send(WebSocketMessage::Text(request_payload.into())),
1044 )
1045 .await
1046 .unwrap_or(Err(
1047 tokio_tungstenite::tungstenite::Error::ConnectionClosed,
1048 ))
1049 } else {
1050 Err(tokio_tungstenite::tungstenite::Error::ConnectionClosed)
1051 };
1052
1053 if let Err(error) = send_result {
1054 log::warn!(
1055 "OpenAI Codex websocket send failed on attempt {}: {error}",
1056 attempt + 1,
1057 );
1058 reset_websocket_connection(&mut websocket_session);
1059 if attempt == 1 {
1060 websocket_session.websocket_disabled = true;
1061 mark_websocket_transport_unhealthy();
1062 }
1063 continue;
1064 }
1065
1066 let mut usage: Option<Usage> = None;
1067 let mut tool_calls: HashMap<String, ToolCallAccumulator> = HashMap::new();
1068 let mut response_id: Option<String> = None;
1069 let mut response_items = Vec::new();
1070 let mut streamed_reasoning_summaries = HashSet::new();
1071 let mut emitted_output = false;
1072 let mut refused = false;
1073
1074 loop {
1075 let message_result = if let Some(connection) =
1076 websocket_session.connection.as_mut()
1077 {
1078 timeout(WEBSOCKET_IO_TIMEOUT, connection.next()).await.unwrap_or_else(|_| {
1079 log::warn!("OpenAI Codex websocket read timed out");
1080 None
1081 })
1082 } else {
1083 None
1084 };
1085 let Some(message_result) = message_result else {
1086 if emitted_output {
1087 end_websocket_turn(&mut websocket_session);
1088 yield Ok(StreamDelta::Error {
1089 message: "websocket closed before response.completed"
1090 .to_string(),
1091 kind: StreamErrorKind::ConnectionLost,
1092 });
1093 return;
1094 }
1095 reset_websocket_connection(&mut websocket_session);
1096 if attempt == 1 {
1097 websocket_session.websocket_disabled = true;
1098 mark_websocket_transport_unhealthy();
1099 }
1100 continue 'websocket_attempts;
1101 };
1102
1103 let message = match message_result {
1104 Ok(message) => message,
1105 Err(error) => {
1106 if emitted_output {
1107 end_websocket_turn(&mut websocket_session);
1108 yield Ok(StreamDelta::Error {
1109 message: format!("websocket error: {error}"),
1110 kind: websocket_transport_error_kind(&error),
1111 });
1112 return;
1113 }
1114 reset_websocket_connection(&mut websocket_session);
1115 if attempt == 1 {
1116 websocket_session.websocket_disabled = true;
1117 mark_websocket_transport_unhealthy();
1118 }
1119 continue 'websocket_attempts;
1120 }
1121 };
1122
1123 match message {
1124 WebSocketMessage::Text(text) => {
1125 if let Some(error) = parse_wrapped_websocket_error_event(&text)
1126 {
1127 let kind = websocket_error_kind(&error);
1128 if emitted_output || is_websocket_quota_rejection(&error) {
1134 end_websocket_turn(&mut websocket_session);
1135 yield Ok(StreamDelta::Error {
1136 message: error.message,
1137 kind,
1138 });
1139 return;
1140 }
1141 if error.status == StatusCode::UNAUTHORIZED
1142 || error.status == StatusCode::UPGRADE_REQUIRED
1143 || error.status.is_client_error()
1144 {
1145 websocket_session.websocket_disabled = true;
1146 }
1147 reset_websocket_connection(&mut websocket_session);
1148 continue 'websocket_attempts;
1149 }
1150 let event = match decode_stream_event(&text) {
1151 Ok(event) => event,
1152 Err(error) => {
1153 end_websocket_turn(&mut websocket_session);
1154 yield Ok(StreamDelta::Error {
1155 message: error.to_string(),
1156 kind: StreamErrorKind::ServerError,
1157 });
1158 return;
1159 }
1160 };
1161 match event.r#type.as_str() {
1162 "response.output_text.delta" => {
1163 if let Some(delta) = event.delta {
1164 emitted_output = true;
1165 yield Ok(StreamDelta::TextDelta {
1166 delta,
1167 block_index: output_block_index(event.output_index),
1168 });
1169 }
1170 }
1171 "response.refusal.delta" => {
1172 refused = true;
1173 if let Some(delta) = event.delta {
1174 emitted_output = true;
1175 yield Ok(StreamDelta::TextDelta {
1176 delta,
1177 block_index: output_block_index(
1178 event.output_index,
1179 ),
1180 });
1181 }
1182 }
1183 "response.reasoning_summary_text.delta" => {
1184 if let Some(delta) = event.delta {
1185 let output_index = event.output_index.unwrap_or(0);
1186 streamed_reasoning_summaries.insert(output_index);
1187 emitted_output = true;
1188 yield Ok(StreamDelta::ThinkingDelta {
1189 delta,
1190 block_index: reasoning_summary_block_index(
1191 Some(output_index),
1192 ),
1193 });
1194 }
1195 }
1196 "response.function_call_arguments.delta" => {
1197 let block_index = event
1198 .output_index
1199 .map(|index| index.saturating_mul(2));
1200 if let (Some(call_id), Some(delta)) =
1201 (event.call_id, event.delta)
1202 {
1203 emitted_output = true;
1204 let order = tool_calls.len();
1205 let acc = tool_calls
1206 .entry(call_id.clone())
1207 .or_insert_with(|| ToolCallAccumulator {
1208 id: call_id,
1209 name: event.name.unwrap_or_default(),
1210 arguments: String::new(),
1211 order,
1212 block_index,
1213 });
1214 acc.arguments.push_str(&delta);
1215 }
1216 }
1217 "response.output_item.done" => {
1218 let item = match decode_output_item(event.item) {
1219 Ok(item) => item,
1220 Err(error) => {
1221 end_websocket_turn(
1222 &mut websocket_session,
1223 );
1224 yield Ok(StreamDelta::Error {
1225 message: error.to_string(),
1226 kind: StreamErrorKind::ServerError,
1227 });
1228 return;
1229 }
1230 };
1231 let block_index = event.output_index.unwrap_or(0);
1232 accumulate_completed_tool_call(
1233 &item,
1234 block_index,
1235 &mut tool_calls,
1236 );
1237 let include_summary = !streamed_reasoning_summaries
1238 .contains(&block_index);
1239 for delta in output_item_stream_deltas(
1240 &item,
1241 block_index,
1242 include_summary,
1243 ) {
1244 emitted_output = true;
1245 yield Ok(delta);
1246 }
1247 if let Some(item) = output_item_to_input_item(item) {
1248 response_items.push(item);
1249 }
1250 }
1251 "response.completed"
1252 | "response.incomplete"
1253 | "response.done" => {
1254 let response_status = event
1255 .response
1256 .as_ref()
1257 .and_then(|response| response.status);
1258 let incomplete_reason = event
1259 .response
1260 .as_ref()
1261 .and_then(|response| {
1262 response.incomplete_details.as_ref()
1263 })
1264 .and_then(|details| details.reason.clone());
1265 if let Some(resp) = event.response {
1266 if let Some(u) = resp.usage {
1267 usage = Some(usage_from_api_usage(&u));
1268 }
1269 if let Some(id) = resp.id {
1270 response_id = Some(id);
1271 }
1272 }
1273 let final_status = match event.r#type.as_str() {
1274 "response.incomplete" => {
1275 Some(ApiStatus::Incomplete)
1276 }
1277 "response.done" => response_status
1278 .or(Some(ApiStatus::Completed)),
1279 _ => Some(ApiStatus::Completed),
1280 };
1281 let stop_reason = stop_reason_from_stream_state(
1282 &tool_calls,
1283 final_status,
1284 refused,
1285 incomplete_reason.as_deref(),
1286 );
1287 if stop_reason == Some(StopReason::ToolUse) {
1288 for delta in
1289 emit_accumulated_tool_calls(&tool_calls)
1290 {
1291 yield Ok(delta);
1292 }
1293 }
1294 if let Some(u) = usage.take() {
1295 yield Ok(StreamDelta::Usage(u));
1296 }
1297 websocket_session.last_request = Some(api_request.clone());
1298 websocket_session.last_response_id = response_id;
1299 websocket_session.last_response_items = response_items;
1300 websocket_session.prewarmed = false;
1301 websocket_session.in_flight = false;
1305 yield Ok(StreamDelta::Done {
1306 stop_reason,
1307 });
1308 return;
1309 }
1310 "response.failed" => {
1311 websocket_session.last_request = None;
1312 websocket_session.last_response_id = None;
1313 websocket_session.last_response_items.clear();
1314 websocket_session.prewarmed = false;
1315 websocket_session.in_flight = false;
1318 let failure =
1319 codex_response_failed_error(event.response);
1320 if let Some(usage) = failure.usage {
1321 yield Ok(StreamDelta::Usage(usage));
1322 }
1323 yield Ok(StreamDelta::Error {
1324 message: failure.message,
1325 kind: failure.kind,
1326 });
1327 return;
1328 }
1329 _ => {}
1330 }
1331 }
1332 WebSocketMessage::Binary(bytes) => {
1333 let text = match String::from_utf8(bytes.to_vec()) {
1334 Ok(text) => text,
1335 Err(error) => {
1336 end_websocket_turn(&mut websocket_session);
1337 yield Ok(StreamDelta::Error {
1338 message: format!(
1339 "invalid OpenAI Codex websocket UTF-8: {error}"
1340 ),
1341 kind: StreamErrorKind::ServerError,
1342 });
1343 return;
1344 }
1345 };
1346 if let Some(error) =
1347 parse_wrapped_websocket_error_event(&text)
1348 {
1349 let kind = websocket_error_kind(&error);
1350 if emitted_output
1354 || is_websocket_quota_rejection(&error)
1355 {
1356 end_websocket_turn(&mut websocket_session);
1357 yield Ok(StreamDelta::Error {
1358 message: error.message,
1359 kind,
1360 });
1361 return;
1362 }
1363 if error.status == StatusCode::UNAUTHORIZED
1364 || error.status == StatusCode::UPGRADE_REQUIRED
1365 || error.status.is_client_error()
1366 {
1367 websocket_session.websocket_disabled = true;
1368 }
1369 reset_websocket_connection(&mut websocket_session);
1370 continue 'websocket_attempts;
1371 }
1372
1373 let event = match decode_stream_event(&text) {
1374 Ok(event) => event,
1375 Err(error) => {
1376 end_websocket_turn(
1377 &mut websocket_session,
1378 );
1379 yield Ok(StreamDelta::Error {
1380 message: error.to_string(),
1381 kind: StreamErrorKind::ServerError,
1382 });
1383 return;
1384 }
1385 };
1386 match event.r#type.as_str() {
1387 "response.output_text.delta" => {
1388 if let Some(delta) = event.delta {
1389 emitted_output = true;
1390 yield Ok(StreamDelta::TextDelta {
1391 delta,
1392 block_index: output_block_index(event.output_index),
1393 });
1394 }
1395 }
1396 "response.refusal.delta" => {
1397 refused = true;
1398 if let Some(delta) = event.delta {
1399 emitted_output = true;
1400 yield Ok(StreamDelta::TextDelta {
1401 delta,
1402 block_index: output_block_index(
1403 event.output_index,
1404 ),
1405 });
1406 }
1407 }
1408 "response.reasoning_summary_text.delta" => {
1409 if let Some(delta) = event.delta {
1410 let output_index =
1411 event.output_index.unwrap_or(0);
1412 streamed_reasoning_summaries
1413 .insert(output_index);
1414 emitted_output = true;
1415 yield Ok(StreamDelta::ThinkingDelta {
1416 delta,
1417 block_index:
1418 reasoning_summary_block_index(Some(
1419 output_index,
1420 )),
1421 });
1422 }
1423 }
1424 "response.function_call_arguments.delta" => {
1425 let block_index = event
1426 .output_index
1427 .map(|index| index.saturating_mul(2));
1428 if let (Some(call_id), Some(delta)) =
1429 (event.call_id, event.delta)
1430 {
1431 emitted_output = true;
1432 let order = tool_calls.len();
1433 let acc = tool_calls
1434 .entry(call_id.clone())
1435 .or_insert_with(|| ToolCallAccumulator {
1436 id: call_id,
1437 name: event.name.unwrap_or_default(),
1438 arguments: String::new(),
1439 order,
1440 block_index,
1441 });
1442 acc.arguments.push_str(&delta);
1443 }
1444 }
1445 "response.output_item.done" => {
1446 let item =
1447 match decode_output_item(event.item) {
1448 Ok(item) => item,
1449 Err(error) => {
1450 end_websocket_turn(
1451 &mut websocket_session,
1452 );
1453 yield Ok(StreamDelta::Error {
1454 message: error.to_string(),
1455 kind: StreamErrorKind::ServerError,
1456 });
1457 return;
1458 }
1459 };
1460 let block_index =
1461 event.output_index.unwrap_or(0);
1462 accumulate_completed_tool_call(
1463 &item,
1464 block_index,
1465 &mut tool_calls,
1466 );
1467 let include_summary =
1468 !streamed_reasoning_summaries
1469 .contains(&block_index);
1470 for delta in output_item_stream_deltas(
1471 &item,
1472 block_index,
1473 include_summary,
1474 ) {
1475 emitted_output = true;
1476 yield Ok(delta);
1477 }
1478 if let Some(item) =
1479 output_item_to_input_item(item)
1480 {
1481 response_items.push(item);
1482 }
1483 }
1484 "response.completed"
1485 | "response.incomplete"
1486 | "response.done" => {
1487 let response_status = event
1488 .response
1489 .as_ref()
1490 .and_then(|response| response.status);
1491 let incomplete_reason = event
1492 .response
1493 .as_ref()
1494 .and_then(|response| {
1495 response.incomplete_details.as_ref()
1496 })
1497 .and_then(|details| details.reason.clone());
1498 if let Some(resp) = event.response {
1499 if let Some(u) = resp.usage {
1500 usage = Some(usage_from_api_usage(&u));
1501 }
1502 if let Some(id) = resp.id {
1503 response_id = Some(id);
1504 }
1505 }
1506 let final_status =
1507 match event.r#type.as_str() {
1508 "response.incomplete" => {
1509 Some(ApiStatus::Incomplete)
1510 }
1511 "response.done" => response_status
1512 .or(Some(ApiStatus::Completed)),
1513 _ => Some(ApiStatus::Completed),
1514 };
1515 let stop_reason =
1516 stop_reason_from_stream_state(
1517 &tool_calls,
1518 final_status,
1519 refused,
1520 incomplete_reason.as_deref(),
1521 );
1522 if stop_reason
1523 == Some(StopReason::ToolUse)
1524 {
1525 for delta in
1526 emit_accumulated_tool_calls(&tool_calls)
1527 {
1528 yield Ok(delta);
1529 }
1530 }
1531 if let Some(u) = usage.take() {
1532 yield Ok(StreamDelta::Usage(u));
1533 }
1534 websocket_session.last_request =
1535 Some(api_request.clone());
1536 websocket_session.last_response_id = response_id;
1537 websocket_session.last_response_items =
1538 response_items;
1539 websocket_session.prewarmed = false;
1540 websocket_session.in_flight = false;
1544 yield Ok(StreamDelta::Done {
1545 stop_reason,
1546 });
1547 return;
1548 }
1549 "response.failed" => {
1550 websocket_session.last_request = None;
1551 websocket_session.last_response_id = None;
1552 websocket_session.last_response_items.clear();
1553 websocket_session.prewarmed = false;
1554 websocket_session.in_flight = false;
1558 let failure =
1559 codex_response_failed_error(event.response);
1560 if let Some(usage) = failure.usage {
1561 yield Ok(StreamDelta::Usage(usage));
1562 }
1563 yield Ok(StreamDelta::Error {
1564 message: failure.message,
1565 kind: failure.kind,
1566 });
1567 return;
1568 }
1569 _ => {}
1570 }
1571 }
1572 WebSocketMessage::Ping(payload) => {
1573 if let Some(connection) = websocket_session.connection.as_mut()
1574 && let Err(error) = connection
1575 .send(WebSocketMessage::Pong(payload))
1576 .await
1577 {
1578 if emitted_output {
1579 end_websocket_turn(&mut websocket_session);
1580 yield Ok(StreamDelta::Error {
1581 message: format!("websocket pong failed: {error}"),
1582 kind: websocket_transport_error_kind(&error),
1583 });
1584 return;
1585 }
1586 reset_websocket_connection(&mut websocket_session);
1587 if attempt == 1 {
1588 websocket_session.websocket_disabled = true;
1589 mark_websocket_transport_unhealthy();
1590 }
1591 continue 'websocket_attempts;
1592 }
1593 }
1594 WebSocketMessage::Pong(_) | WebSocketMessage::Frame(_) => {}
1595 WebSocketMessage::Close(_) => {
1596 if emitted_output {
1597 end_websocket_turn(&mut websocket_session);
1598 yield Ok(StreamDelta::Error {
1599 message: "websocket closed before response.completed"
1600 .to_string(),
1601 kind: StreamErrorKind::ServerError,
1602 });
1603 return;
1604 }
1605 reset_websocket_connection(&mut websocket_session);
1606 if attempt == 1 {
1607 websocket_session.websocket_disabled = true;
1608 mark_websocket_transport_unhealthy();
1609 }
1610 continue 'websocket_attempts;
1611 }
1612 }
1613 }
1614 }
1615 }
1616 websocket_session.in_flight = false;
1620 sse_turn_state = websocket_session.turn_state.clone();
1621 drop(websocket_session);
1622 }
1623
1624 let headers = match self.build_headers(
1625 true,
1626 request.session_id.as_deref(),
1627 sse_turn_state.as_deref(),
1628 ) {
1629 Ok(headers) => headers,
1630 Err(error) => {
1631 yield Ok(StreamDelta::Error {
1632 message: error.to_string(),
1633 kind: StreamErrorKind::InvalidRequest,
1634 });
1635 return;
1636 }
1637 };
1638
1639 let response = match self
1640 .client
1641 .post(codex_url(&self.base_url))
1642 .headers(headers)
1643 .json(&api_request)
1644 .send()
1645 .await
1646 {
1647 Ok(response) => response,
1648 Err(error) => {
1649 yield Ok(reqwest_error_delta("request failed", &error));
1650 return;
1651 }
1652 };
1653
1654 let status = response.status();
1655 if !status.is_success() {
1656 let header_hint = crate::http::retry_after_from_headers(response.headers());
1658 let body = response.text().await.unwrap_or_default();
1659 let kind = if status == StatusCode::TOO_MANY_REQUESTS {
1660 StreamErrorKind::RateLimited(
1661 header_hint
1662 .or_else(|| codex_http_reset_after(&body))
1663 .or_else(|| crate::retry_hints::openai_retry_delay(&body)),
1664 )
1665 } else if status.is_server_error() {
1666 StreamErrorKind::ServerError
1667 } else {
1668 StreamErrorKind::InvalidRequest
1669 };
1670 log::warn!("OpenAI Codex error status={status} body={body}");
1671 yield Ok(StreamDelta::Error { message: body, kind });
1672 return;
1673 }
1674
1675 if let Some(session_id) = request.session_id.as_deref() {
1676 let turn_state = response
1677 .headers()
1678 .get(OPENAI_CODEX_TURN_STATE_HEADER)
1679 .and_then(|value| value.to_str().ok())
1680 .map(ToOwned::to_owned);
1681 if let Some(turn_state) = turn_state {
1682 let session = self.websocket_session(session_id).await;
1683 let mut websocket_session = session.lock().await;
1684 websocket_session.turn_state = Some(turn_state);
1685 }
1686 }
1687
1688 let mut sse = SseLineBuffer::new();
1689 let mut stream = response.bytes_stream();
1690 let mut usage: Option<Usage> = None;
1691 let mut tool_calls: HashMap<String, ToolCallAccumulator> = HashMap::new();
1692 let mut final_status: Option<ApiStatus> = None;
1693 let mut streamed_reasoning_summaries = HashSet::new();
1694 let mut refused = false;
1695 let mut incomplete_reason: Option<String> = None;
1696
1697 while let Some(chunk_result) = stream.next().await {
1698 let chunk = match chunk_result {
1699 Ok(chunk) => chunk,
1700 Err(error) => {
1701 yield Ok(reqwest_body_error_delta("stream error", &error));
1702 return;
1703 }
1704 };
1705 sse.extend(&chunk);
1706
1707 while let Some(line) = sse.next_line() {
1708 let line = line.trim();
1709 if line.is_empty() {
1710 continue;
1711 }
1712
1713 let Some(data) = line.strip_prefix("data: ") else {
1714 continue;
1715 };
1716
1717 if data == "[DONE]" {
1718 let Some(stop_reason) =
1719 stop_reason_from_stream_state(
1720 &tool_calls,
1721 final_status,
1722 refused,
1723 incomplete_reason.as_deref(),
1724 )
1725 else {
1726 yield Ok(StreamDelta::Error {
1727 message: "OpenAI Codex stream sent [DONE] before a terminal response event"
1728 .to_owned(),
1729 kind: StreamErrorKind::ServerError,
1730 });
1731 return;
1732 };
1733 if stop_reason == StopReason::ToolUse {
1734 for delta in emit_accumulated_tool_calls(&tool_calls) {
1735 yield Ok(delta);
1736 }
1737 }
1738 if let Some(u) = usage.take() {
1739 yield Ok(StreamDelta::Usage(u));
1740 }
1741 yield Ok(StreamDelta::Done {
1742 stop_reason: Some(stop_reason),
1743 });
1744 return;
1745 }
1746
1747 let event = match decode_stream_event(data) {
1748 Ok(event) => event,
1749 Err(error) => {
1750 yield Ok(StreamDelta::Error {
1751 message: format!(
1752 "invalid OpenAI Codex Responses stream event: {error}"
1753 ),
1754 kind: StreamErrorKind::ServerError,
1755 });
1756 return;
1757 }
1758 };
1759 match event.r#type.as_str() {
1760 "response.output_text.delta" => {
1761 if let Some(delta) = event.delta {
1762 yield Ok(StreamDelta::TextDelta {
1763 delta,
1764 block_index: output_block_index(event.output_index),
1765 });
1766 }
1767 }
1768 "response.refusal.delta" => {
1769 refused = true;
1770 if let Some(delta) = event.delta {
1771 yield Ok(StreamDelta::TextDelta {
1772 delta,
1773 block_index: output_block_index(event.output_index),
1774 });
1775 }
1776 }
1777 "response.reasoning_summary_text.delta" => {
1778 if let Some(delta) = event.delta {
1779 let output_index = event.output_index.unwrap_or(0);
1780 streamed_reasoning_summaries.insert(output_index);
1781 yield Ok(StreamDelta::ThinkingDelta {
1782 delta,
1783 block_index: reasoning_summary_block_index(Some(
1784 output_index,
1785 )),
1786 });
1787 }
1788 }
1789 "response.function_call_arguments.delta" => {
1790 let block_index = event
1791 .output_index
1792 .map(|index| index.saturating_mul(2));
1793 if let (Some(call_id), Some(delta)) = (event.call_id, event.delta) {
1794 let order = tool_calls.len();
1795 let acc = tool_calls.entry(call_id.clone()).or_insert_with(|| {
1796 ToolCallAccumulator {
1797 id: call_id,
1798 name: event.name.unwrap_or_default(),
1799 arguments: String::new(),
1800 order,
1801 block_index,
1802 }
1803 });
1804 acc.arguments.push_str(&delta);
1805 }
1806 }
1807 "response.output_item.done" => {
1808 let item = match decode_output_item(event.item) {
1809 Ok(item) => item,
1810 Err(error) => {
1811 yield Ok(StreamDelta::Error {
1812 message: error.to_string(),
1813 kind: StreamErrorKind::ServerError,
1814 });
1815 return;
1816 }
1817 };
1818 let block_index = event.output_index.unwrap_or(0);
1819 accumulate_completed_tool_call(
1820 &item,
1821 block_index,
1822 &mut tool_calls,
1823 );
1824 let include_summary =
1825 !streamed_reasoning_summaries.contains(&block_index);
1826 for delta in output_item_stream_deltas(
1827 &item,
1828 block_index,
1829 include_summary,
1830 ) {
1831 yield Ok(delta);
1832 }
1833 }
1834 "response.completed" | "response.incomplete" | "response.done" => {
1835 let response_status = event
1836 .response
1837 .as_ref()
1838 .and_then(|response| response.status);
1839 incomplete_reason = event
1840 .response
1841 .as_ref()
1842 .and_then(|response| response.incomplete_details.as_ref())
1843 .and_then(|details| details.reason.clone());
1844 if let Some(resp) = event.response
1845 && let Some(u) = resp.usage
1846 {
1847 usage = Some(usage_from_api_usage(&u));
1848 }
1849 final_status = match event.r#type.as_str() {
1850 "response.incomplete" => Some(ApiStatus::Incomplete),
1851 "response.done" => {
1852 response_status.or(Some(ApiStatus::Completed))
1853 }
1854 _ => Some(ApiStatus::Completed),
1855 };
1856 }
1857 "response.failed" => {
1858 let failure = codex_response_failed_error(event.response);
1859 if let Some(usage) = failure.usage {
1860 yield Ok(StreamDelta::Usage(usage));
1861 }
1862 yield Ok(StreamDelta::Error {
1863 message: failure.message,
1864 kind: failure.kind,
1865 });
1866 return;
1867 }
1868 _ => {}
1869 }
1870 }
1871 }
1872
1873 let Some(stop_reason) = stop_reason_from_stream_state(
1874 &tool_calls,
1875 final_status,
1876 refused,
1877 incomplete_reason.as_deref(),
1878 ) else {
1879 yield Ok(StreamDelta::Error {
1880 message: "OpenAI Codex stream ended before a terminal response event".to_owned(),
1881 kind: StreamErrorKind::ServerError,
1882 });
1883 return;
1884 };
1885 if stop_reason == StopReason::ToolUse {
1886 for delta in emit_accumulated_tool_calls(&tool_calls) {
1887 yield Ok(delta);
1888 }
1889 }
1890 if let Some(u) = usage {
1891 yield Ok(StreamDelta::Usage(u));
1892 }
1893 yield Ok(StreamDelta::Done {
1894 stop_reason: Some(stop_reason),
1895 });
1896 })
1897 }
1898
1899 async fn probe_connectivity(&self) -> bool {
1900 crate::provider::probe_http_reachability(&self.client, &self.base_url).await
1901 }
1902
1903 fn model(&self) -> &str {
1904 &self.model
1905 }
1906
1907 fn provider(&self) -> &'static str {
1908 "openai-codex"
1909 }
1910
1911 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
1912 self.thinking.as_ref()
1913 }
1914}
1915
1916fn build_api_input(request: &ChatRequest) -> Vec<ApiInputItem> {
1921 let mut items = Vec::new();
1922
1923 for msg in &request.messages {
1926 let role = match msg.role {
1927 agent_sdk_foundation::llm::Role::User => ApiRole::User,
1928 agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
1929 };
1930 match &msg.content {
1931 Content::Text(text) => {
1932 items.push(ApiInputItem::Message(ApiMessage {
1933 role,
1934 content: ApiMessageContent::Text(text.clone()),
1935 phase: api_message_phase(role, false),
1936 }));
1937 }
1938 Content::Blocks(blocks) => append_block_input(&mut items, role, blocks),
1939 }
1940 }
1941
1942 items
1943}
1944
1945fn append_block_input(items: &mut Vec<ApiInputItem>, role: ApiRole, blocks: &[ContentBlock]) {
1946 let mut content_parts = Vec::new();
1947 let mut phase = api_message_phase(
1948 role,
1949 blocks
1950 .iter()
1951 .any(|block| matches!(block, ContentBlock::ToolUse { .. })),
1952 );
1953
1954 for block in blocks {
1955 match block {
1956 ContentBlock::Text { text } => {
1957 let part = if matches!(role, ApiRole::Assistant) {
1958 ApiInputContent::OutputText { text: text.clone() }
1959 } else {
1960 ApiInputContent::InputText { text: text.clone() }
1961 };
1962 content_parts.push(part);
1963 }
1964 ContentBlock::OpaqueReasoning { provider, data }
1965 if matches!(role, ApiRole::Assistant)
1966 && is_message_state_marker(provider, data) =>
1967 {
1968 flush_message_parts(items, role, phase.clone(), &mut content_parts);
1969 phase = data
1970 .get("phase")
1971 .and_then(serde_json::Value::as_str)
1972 .map(ToOwned::to_owned);
1973 }
1974 ContentBlock::OpaqueReasoning { provider, data }
1975 if provider == OPENAI_RESPONSES_REASONING_PROVIDER
1976 && data.get("type").and_then(serde_json::Value::as_str)
1977 == Some("reasoning") =>
1978 {
1979 flush_message_parts(items, role, phase.clone(), &mut content_parts);
1980 items.push(ApiInputItem::OpaqueReasoning(data.clone()));
1981 }
1982 ContentBlock::Thinking { .. }
1983 | ContentBlock::RedactedThinking { .. }
1984 | ContentBlock::OpaqueReasoning { .. } => {}
1985 ContentBlock::Image { source } => content_parts.push(ApiInputContent::Image {
1986 image_url: format!("data:{};base64,{}", source.media_type, source.data),
1987 }),
1988 ContentBlock::Document { source } => content_parts.push(ApiInputContent::File {
1989 filename: suggested_filename(&source.media_type),
1990 file_data: format!("data:{};base64,{}", source.media_type, source.data),
1991 }),
1992 ContentBlock::ToolUse {
1993 id, name, input, ..
1994 } => {
1995 flush_message_parts(items, role, phase.clone(), &mut content_parts);
1996 items.push(ApiInputItem::FunctionCall(ApiFunctionCall::new(
1997 id.clone(),
1998 name.clone(),
1999 serde_json::to_string(input).unwrap_or_default(),
2000 )));
2001 }
2002 ContentBlock::ToolResult {
2003 tool_use_id,
2004 content,
2005 ..
2006 } => {
2007 flush_message_parts(items, role, phase.clone(), &mut content_parts);
2008 items.push(ApiInputItem::FunctionCallOutput(
2009 ApiFunctionCallOutput::new(tool_use_id.clone(), content.clone()),
2010 ));
2011 }
2012 _ => log::warn!("Skipping unrecognized OpenAI Responses content block"),
2013 }
2014 }
2015
2016 flush_message_parts(items, role, phase, &mut content_parts);
2017}
2018
2019fn api_message_phase(role: ApiRole, has_tool_use: bool) -> Option<String> {
2020 match (role, has_tool_use) {
2021 (ApiRole::Assistant, true) => Some("commentary".to_owned()),
2022 (ApiRole::Assistant, false) => Some("final_answer".to_owned()),
2023 (ApiRole::User, _) => None,
2024 }
2025}
2026
2027fn message_state_marker(role: &str, phase: Option<&str>) -> serde_json::Value {
2028 let mut marker = serde_json::Map::new();
2029 marker.insert(
2030 "type".to_owned(),
2031 serde_json::Value::String(OPENAI_MESSAGE_ITEM_TYPE.to_owned()),
2032 );
2033 marker.insert(
2034 "role".to_owned(),
2035 serde_json::Value::String(role.to_owned()),
2036 );
2037 if let Some(phase) = phase {
2038 marker.insert(
2039 "phase".to_owned(),
2040 serde_json::Value::String(phase.to_owned()),
2041 );
2042 }
2043 serde_json::Value::Object(marker)
2044}
2045
2046fn is_message_state_marker(provider: &str, data: &serde_json::Value) -> bool {
2047 provider == OPENAI_RESPONSES_REASONING_PROVIDER
2048 && data.get("type").and_then(serde_json::Value::as_str) == Some(OPENAI_MESSAGE_ITEM_TYPE)
2049 && data.get("content").is_none()
2050}
2051
2052fn flush_message_parts(
2053 items: &mut Vec<ApiInputItem>,
2054 role: ApiRole,
2055 phase: Option<String>,
2056 content_parts: &mut Vec<ApiInputContent>,
2057) {
2058 if content_parts.is_empty() {
2059 return;
2060 }
2061 items.push(ApiInputItem::Message(ApiMessage {
2062 role,
2063 content: ApiMessageContent::Parts(std::mem::take(content_parts)),
2064 phase,
2065 }));
2066}
2067
2068fn fix_schema_for_strict_mode(schema: &mut serde_json::Value) {
2076 let Some(obj) = schema.as_object_mut() else {
2077 return;
2078 };
2079
2080 let is_object_type = obj
2082 .get("type")
2083 .is_some_and(|t| t.as_str() == Some("object"));
2084
2085 if is_object_type {
2086 obj.insert(
2088 "additionalProperties".to_owned(),
2089 serde_json::Value::Bool(false),
2090 );
2091
2092 obj.entry("properties".to_owned())
2094 .or_insert_with(|| serde_json::json!({}));
2095 obj.entry("required".to_owned())
2096 .or_insert_with(|| serde_json::json!([]));
2097
2098 let originally_required: std::collections::HashSet<String> = obj
2100 .get("required")
2101 .and_then(|v| v.as_array())
2102 .map(|arr| {
2103 arr.iter()
2104 .filter_map(|v| v.as_str().map(String::from))
2105 .collect()
2106 })
2107 .unwrap_or_default();
2108
2109 if let Some(serde_json::Value::Object(props)) = obj.get_mut("properties") {
2111 for (key, prop_schema) in props.iter_mut() {
2112 if !originally_required.contains(key) {
2113 make_nullable(prop_schema);
2114 }
2115 }
2116 }
2117
2118 if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
2120 let all_keys: Vec<serde_json::Value> = props
2121 .keys()
2122 .map(|k| serde_json::Value::String(k.clone()))
2123 .collect();
2124 obj.insert("required".to_owned(), serde_json::Value::Array(all_keys));
2125 }
2126 }
2127
2128 if let Some(props) = obj.get_mut("properties")
2130 && let Some(props_obj) = props.as_object_mut()
2131 {
2132 for prop_schema in props_obj.values_mut() {
2133 fix_schema_for_strict_mode(prop_schema);
2134 }
2135 }
2136
2137 if let Some(items) = obj.get_mut("items") {
2139 fix_schema_for_strict_mode(items);
2140 }
2141
2142 for key in ["anyOf", "oneOf", "allOf"] {
2144 if let Some(arr) = obj.get_mut(key)
2145 && let Some(arr_items) = arr.as_array_mut()
2146 {
2147 for item in arr_items {
2148 fix_schema_for_strict_mode(item);
2149 }
2150 }
2151 }
2152}
2153
2154fn make_nullable(schema: &mut serde_json::Value) {
2158 if let Some(any_of) = schema
2159 .as_object_mut()
2160 .and_then(|o| o.get_mut("anyOf"))
2161 .and_then(|v| v.as_array_mut())
2162 {
2163 let has_null = any_of
2164 .iter()
2165 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
2166 if !has_null {
2167 any_of.push(serde_json::json!({"type": "null"}));
2168 }
2169 return;
2170 }
2171
2172 let original = schema.clone();
2173 *schema = serde_json::json!({
2174 "anyOf": [original, {"type": "null"}]
2175 });
2176}
2177
2178fn has_freeform_object(schema: &serde_json::Value) -> bool {
2182 let Some(obj) = schema.as_object() else {
2183 return false;
2184 };
2185
2186 let is_object = obj
2187 .get("type")
2188 .is_some_and(|t| t.as_str() == Some("object"));
2189
2190 if is_object && !obj.contains_key("properties") {
2191 return true;
2192 }
2193
2194 if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
2195 for prop in props.values() {
2196 if has_freeform_object(prop) {
2197 return true;
2198 }
2199 }
2200 }
2201
2202 if let Some(items) = obj.get("items")
2203 && has_freeform_object(items)
2204 {
2205 return true;
2206 }
2207
2208 for key in ["anyOf", "oneOf", "allOf"] {
2209 if let Some(arr) = obj.get(key).and_then(|v| v.as_array()) {
2210 for item in arr {
2211 if has_freeform_object(item) {
2212 return true;
2213 }
2214 }
2215 }
2216 }
2217
2218 false
2219}
2220
2221fn convert_tool(tool: agent_sdk_foundation::llm::Tool) -> ApiTool {
2222 let mut schema = tool.input_schema;
2227 let use_strict = if has_freeform_object(&schema) {
2228 log::debug!(
2229 "Tool '{}' has free-form object schema — disabling strict mode",
2230 tool.name
2231 );
2232 None
2233 } else {
2234 fix_schema_for_strict_mode(&mut schema);
2235 Some(true)
2236 };
2237
2238 ApiTool {
2239 r#type: "function".to_owned(),
2240 name: tool.name,
2241 description: Some(tool.description),
2242 parameters: Some(schema),
2243 strict: use_strict,
2244 }
2245}
2246
2247fn suggested_filename(media_type: &str) -> String {
2248 match media_type {
2249 "application/pdf" => "attachment.pdf".to_string(),
2250 "image/png" => "image.png".to_string(),
2251 "image/jpeg" => "image.jpg".to_string(),
2252 "image/gif" => "image.gif".to_string(),
2253 "image/webp" => "image.webp".to_string(),
2254 _ => "attachment.bin".to_string(),
2255 }
2256}
2257
2258fn reasoning_output_item(fields: &serde_json::Map<String, serde_json::Value>) -> serde_json::Value {
2259 let mut item = fields.clone();
2260 item.insert(
2261 "type".to_owned(),
2262 serde_json::Value::String("reasoning".to_owned()),
2263 );
2264 serde_json::Value::Object(item)
2265}
2266
2267fn reasoning_summary_texts(fields: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
2268 fields
2269 .get("summary")
2270 .and_then(serde_json::Value::as_array)
2271 .into_iter()
2272 .flatten()
2273 .filter(|summary| {
2274 summary.get("type").and_then(serde_json::Value::as_str) == Some("summary_text")
2275 })
2276 .filter_map(|summary| {
2277 summary
2278 .get("text")
2279 .and_then(serde_json::Value::as_str)
2280 .filter(|text| !text.is_empty())
2281 .map(ToOwned::to_owned)
2282 })
2283 .collect()
2284}
2285
2286fn build_content_blocks(output: &[ApiOutputItem]) -> Vec<ContentBlock> {
2287 let mut blocks = Vec::new();
2288
2289 for item in output {
2290 match item {
2291 ApiOutputItem::Message {
2292 role,
2293 phase,
2294 content,
2295 } => {
2296 blocks.push(ContentBlock::OpaqueReasoning {
2297 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2298 data: message_state_marker(role, phase.as_deref()),
2299 });
2300 for c in content {
2301 match c {
2302 ApiOutputContent::Text { text }
2303 | ApiOutputContent::Refusal { refusal: text }
2304 if !text.is_empty() =>
2305 {
2306 blocks.push(ContentBlock::Text { text: text.clone() });
2307 }
2308 ApiOutputContent::Text { .. }
2309 | ApiOutputContent::Refusal { .. }
2310 | ApiOutputContent::Unknown => {}
2311 }
2312 }
2313 }
2314 ApiOutputItem::FunctionCall {
2315 call_id,
2316 name,
2317 arguments,
2318 ..
2319 } => {
2320 let input =
2321 serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
2322 blocks.push(ContentBlock::ToolUse {
2323 id: call_id.clone(),
2324 name: name.clone(),
2325 input,
2326 thought_signature: None,
2327 });
2328 }
2329 ApiOutputItem::Reasoning { fields } => {
2330 blocks.push(ContentBlock::OpaqueReasoning {
2331 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2332 data: reasoning_output_item(fields),
2333 });
2334 blocks.extend(reasoning_summary_texts(fields).into_iter().map(|thinking| {
2335 ContentBlock::Thinking {
2336 thinking,
2337 signature: None,
2338 }
2339 }));
2340 }
2341 ApiOutputItem::Unknown => {
2342 }
2344 }
2345 }
2346
2347 blocks
2348}
2349
2350fn output_contains_refusal(output: &[ApiOutputItem]) -> bool {
2351 output.iter().any(|item| {
2352 matches!(
2353 item,
2354 ApiOutputItem::Message { content, .. }
2355 if content
2356 .iter()
2357 .any(|content| matches!(content, ApiOutputContent::Refusal { .. }))
2358 )
2359 })
2360}
2361
2362fn build_api_reasoning(thinking: Option<&ThinkingConfig>) -> Option<ApiReasoning> {
2363 thinking
2364 .and_then(resolve_reasoning_effort)
2365 .map(|effort| ApiReasoning { effort })
2366}
2367
2368const fn resolve_reasoning_effort(config: &ThinkingConfig) -> Option<ReasoningEffort> {
2369 if let Some(effort) = config.effort {
2370 return Some(map_effort(effort));
2371 }
2372
2373 match &config.mode {
2374 ThinkingMode::Adaptive => None,
2375 ThinkingMode::Enabled { budget_tokens } => Some(map_budget_to_reasoning(*budget_tokens)),
2376 }
2377}
2378
2379const fn map_effort(effort: Effort) -> ReasoningEffort {
2380 match effort {
2381 Effort::Low => ReasoningEffort::Low,
2382 Effort::Medium => ReasoningEffort::Medium,
2383 Effort::High => ReasoningEffort::High,
2384 Effort::Max => ReasoningEffort::XHigh,
2385 }
2386}
2387
2388const fn map_reasoning_effort(effort: ReasoningEffort) -> Effort {
2389 match effort {
2390 ReasoningEffort::Low => Effort::Low,
2391 ReasoningEffort::Medium => Effort::Medium,
2392 ReasoningEffort::High => Effort::High,
2393 ReasoningEffort::XHigh => Effort::Max,
2394 }
2395}
2396
2397const fn map_budget_to_reasoning(budget_tokens: u32) -> ReasoningEffort {
2398 if budget_tokens <= 4_096 {
2399 ReasoningEffort::Low
2400 } else if budget_tokens <= 16_384 {
2401 ReasoningEffort::Medium
2402 } else if budget_tokens <= 32_768 {
2403 ReasoningEffort::High
2404 } else {
2405 ReasoningEffort::XHigh
2406 }
2407}
2408
2409fn codex_url(base_url: &str) -> String {
2410 let normalized = base_url.trim_end_matches('/');
2411 if normalized.ends_with("/codex/responses") {
2412 normalized.to_string()
2413 } else if normalized.ends_with("/codex") {
2414 format!("{normalized}/responses")
2415 } else {
2416 format!("{normalized}/codex/responses")
2417 }
2418}
2419
2420fn codex_websocket_url(base_url: &str) -> Result<url::Url> {
2421 let mut url = url::Url::parse(&codex_url(base_url))
2422 .context("failed to parse OpenAI Codex websocket URL")?;
2423
2424 let scheme = match url.scheme() {
2425 "http" => Some("ws"),
2426 "https" => Some("wss"),
2427 _ => None,
2428 };
2429
2430 if let Some(scheme) = scheme {
2431 let _ = url.set_scheme(scheme);
2432 }
2433
2434 Ok(url)
2435}
2436
2437fn extract_account_id(token: &str) -> Result<String> {
2438 let payload = token
2439 .split('.')
2440 .nth(1)
2441 .ok_or_else(|| anyhow::anyhow!("invalid OpenAI Codex OAuth token"))?;
2442 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
2443 .decode(payload)
2444 .context("failed to decode OpenAI Codex token payload")?;
2445 let payload: serde_json::Value =
2446 serde_json::from_slice(&decoded).context("failed to parse OpenAI Codex token payload")?;
2447 payload
2448 .get(OPENAI_CODEX_JWT_CLAIM_PATH)
2449 .and_then(|value| value.get("chatgpt_account_id"))
2450 .and_then(serde_json::Value::as_str)
2451 .map(ToOwned::to_owned)
2452 .ok_or_else(|| anyhow::anyhow!("chatgpt_account_id missing from OpenAI Codex token"))
2453}
2454
2455fn is_empty(value: &str) -> bool {
2456 value.trim().is_empty()
2457}
2458
2459struct ToolCallAccumulator {
2464 id: String,
2465 name: String,
2466 arguments: String,
2467 order: usize,
2470 block_index: Option<usize>,
2472}
2473
2474fn usage_from_api_usage(usage: &ApiUsage) -> Usage {
2475 Usage {
2476 input_tokens: usage.input_tokens,
2477 output_tokens: usage.output_tokens,
2478 cached_input_tokens: usage
2479 .input_tokens_details
2480 .as_ref()
2481 .map_or(0, |details| details.cached_tokens),
2482 cache_creation_input_tokens: usage
2483 .input_tokens_details
2484 .as_ref()
2485 .map_or(0, |details| details.cache_write_tokens),
2486 }
2487}
2488
2489fn output_block_index(output_index: Option<usize>) -> usize {
2490 output_index.unwrap_or(0).saturating_mul(2)
2491}
2492
2493fn reasoning_summary_block_index(output_index: Option<usize>) -> usize {
2494 output_block_index(output_index).saturating_add(1)
2495}
2496
2497fn decode_stream_event(data: &str) -> Result<ApiStreamEvent> {
2498 serde_json::from_str(data).with_context(|| {
2499 let mut snippet: String = data.chars().take(512).collect();
2503 if data.chars().count() > 512 {
2504 snippet.push('…');
2505 }
2506 format!("invalid OpenAI Codex Responses stream event: {snippet}")
2507 })
2508}
2509
2510fn decode_output_item(item: Option<serde_json::Value>) -> Result<ApiOutputItem> {
2511 let item = item.context("OpenAI Codex output_item.done omitted item")?;
2512 serde_json::from_value(item).context("invalid OpenAI Codex output item")
2513}
2514
2515fn output_item_stream_deltas(
2516 item: &ApiOutputItem,
2517 output_index: usize,
2518 include_summary: bool,
2519) -> Vec<StreamDelta> {
2520 let block_index = output_index.saturating_mul(2);
2521 match item {
2522 ApiOutputItem::Message { role, phase, .. } => {
2523 vec![StreamDelta::OpaqueReasoning {
2524 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2525 data: message_state_marker(role, phase.as_deref()),
2526 block_index,
2527 }]
2528 }
2529 ApiOutputItem::Reasoning { fields } => {
2530 let mut deltas = vec![StreamDelta::OpaqueReasoning {
2531 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2532 data: reasoning_output_item(fields),
2533 block_index,
2534 }];
2535 if include_summary {
2536 let summary_block_index = block_index.saturating_add(1);
2537 deltas.extend(reasoning_summary_texts(fields).into_iter().map(|delta| {
2538 StreamDelta::ThinkingDelta {
2539 delta,
2540 block_index: summary_block_index,
2541 }
2542 }));
2543 }
2544 deltas
2545 }
2546 ApiOutputItem::FunctionCall { .. } | ApiOutputItem::Unknown => Vec::new(),
2547 }
2548}
2549
2550fn accumulate_completed_tool_call(
2551 item: &ApiOutputItem,
2552 output_index: usize,
2553 tool_calls: &mut HashMap<String, ToolCallAccumulator>,
2554) {
2555 let ApiOutputItem::FunctionCall {
2556 call_id,
2557 name,
2558 arguments,
2559 } = item
2560 else {
2561 return;
2562 };
2563
2564 let order = tool_calls.len();
2565 let accumulator = tool_calls
2566 .entry(call_id.clone())
2567 .or_insert_with(|| ToolCallAccumulator {
2568 id: call_id.clone(),
2569 name: name.clone(),
2570 arguments: String::new(),
2571 order,
2572 block_index: Some(output_index.saturating_mul(2)),
2573 });
2574 accumulator.name.clone_from(name);
2575 accumulator.arguments.clone_from(arguments);
2576 accumulator.block_index = Some(output_index.saturating_mul(2));
2577}
2578
2579fn emit_accumulated_tool_calls(
2580 tool_calls: &HashMap<String, ToolCallAccumulator>,
2581) -> Vec<StreamDelta> {
2582 let mut accs: Vec<&ToolCallAccumulator> = tool_calls.values().collect();
2587 accs.sort_by_key(|acc| (acc.block_index.unwrap_or(usize::MAX), acc.order));
2588
2589 let mut deltas = Vec::with_capacity(accs.len() * 2);
2590 for (idx, acc) in accs.iter().enumerate() {
2591 let block_index = acc
2592 .block_index
2593 .unwrap_or_else(|| idx.saturating_add(1).saturating_mul(2));
2594 deltas.push(StreamDelta::ToolUseStart {
2595 id: acc.id.clone(),
2596 name: acc.name.clone(),
2597 block_index,
2598 thought_signature: None,
2599 });
2600 deltas.push(StreamDelta::ToolInputDelta {
2601 id: acc.id.clone(),
2602 delta: acc.arguments.clone(),
2603 block_index,
2604 });
2605 }
2606 deltas
2607}
2608
2609fn stop_reason_from_stream_state(
2610 tool_calls: &HashMap<String, ToolCallAccumulator>,
2611 status: Option<ApiStatus>,
2612 refused: bool,
2613 incomplete_reason: Option<&str>,
2614) -> Option<StopReason> {
2615 let status = status?;
2616 Some(match status {
2617 ApiStatus::Incomplete => {
2618 incomplete_reason.map_or(StopReason::Unknown, incomplete_stop_reason)
2619 }
2620 ApiStatus::Completed if refused => StopReason::Refusal,
2621 ApiStatus::Completed if !tool_calls.is_empty() => StopReason::ToolUse,
2622 ApiStatus::Completed => StopReason::EndTurn,
2623 ApiStatus::Failed
2628 | ApiStatus::InProgress
2629 | ApiStatus::Queued
2630 | ApiStatus::Cancelled
2631 | ApiStatus::Other => StopReason::Unknown,
2632 })
2633}
2634
2635fn incomplete_stop_reason(reason: &str) -> StopReason {
2636 match reason {
2637 "max_output_tokens" => StopReason::MaxTokens,
2638 "content_filter" => StopReason::Refusal,
2639 "model_context_window_exceeded" => StopReason::ModelContextWindowExceeded,
2640 _ => StopReason::Unknown,
2641 }
2642}
2643
2644const fn websocket_transport_error_kind(
2645 error: &tokio_tungstenite::tungstenite::Error,
2646) -> StreamErrorKind {
2647 match error {
2648 tokio_tungstenite::tungstenite::Error::ConnectionClosed
2649 | tokio_tungstenite::tungstenite::Error::Io(_) => StreamErrorKind::ConnectionLost,
2650 tokio_tungstenite::tungstenite::Error::AlreadyClosed
2651 | tokio_tungstenite::tungstenite::Error::Tls(_)
2652 | tokio_tungstenite::tungstenite::Error::Capacity(_)
2653 | tokio_tungstenite::tungstenite::Error::Protocol(_)
2654 | tokio_tungstenite::tungstenite::Error::WriteBufferFull(_)
2655 | tokio_tungstenite::tungstenite::Error::Utf8(_)
2656 | tokio_tungstenite::tungstenite::Error::AttackAttempt
2657 | tokio_tungstenite::tungstenite::Error::Url(_)
2658 | tokio_tungstenite::tungstenite::Error::Http(_)
2659 | tokio_tungstenite::tungstenite::Error::HttpFormat(_) => StreamErrorKind::ServerError,
2660 }
2661}
2662
2663fn reset_websocket_connection(session: &mut WebsocketSessionState) {
2664 session.connection = None;
2665 if session.prewarmed {
2666 session.last_request = None;
2667 session.last_response_id = None;
2668 session.last_response_items.clear();
2669 }
2670 session.prewarmed = false;
2671}
2672
2673fn end_websocket_turn(session: &mut WebsocketSessionState) {
2694 reset_websocket_connection(session);
2695 session.last_request = None;
2696 session.last_response_id = None;
2697 session.last_response_items.clear();
2698 session.in_flight = false;
2699}
2700
2701fn evict_idle_sessions(sessions: &mut HashMap<String, Arc<Mutex<WebsocketSessionState>>>) {
2708 let mut candidates: Vec<(String, Option<Instant>)> = Vec::new();
2709 for (key, state) in sessions.iter() {
2710 if let Ok(guard) = state.try_lock()
2711 && !guard.in_flight
2712 {
2713 candidates.push((key.clone(), guard.last_used));
2714 }
2715 }
2716 candidates.sort_by_key(|a| a.1);
2718 let evict_count = candidates.len().min(sessions.len() / 2 + 1);
2719 for (key, _) in candidates.into_iter().take(evict_count) {
2720 sessions.remove(&key);
2721 }
2722}
2723
2724struct WrappedWebsocketError {
2726 status: StatusCode,
2727 message: String,
2728 connection_limit: bool,
2734 reset_after: Option<Duration>,
2740}
2741
2742fn codex_reset_after(resets_in_seconds: Option<f64>, resets_at: Option<f64>) -> Option<Duration> {
2753 if let Some(seconds) = resets_in_seconds {
2754 return crate::retry_hints::bounded_delay(seconds);
2755 }
2756 let resets_at = resets_at?;
2757 if !resets_at.is_finite() {
2758 return None;
2759 }
2760 let now = std::time::SystemTime::now()
2761 .duration_since(std::time::UNIX_EPOCH)
2762 .ok()?
2763 .as_secs();
2764 crate::retry_hints::bounded_delay(resets_at.floor() - f64::from(u32::try_from(now).ok()?))
2767}
2768
2769fn websocket_reset_after(error: Option<&ApiWrappedWebsocketErrorBody>) -> Option<Duration> {
2771 let error = error?;
2772 codex_reset_after(error.resets_in_seconds, error.resets_at)
2773}
2774
2775#[derive(Deserialize)]
2781struct ApiHttpErrorEnvelope {
2782 #[serde(default)]
2783 error: Option<ApiHttpErrorBody>,
2784}
2785
2786#[derive(Deserialize)]
2787struct ApiHttpErrorBody {
2788 #[serde(default)]
2789 resets_in_seconds: Option<f64>,
2790 #[serde(default)]
2791 resets_at: Option<f64>,
2792}
2793
2794fn codex_http_reset_after(body: &str) -> Option<Duration> {
2797 let envelope: ApiHttpErrorEnvelope = serde_json::from_str(body).ok()?;
2798 let error = envelope.error?;
2799 codex_reset_after(error.resets_in_seconds, error.resets_at)
2800}
2801
2802fn parse_wrapped_websocket_error_event(payload: &str) -> Option<WrappedWebsocketError> {
2803 let event: ApiWrappedWebsocketErrorEvent = serde_json::from_str(payload).ok()?;
2804 if event.kind != "error" {
2805 return None;
2806 }
2807
2808 let reset_after = websocket_reset_after(event.error.as_ref());
2809
2810 if event.error.as_ref().and_then(|error| error.code.as_deref())
2811 == Some(OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE)
2812 {
2813 let message = event
2814 .error
2815 .and_then(|error| error.message)
2816 .unwrap_or_else(|| "Responses websocket connection limit reached".to_string());
2817 return Some(WrappedWebsocketError {
2818 status: StatusCode::TOO_MANY_REQUESTS,
2819 message,
2820 connection_limit: true,
2821 reset_after,
2822 });
2823 }
2824
2825 let status = StatusCode::from_u16(event.status?).ok()?;
2826 let message = event
2827 .error
2828 .and_then(|error| error.message)
2829 .unwrap_or_else(|| payload.to_string());
2830 if status.is_success() {
2831 None
2832 } else {
2833 Some(WrappedWebsocketError {
2834 status,
2835 message,
2836 connection_limit: false,
2837 reset_after,
2838 })
2839 }
2840}
2841
2842fn is_websocket_quota_rejection(error: &WrappedWebsocketError) -> bool {
2849 error.status == StatusCode::TOO_MANY_REQUESTS && !error.connection_limit
2850}
2851
2852fn codex_response_failed_error(response: Option<ApiStreamResponse>) -> CodexResponseFailure {
2860 let usage = response
2865 .as_ref()
2866 .and_then(|resp| resp.usage.as_ref())
2867 .map(usage_from_api_usage);
2868 let (code, message) = response
2869 .and_then(|resp| resp.error)
2870 .map_or((None, None), |error| (error.code, error.message));
2871 let message = message.unwrap_or_else(|| "Codex response failed".to_string());
2872 let kind = if matches!(
2873 code.as_deref(),
2874 Some("rate_limit_exceeded" | "rate_limit_error")
2875 ) {
2876 StreamErrorKind::RateLimited(crate::retry_hints::openai_retry_delay(&message))
2877 } else {
2878 StreamErrorKind::ServerError
2879 };
2880 CodexResponseFailure {
2881 message,
2882 kind,
2883 usage,
2884 }
2885}
2886
2887struct CodexResponseFailure {
2889 message: String,
2890 kind: StreamErrorKind,
2891 usage: Option<Usage>,
2893}
2894
2895fn websocket_error_kind(error: &WrappedWebsocketError) -> StreamErrorKind {
2902 if error.status == StatusCode::TOO_MANY_REQUESTS {
2903 let delay = error
2904 .reset_after
2905 .or_else(|| crate::retry_hints::openai_retry_delay(&error.message));
2906 StreamErrorKind::RateLimited(delay)
2907 } else if error.status.is_server_error() {
2908 StreamErrorKind::ServerError
2909 } else {
2910 StreamErrorKind::InvalidRequest
2911 }
2912}
2913
2914fn output_item_to_input_item(item: ApiOutputItem) -> Option<ApiInputItem> {
2915 match item {
2916 ApiOutputItem::Message {
2917 role,
2918 phase,
2919 content,
2920 } => {
2921 let role = if role == "user" {
2922 ApiRole::User
2923 } else {
2924 ApiRole::Assistant
2925 };
2926 let parts: Vec<ApiInputContent> = content
2927 .into_iter()
2928 .filter_map(|content| match content {
2929 ApiOutputContent::Text { text }
2930 | ApiOutputContent::Refusal { refusal: text }
2931 if !text.is_empty() =>
2932 {
2933 Some(ApiInputContent::OutputText { text })
2934 }
2935 ApiOutputContent::Unknown
2936 | ApiOutputContent::Text { .. }
2937 | ApiOutputContent::Refusal { .. } => None,
2938 })
2939 .collect();
2940 if parts.is_empty() {
2941 None
2942 } else {
2943 Some(ApiInputItem::Message(ApiMessage {
2944 role,
2945 content: ApiMessageContent::Parts(parts),
2946 phase,
2947 }))
2948 }
2949 }
2950 ApiOutputItem::FunctionCall {
2951 call_id,
2952 name,
2953 arguments,
2954 } => Some(ApiInputItem::FunctionCall(ApiFunctionCall::new(
2955 call_id, name, arguments,
2956 ))),
2957 ApiOutputItem::Reasoning { fields } => Some(ApiInputItem::OpaqueReasoning(
2958 reasoning_output_item(&fields),
2959 )),
2960 ApiOutputItem::Unknown => None,
2961 }
2962}
2963
2964fn prepare_websocket_request(
2965 request: &ApiStreamingRequest,
2966 session: &WebsocketSessionState,
2967 allow_empty_delta: bool,
2968) -> ApiWebsocketRequest {
2969 let mut websocket_request = ApiWebsocketRequest::from(request);
2970
2971 let Some(last_request) = session.last_request.as_ref() else {
2972 return websocket_request;
2973 };
2974 let Some(last_response_id) = session.last_response_id.as_ref() else {
2975 return websocket_request;
2976 };
2977
2978 let mut previous_without_input = last_request.clone();
2979 previous_without_input.input.clear();
2980 let mut current_without_input = request.clone();
2981 current_without_input.input.clear();
2982 if previous_without_input != current_without_input {
2983 return websocket_request;
2984 }
2985
2986 let mut baseline = last_request.input.clone();
2987 baseline.extend(session.last_response_items.clone());
2988 if request.input.starts_with(&baseline)
2989 && (allow_empty_delta || baseline.len() < request.input.len())
2990 {
2991 websocket_request.previous_response_id = Some(last_response_id.clone());
2992 websocket_request.input = request.input[baseline.len()..].to_vec();
2993 }
2994
2995 websocket_request
2996}
2997
2998#[derive(Serialize)]
3003struct ApiResponsesRequest<'a> {
3004 model: &'a str,
3005 #[serde(skip_serializing_if = "is_empty")]
3006 instructions: &'a str,
3007 input: &'a [ApiInputItem],
3008 #[serde(skip_serializing_if = "Option::is_none")]
3009 tools: Option<&'a [ApiTool]>,
3010 #[serde(skip_serializing_if = "Option::is_none")]
3011 max_output_tokens: Option<u32>,
3012 #[serde(skip_serializing_if = "Option::is_none")]
3013 reasoning: Option<ApiReasoning>,
3014 #[serde(skip_serializing_if = "Option::is_none")]
3015 tool_choice: Option<ApiToolChoice>,
3016 #[serde(skip_serializing_if = "Option::is_none")]
3017 parallel_tool_calls: Option<bool>,
3018 store: bool,
3019 #[serde(skip_serializing_if = "Option::is_none")]
3020 text: Option<ApiTextSettings>,
3021 #[serde(skip_serializing_if = "Option::is_none")]
3022 include: Option<&'a [&'static str]>,
3023 #[serde(skip_serializing_if = "Option::is_none")]
3024 prompt_cache_key: Option<&'a str>,
3025}
3026
3027#[derive(Clone, PartialEq, Serialize)]
3028struct ApiStreamingRequest {
3029 model: String,
3030 #[serde(skip_serializing_if = "String::is_empty")]
3031 instructions: String,
3032 input: Vec<ApiInputItem>,
3033 #[serde(skip_serializing_if = "Option::is_none")]
3034 tools: Option<Vec<ApiTool>>,
3035 #[serde(skip_serializing_if = "Option::is_none")]
3036 max_output_tokens: Option<u32>,
3037 #[serde(skip_serializing_if = "Option::is_none")]
3038 reasoning: Option<ApiReasoning>,
3039 #[serde(skip_serializing_if = "Option::is_none")]
3040 tool_choice: Option<ApiToolChoice>,
3041 #[serde(skip_serializing_if = "Option::is_none")]
3042 parallel_tool_calls: Option<bool>,
3043 store: bool,
3044 #[serde(skip_serializing_if = "Option::is_none")]
3045 text: Option<ApiTextSettings>,
3046 #[serde(skip_serializing_if = "Option::is_none")]
3047 include: Option<Vec<String>>,
3048 #[serde(skip_serializing_if = "Option::is_none")]
3049 prompt_cache_key: Option<String>,
3050 stream: bool,
3051}
3052
3053#[derive(Clone, Serialize)]
3054struct ApiWebsocketRequest {
3055 #[serde(rename = "type")]
3056 kind: &'static str,
3057 model: String,
3058 #[serde(skip_serializing_if = "String::is_empty")]
3059 instructions: String,
3060 #[serde(skip_serializing_if = "Option::is_none")]
3061 previous_response_id: Option<String>,
3062 input: Vec<ApiInputItem>,
3063 #[serde(skip_serializing_if = "Option::is_none")]
3064 tools: Option<Vec<ApiTool>>,
3065 #[serde(skip_serializing_if = "Option::is_none")]
3066 max_output_tokens: Option<u32>,
3067 #[serde(skip_serializing_if = "Option::is_none")]
3068 reasoning: Option<ApiReasoning>,
3069 #[serde(skip_serializing_if = "Option::is_none")]
3070 tool_choice: Option<ApiToolChoice>,
3071 #[serde(skip_serializing_if = "Option::is_none")]
3072 parallel_tool_calls: Option<bool>,
3073 store: bool,
3074 #[serde(skip_serializing_if = "Option::is_none")]
3075 text: Option<ApiTextSettings>,
3076 #[serde(skip_serializing_if = "Option::is_none")]
3077 include: Option<Vec<String>>,
3078 #[serde(skip_serializing_if = "Option::is_none")]
3079 prompt_cache_key: Option<String>,
3080 stream: bool,
3081 #[serde(skip_serializing_if = "Option::is_none")]
3082 generate: Option<bool>,
3083}
3084
3085impl From<&ApiStreamingRequest> for ApiWebsocketRequest {
3086 fn from(request: &ApiStreamingRequest) -> Self {
3087 Self {
3088 kind: "response.create",
3089 model: request.model.clone(),
3090 instructions: request.instructions.clone(),
3091 previous_response_id: None,
3092 input: request.input.clone(),
3093 tools: request.tools.clone(),
3094 max_output_tokens: request.max_output_tokens,
3095 reasoning: request.reasoning.clone(),
3096 tool_choice: request.tool_choice.clone(),
3097 parallel_tool_calls: request.parallel_tool_calls,
3098 store: request.store,
3099 text: request.text.clone(),
3100 include: request.include.clone(),
3101 prompt_cache_key: request.prompt_cache_key.clone(),
3102 stream: request.stream,
3103 generate: None,
3104 }
3105 }
3106}
3107
3108#[derive(Clone, PartialEq, Serialize)]
3109struct ApiTextSettings {
3110 verbosity: &'static str,
3111 #[serde(skip_serializing_if = "Option::is_none")]
3114 format: Option<ApiResponseTextFormat>,
3115}
3116
3117#[derive(Clone, PartialEq, Serialize)]
3118struct ApiResponseTextFormat {
3119 #[serde(rename = "type")]
3120 format_type: &'static str,
3121 name: String,
3122 schema: serde_json::Value,
3123 strict: bool,
3124}
3125
3126impl From<&ResponseFormat> for ApiResponseTextFormat {
3127 fn from(rf: &ResponseFormat) -> Self {
3128 Self {
3129 format_type: "json_schema",
3130 name: rf.name.clone(),
3131 schema: rf.schema.clone(),
3132 strict: rf.strict,
3133 }
3134 }
3135}
3136
3137#[derive(Clone, PartialEq, Serialize)]
3142#[serde(untagged)]
3143enum ApiToolChoice {
3144 Mode(&'static str),
3145 Function {
3146 #[serde(rename = "type")]
3147 choice_type: &'static str,
3148 name: String,
3149 },
3150}
3151
3152fn codex_tool_choice(tool_choice: Option<&ToolChoice>) -> ApiToolChoice {
3155 match tool_choice {
3156 Some(ToolChoice::Tool(name)) => ApiToolChoice::Function {
3157 choice_type: "function",
3158 name: name.clone(),
3159 },
3160 _ => ApiToolChoice::Mode("auto"),
3161 }
3162}
3163
3164#[derive(Clone, PartialEq, Serialize)]
3165struct ApiReasoning {
3166 effort: ReasoningEffort,
3167}
3168
3169#[derive(Clone, PartialEq, Serialize)]
3170#[serde(untagged)]
3171enum ApiInputItem {
3172 Message(ApiMessage),
3173 FunctionCall(ApiFunctionCall),
3174 FunctionCallOutput(ApiFunctionCallOutput),
3175 OpaqueReasoning(serde_json::Value),
3176}
3177
3178#[derive(Clone, PartialEq, Serialize)]
3179struct ApiMessage {
3180 role: ApiRole,
3181 content: ApiMessageContent,
3182 #[serde(skip_serializing_if = "Option::is_none")]
3183 phase: Option<String>,
3184}
3185
3186#[derive(Clone, Copy, PartialEq, Serialize)]
3187#[serde(rename_all = "lowercase")]
3188enum ApiRole {
3189 User,
3190 Assistant,
3191}
3192
3193#[derive(Clone, PartialEq, Serialize)]
3194#[serde(untagged)]
3195enum ApiMessageContent {
3196 Text(String),
3197 Parts(Vec<ApiInputContent>),
3198}
3199
3200#[derive(Clone, PartialEq, Serialize)]
3201#[serde(tag = "type")]
3202enum ApiInputContent {
3203 #[serde(rename = "input_text")]
3204 InputText { text: String },
3205 #[serde(rename = "output_text")]
3206 OutputText { text: String },
3207 #[serde(rename = "input_image")]
3208 Image { image_url: String },
3209 #[serde(rename = "input_file")]
3210 File { filename: String, file_data: String },
3211}
3212
3213#[derive(Clone, PartialEq, Serialize)]
3214struct ApiFunctionCall {
3215 r#type: &'static str,
3216 call_id: String,
3217 name: String,
3218 arguments: String,
3219}
3220
3221impl ApiFunctionCall {
3222 const fn new(call_id: String, name: String, arguments: String) -> Self {
3223 Self {
3224 r#type: "function_call",
3225 call_id,
3226 name,
3227 arguments,
3228 }
3229 }
3230}
3231
3232#[derive(Clone, PartialEq, Serialize)]
3233struct ApiFunctionCallOutput {
3234 r#type: &'static str,
3235 call_id: String,
3236 output: String,
3237}
3238
3239impl ApiFunctionCallOutput {
3240 const fn new(call_id: String, output: String) -> Self {
3241 Self {
3242 r#type: "function_call_output",
3243 call_id,
3244 output,
3245 }
3246 }
3247}
3248
3249#[derive(Clone, PartialEq, Serialize)]
3250struct ApiTool {
3251 r#type: String,
3252 name: String,
3253 #[serde(skip_serializing_if = "Option::is_none")]
3254 description: Option<String>,
3255 #[serde(skip_serializing_if = "Option::is_none")]
3256 parameters: Option<serde_json::Value>,
3257 #[serde(skip_serializing_if = "Option::is_none")]
3258 strict: Option<bool>,
3259}
3260
3261#[derive(Deserialize)]
3266struct ApiResponse {
3267 id: String,
3268 model: String,
3269 output: Vec<ApiOutputItem>,
3270 #[serde(default)]
3271 status: Option<ApiStatus>,
3272 #[serde(default)]
3273 usage: Option<ApiUsage>,
3274 #[serde(default)]
3275 error: Option<ApiErrorBody>,
3276 #[serde(default)]
3277 incomplete_details: Option<ApiIncompleteDetails>,
3278}
3279
3280#[derive(Deserialize)]
3281struct ApiIncompleteDetails {
3282 #[serde(default)]
3283 reason: Option<String>,
3284}
3285
3286#[derive(Clone, Copy, Deserialize)]
3287#[serde(rename_all = "snake_case")]
3288enum ApiStatus {
3289 Completed,
3290 Incomplete,
3291 Failed,
3292 InProgress,
3297 Queued,
3298 Cancelled,
3299 #[serde(other)]
3302 Other,
3303}
3304
3305#[derive(Deserialize)]
3306struct ApiUsage {
3307 input_tokens: u32,
3308 output_tokens: u32,
3309 #[serde(default)]
3310 input_tokens_details: Option<ApiInputTokensDetails>,
3311}
3312
3313#[derive(Deserialize)]
3314struct ApiInputTokensDetails {
3315 #[serde(default)]
3316 cached_tokens: u32,
3317 #[serde(default)]
3318 cache_write_tokens: u32,
3319}
3320
3321#[derive(Deserialize)]
3322#[serde(tag = "type")]
3323enum ApiOutputItem {
3324 #[serde(rename = "message")]
3325 Message {
3326 role: String,
3327 #[serde(default)]
3328 phase: Option<String>,
3329 content: Vec<ApiOutputContent>,
3330 },
3331 #[serde(rename = "function_call")]
3332 FunctionCall {
3333 call_id: String,
3334 name: String,
3335 arguments: String,
3336 },
3337 #[serde(rename = "reasoning")]
3338 Reasoning {
3339 #[serde(flatten)]
3340 fields: serde_json::Map<String, serde_json::Value>,
3341 },
3342 #[serde(other)]
3343 Unknown,
3344}
3345
3346#[derive(Deserialize)]
3347#[serde(tag = "type")]
3348enum ApiOutputContent {
3349 #[serde(rename = "output_text")]
3350 Text { text: String },
3351 #[serde(rename = "refusal")]
3352 Refusal { refusal: String },
3353 #[serde(other)]
3354 Unknown,
3355}
3356
3357#[derive(Deserialize)]
3362struct ApiStreamEvent {
3363 r#type: String,
3364 #[serde(default)]
3365 output_index: Option<usize>,
3366 #[serde(default)]
3367 delta: Option<String>,
3368 #[serde(default)]
3369 call_id: Option<String>,
3370 #[serde(default)]
3371 name: Option<String>,
3372 #[serde(default)]
3373 item: Option<serde_json::Value>,
3374 #[serde(default)]
3375 response: Option<ApiStreamResponse>,
3376}
3377
3378#[derive(Deserialize)]
3379struct ApiStreamResponse {
3380 #[serde(default)]
3381 id: Option<String>,
3382 #[serde(default)]
3383 usage: Option<ApiUsage>,
3384 #[serde(default)]
3385 error: Option<ApiErrorBody>,
3386 #[serde(default)]
3387 status: Option<ApiStatus>,
3388 #[serde(default)]
3389 incomplete_details: Option<ApiIncompleteDetails>,
3390}
3391
3392#[derive(Deserialize)]
3393struct ApiErrorBody {
3394 #[serde(default)]
3395 message: Option<String>,
3396 #[serde(default)]
3400 code: Option<String>,
3401}
3402
3403#[derive(Deserialize)]
3404struct ApiWrappedWebsocketErrorBody {
3405 #[serde(default)]
3406 code: Option<String>,
3407 #[serde(default)]
3408 message: Option<String>,
3409 #[serde(default)]
3414 resets_in_seconds: Option<f64>,
3415 #[serde(default)]
3420 resets_at: Option<f64>,
3421}
3422
3423#[derive(Deserialize)]
3424struct ApiWrappedWebsocketErrorEvent {
3425 #[serde(rename = "type")]
3426 kind: String,
3427 #[serde(alias = "status_code")]
3428 status: Option<u16>,
3429 #[serde(default)]
3430 error: Option<ApiWrappedWebsocketErrorBody>,
3431}
3432
3433#[cfg(test)]
3438mod tests {
3439 use super::*;
3440
3441 #[test]
3442 fn test_model_constant() {
3443 assert_eq!(MODEL_GPT54, "gpt-5.4");
3444 assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
3445 assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
3446 }
3447
3448 #[test]
3449 fn warmup_quota_rejection_is_surfaced_while_connection_limit_still_falls_back()
3450 -> anyhow::Result<()> {
3451 let quota = parse_wrapped_websocket_error_event(
3457 r#"{"type":"error","status":429,"error":{"code":"rate_limit_exceeded","message":"Rate limit reached. Please try again in 45s."}}"#,
3458 )
3459 .context("expected a wrapped error")?;
3460 assert!(is_websocket_quota_rejection("a));
3461 let kind = websocket_error_kind("a);
3462 assert_eq!(
3463 kind,
3464 StreamErrorKind::RateLimited(Some(std::time::Duration::from_secs(45)))
3465 );
3466 assert!(kind.is_recoverable());
3467
3468 let usage_limit = parse_wrapped_websocket_error_event(
3471 r#"{"type":"error","status":429,"error":{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_in_seconds":900}}"#,
3472 )
3473 .context("expected a wrapped error")?;
3474 assert!(is_websocket_quota_rejection(&usage_limit));
3475 assert_eq!(
3476 websocket_error_kind(&usage_limit),
3477 StreamErrorKind::RateLimited(Some(std::time::Duration::from_mins(15))),
3478 "the structured reset must reach the retry loop through the warmup path"
3479 );
3480
3481 let connection_limit = parse_wrapped_websocket_error_event(&format!(
3482 r#"{{"type":"error","status":429,"error":{{"code":"{OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE}","message":"limit"}}}}"#,
3483 ))
3484 .context("expected a wrapped error")?;
3485 assert!(
3486 !is_websocket_quota_rejection(&connection_limit),
3487 "the connection-limit sentinel must keep its immediate fallback"
3488 );
3489 Ok(())
3490 }
3491
3492 #[test]
3493 fn in_band_response_failed_rate_limit_keeps_its_hint() -> anyhow::Result<()> {
3494 let event: ApiStreamEvent = serde_json::from_str(
3495 r#"{"type":"response.failed","response":{"error":{"code":"rate_limit_exceeded","message":"Rate limit reached. Please try again in 20s."}}}"#,
3496 )?;
3497 let failure = codex_response_failed_error(event.response);
3498
3499 assert!(failure.message.contains("try again in 20s"));
3500 assert_eq!(
3501 failure.kind,
3502 StreamErrorKind::RateLimited(Some(std::time::Duration::from_secs(20)))
3503 );
3504 assert!(failure.kind.is_recoverable());
3505 Ok(())
3506 }
3507
3508 #[test]
3509 fn in_band_response_failed_without_a_rate_limit_code_is_a_server_error() -> anyhow::Result<()> {
3510 let event: ApiStreamEvent = serde_json::from_str(
3511 r#"{"type":"response.failed","response":{"error":{"code":"server_error","message":"upstream blew up"}}}"#,
3512 )?;
3513 let failure = codex_response_failed_error(event.response);
3514
3515 assert_eq!(failure.message, "upstream blew up");
3516 assert_eq!(failure.kind, StreamErrorKind::ServerError);
3517 Ok(())
3518 }
3519
3520 #[test]
3521 fn in_band_response_failed_keeps_the_usage_it_reported() -> anyhow::Result<()> {
3522 let event: ApiStreamEvent = serde_json::from_str(
3525 r#"{"type":"response.failed","response":{"usage":{"input_tokens":120,"output_tokens":34},"error":{"code":"rate_limit_exceeded","message":"Please try again in 20s."}}}"#,
3526 )?;
3527 let failure = codex_response_failed_error(event.response);
3528
3529 let usage = failure.usage.context("failed response must report usage")?;
3530 assert_eq!(usage.input_tokens, 120);
3531 assert_eq!(usage.output_tokens, 34);
3532 assert_eq!(
3533 failure.kind,
3534 StreamErrorKind::RateLimited(Some(std::time::Duration::from_secs(20)))
3535 );
3536 Ok(())
3537 }
3538
3539 #[test]
3540 fn test_codex_factory() {
3541 let provider = OpenAICodexResponsesProvider::codex("test-key".to_string());
3542 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
3543 assert_eq!(provider.provider(), "openai-codex");
3544 }
3545
3546 #[test]
3547 fn test_gpt54_factory() {
3548 let provider = OpenAICodexResponsesProvider::gpt54("test-key".to_string());
3549 assert_eq!(provider.model(), MODEL_GPT54);
3550 assert_eq!(provider.provider(), "openai-codex");
3551 }
3552
3553 #[test]
3554 fn test_gpt53_codex_factory() {
3555 let provider = OpenAICodexResponsesProvider::gpt53_codex("test-key".to_string());
3556 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
3557 assert_eq!(provider.provider(), "openai-codex");
3558 }
3559
3560 #[test]
3561 fn test_reasoning_effort_serialization() {
3562 let low = serde_json::to_string(&ReasoningEffort::Low).unwrap();
3563 assert_eq!(low, "\"low\"");
3564
3565 let xhigh = serde_json::to_string(&ReasoningEffort::XHigh).unwrap();
3566 assert_eq!(xhigh, "\"xhigh\"");
3567 }
3568
3569 #[test]
3570 fn test_with_reasoning_effort() {
3571 let provider = OpenAICodexResponsesProvider::codex("test-key".to_string())
3572 .with_reasoning_effort(ReasoningEffort::High);
3573 let thinking = provider.thinking.as_ref().unwrap();
3574 assert!(matches!(thinking.effort, Some(Effort::High)));
3575 }
3576
3577 #[test]
3578 fn test_build_api_reasoning_uses_explicit_effort() {
3579 let reasoning =
3580 build_api_reasoning(Some(&ThinkingConfig::adaptive_with_effort(Effort::Low))).unwrap();
3581 assert!(matches!(reasoning.effort, ReasoningEffort::Low));
3582 }
3583
3584 #[test]
3585 fn test_build_api_reasoning_omits_adaptive_without_effort() {
3586 assert!(build_api_reasoning(Some(&ThinkingConfig::adaptive())).is_none());
3587 }
3588
3589 #[test]
3590 fn test_openai_responses_accepts_adaptive_thinking() {
3591 let provider = OpenAICodexResponsesProvider::codex("test-key".to_string());
3592 assert!(
3593 provider
3594 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
3595 .is_ok()
3596 );
3597 }
3598
3599 #[test]
3600 fn test_api_tool_serialization() {
3601 let tool = ApiTool {
3602 r#type: "function".to_owned(),
3603 name: "get_weather".to_owned(),
3604 description: Some("Get weather".to_owned()),
3605 parameters: Some(serde_json::json!({"type": "object"})),
3606 strict: Some(true),
3607 };
3608
3609 let json = serde_json::to_string(&tool).unwrap();
3610 assert!(json.contains("\"type\":\"function\""));
3611 assert!(json.contains("\"name\":\"get_weather\""));
3612 assert!(json.contains("\"strict\":true"));
3613 }
3614
3615 fn test_token() -> String {
3616 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
3617 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(
3618 r#"{{"{OPENAI_CODEX_JWT_CLAIM_PATH}":{{"chatgpt_account_id":"acct_123"}}}}"#
3619 ));
3620 format!("{header}.{payload}.sig")
3621 }
3622
3623 #[test]
3624 fn test_build_headers_match_codex_style_defaults() -> anyhow::Result<()> {
3625 let provider = OpenAICodexResponsesProvider::codex(test_token());
3626
3627 let headers = provider.build_headers(true, Some("session-123"), None)?;
3628 assert_eq!(headers.get("originator").unwrap(), OPENAI_CODEX_ORIGINATOR);
3629 assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123");
3630 assert_eq!(headers.get("session_id").unwrap(), "session-123");
3631 assert_eq!(headers.get("x-client-request-id").unwrap(), "session-123");
3632 assert_eq!(
3633 headers.get("OpenAI-Beta").unwrap(),
3634 OPENAI_CODEX_RESPONSES_BETA_HEADER
3635 );
3636
3637 Ok(())
3638 }
3639
3640 #[test]
3641 fn test_build_websocket_headers_match_codex_style_defaults() -> anyhow::Result<()> {
3642 let provider = OpenAICodexResponsesProvider::codex(test_token());
3643
3644 let headers = provider.build_websocket_headers(Some("session-123"), Some("turn-1"))?;
3645 assert_eq!(headers.get("originator").unwrap(), OPENAI_CODEX_ORIGINATOR);
3646 assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123");
3647 assert_eq!(headers.get("session_id").unwrap(), "session-123");
3648 assert_eq!(headers.get("x-client-request-id").unwrap(), "session-123");
3649 assert_eq!(
3650 headers.get(OPENAI_CODEX_TURN_STATE_HEADER).unwrap(),
3651 "turn-1"
3652 );
3653 assert_eq!(
3654 headers.get("OpenAI-Beta").unwrap(),
3655 OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER,
3656 );
3657
3658 Ok(())
3659 }
3660
3661 #[test]
3662 fn test_build_headers_uses_configured_account_id_without_jwt_decode() -> anyhow::Result<()> {
3663 let provider = OpenAICodexResponsesProvider::codex("not-a-jwt".to_string())
3664 .with_account_id("acct_stored");
3665
3666 let headers = provider.build_headers(true, Some("session-123"), Some("turn-1"))?;
3667 assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_stored");
3668 assert_eq!(
3669 headers.get(OPENAI_CODEX_TURN_STATE_HEADER).unwrap(),
3670 "turn-1"
3671 );
3672
3673 Ok(())
3674 }
3675
3676 #[test]
3677 fn test_request_serialization_includes_store_false() {
3678 let request = ApiStreamingRequest {
3679 model: MODEL_GPT53_CODEX.to_string(),
3680 instructions: "system".to_string(),
3681 input: Vec::new(),
3682 tools: None,
3683 max_output_tokens: None,
3684 reasoning: None,
3685 tool_choice: Some(ApiToolChoice::Mode("auto")),
3686 parallel_tool_calls: Some(true),
3687 store: false,
3688 text: Some(ApiTextSettings {
3689 verbosity: "medium",
3690 format: None,
3691 }),
3692 include: Some(vec!["reasoning.encrypted_content".to_string()]),
3693 prompt_cache_key: Some("session-123".to_string()),
3694 stream: true,
3695 };
3696
3697 let json = serde_json::to_string(&request).unwrap();
3698 assert!(json.contains("\"store\":false"));
3699 assert!(json.contains("\"stream\":true"));
3700 }
3701
3702 #[test]
3703 fn test_prepare_websocket_request_uses_previous_response_id_for_incremental_input() {
3704 let request = ApiStreamingRequest {
3705 model: MODEL_GPT53_CODEX.to_string(),
3706 instructions: "system".to_string(),
3707 input: vec![
3708 ApiInputItem::Message(ApiMessage {
3709 role: ApiRole::User,
3710 content: ApiMessageContent::Text("first".to_string()),
3711 phase: None,
3712 }),
3713 ApiInputItem::Message(ApiMessage {
3714 role: ApiRole::Assistant,
3715 content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
3716 text: "answer".to_string(),
3717 }]),
3718 phase: Some("final_answer".to_owned()),
3719 }),
3720 ApiInputItem::Message(ApiMessage {
3721 role: ApiRole::User,
3722 content: ApiMessageContent::Text("follow up".to_string()),
3723 phase: None,
3724 }),
3725 ],
3726 tools: None,
3727 max_output_tokens: None,
3728 reasoning: None,
3729 tool_choice: Some(ApiToolChoice::Mode("auto")),
3730 parallel_tool_calls: None,
3731 store: false,
3732 text: Some(ApiTextSettings {
3733 verbosity: "medium",
3734 format: None,
3735 }),
3736 include: Some(vec!["reasoning.encrypted_content".to_string()]),
3737 prompt_cache_key: Some("thread-1".to_string()),
3738 stream: true,
3739 };
3740 let previous_request = ApiStreamingRequest {
3741 input: vec![ApiInputItem::Message(ApiMessage {
3742 role: ApiRole::User,
3743 content: ApiMessageContent::Text("first".to_string()),
3744 phase: None,
3745 })],
3746 ..request.clone()
3747 };
3748 let session = WebsocketSessionState {
3749 connection: None,
3750 last_request: Some(previous_request),
3751 last_response_id: Some("resp_prev".to_string()),
3752 last_response_items: vec![ApiInputItem::Message(ApiMessage {
3753 role: ApiRole::Assistant,
3754 content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
3755 text: "answer".to_string(),
3756 }]),
3757 phase: Some("final_answer".to_owned()),
3758 })],
3759 turn_state: None,
3760 prewarmed: false,
3761 websocket_disabled: false,
3762 in_flight: false,
3763 last_used: None,
3764 };
3765
3766 let websocket_request = prepare_websocket_request(&request, &session, false);
3767 assert_eq!(
3768 websocket_request.previous_response_id.as_deref(),
3769 Some("resp_prev")
3770 );
3771 assert_eq!(websocket_request.input.len(), 1);
3772 match &websocket_request.input[0] {
3773 ApiInputItem::Message(ApiMessage {
3774 role: ApiRole::User,
3775 content: ApiMessageContent::Text(text),
3776 ..
3777 }) => assert_eq!(text, "follow up"),
3778 _ => panic!("expected incremental follow-up user message"),
3779 }
3780 }
3781
3782 #[test]
3783 fn end_websocket_turn_clears_the_incremental_baseline_for_the_retry() {
3784 let previous_request = ApiStreamingRequest {
3788 model: MODEL_GPT53_CODEX.to_string(),
3789 instructions: "system".to_string(),
3790 input: vec![ApiInputItem::Message(ApiMessage {
3791 role: ApiRole::User,
3792 content: ApiMessageContent::Text("first".to_string()),
3793 phase: None,
3794 })],
3795 tools: None,
3796 max_output_tokens: None,
3797 reasoning: None,
3798 tool_choice: Some(ApiToolChoice::Mode("auto")),
3799 parallel_tool_calls: None,
3800 store: false,
3801 text: Some(ApiTextSettings {
3802 verbosity: "medium",
3803 format: None,
3804 }),
3805 include: None,
3806 prompt_cache_key: Some("thread-1".to_string()),
3807 stream: true,
3808 };
3809 let mut session = WebsocketSessionState {
3810 connection: None,
3811 last_request: Some(previous_request.clone()),
3812 last_response_id: Some("resp_prev".to_string()),
3813 last_response_items: vec![ApiInputItem::Message(ApiMessage {
3814 role: ApiRole::Assistant,
3815 content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
3816 text: "answer".to_string(),
3817 }]),
3818 phase: Some("final_answer".to_owned()),
3819 })],
3820 turn_state: None,
3821 prewarmed: false,
3824 websocket_disabled: false,
3825 in_flight: true,
3826 last_used: None,
3827 };
3828
3829 let retry_request = ApiStreamingRequest {
3835 input: vec![
3836 ApiInputItem::Message(ApiMessage {
3837 role: ApiRole::User,
3838 content: ApiMessageContent::Text("first".to_string()),
3839 phase: None,
3840 }),
3841 ApiInputItem::Message(ApiMessage {
3842 role: ApiRole::Assistant,
3843 content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
3844 text: "answer".to_string(),
3845 }]),
3846 phase: Some("final_answer".to_owned()),
3847 }),
3848 ApiInputItem::Message(ApiMessage {
3849 role: ApiRole::User,
3850 content: ApiMessageContent::Text("follow up".to_string()),
3851 phase: None,
3852 }),
3853 ],
3854 ..previous_request
3855 };
3856
3857 let incremental = prepare_websocket_request(&retry_request, &session, false);
3860
3861 assert_eq!(
3862 incremental.previous_response_id.as_deref(),
3863 Some("resp_prev"),
3864 "precondition: an intact baseline yields an incremental request",
3865 );
3866 assert_eq!(incremental.input.len(), 1);
3867
3868 end_websocket_turn(&mut session);
3869
3870 let websocket_request = prepare_websocket_request(&retry_request, &session, false);
3876 assert!(
3877 websocket_request.previous_response_id.is_none(),
3878 "a retry after a terminal WS error must not reuse the dead socket's response id",
3879 );
3880 assert_eq!(
3881 websocket_request.input.len(),
3882 3,
3883 "the retry must send the full input, not an incremental delta",
3884 );
3885
3886 assert!(session.last_request.is_none());
3889 assert!(session.last_response_id.is_none());
3890 assert!(session.last_response_items.is_empty());
3891 assert!(!session.in_flight, "the ended turn must be evictable");
3892 }
3893
3894 #[test]
3895 fn websocket_error_classification_is_network_specific() {
3896 use tokio_tungstenite::tungstenite::Error as WebSocketError;
3897
3898 assert_eq!(
3899 websocket_transport_error_kind(&WebSocketError::ConnectionClosed),
3900 StreamErrorKind::ConnectionLost
3901 );
3902 assert_eq!(
3903 websocket_transport_error_kind(&WebSocketError::Io(std::io::Error::new(
3904 std::io::ErrorKind::ConnectionReset,
3905 "reset",
3906 ))),
3907 StreamErrorKind::ConnectionLost
3908 );
3909 assert_eq!(
3910 websocket_transport_error_kind(&WebSocketError::AlreadyClosed),
3911 StreamErrorKind::ServerError
3912 );
3913 assert_eq!(
3914 websocket_transport_error_kind(&WebSocketError::Utf8("invalid".to_owned())),
3915 StreamErrorKind::ServerError
3916 );
3917 }
3918
3919 #[test]
3920 fn test_parse_wrapped_websocket_error_event_maps_http_status() -> anyhow::Result<()> {
3921 let payload = r#"{"type":"error","status":401,"error":{"message":"unauthorized"}}"#;
3922 let parsed =
3923 parse_wrapped_websocket_error_event(payload).context("expected a wrapped error")?;
3924
3925 assert_eq!(parsed.status, StatusCode::UNAUTHORIZED);
3926 assert_eq!(parsed.message, "unauthorized");
3927 assert!(!parsed.connection_limit);
3928 Ok(())
3929 }
3930
3931 #[test]
3932 fn test_parse_wrapped_websocket_error_event_maps_connection_limit() -> anyhow::Result<()> {
3933 let payload = format!(
3934 r#"{{"type":"error","status":429,"error":{{"code":"{OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE}","message":"limit"}}}}"#,
3935 );
3936 let parsed =
3937 parse_wrapped_websocket_error_event(&payload).context("expected a wrapped error")?;
3938
3939 assert_eq!(parsed.status, StatusCode::TOO_MANY_REQUESTS);
3940 assert_eq!(parsed.message, "limit");
3941 assert!(parsed.connection_limit);
3942 assert!(
3945 !is_websocket_quota_rejection(&parsed),
3946 "the connection-limit fallback must not be treated as a quota wait"
3947 );
3948 Ok(())
3949 }
3950
3951 #[test]
3952 fn pre_output_websocket_quota_rejection_surfaces_with_its_hint() -> anyhow::Result<()> {
3953 let payload = r#"{"type":"error","status":429,"error":{"code":"rate_limit_exceeded","message":"Rate limit reached. Please try again in 30s."}}"#;
3957 let parsed =
3958 parse_wrapped_websocket_error_event(payload).context("expected a wrapped error")?;
3959
3960 assert!(!parsed.connection_limit);
3961 assert!(
3962 is_websocket_quota_rejection(&parsed),
3963 "a quota 429 must be surfaced rather than retried immediately"
3964 );
3965
3966 let kind = websocket_error_kind(&parsed);
3967 assert_eq!(
3968 kind,
3969 StreamErrorKind::RateLimited(Some(std::time::Duration::from_secs(30)))
3970 );
3971 assert!(kind.is_recoverable());
3972 Ok(())
3973 }
3974
3975 fn unix_now() -> i64 {
3977 i64::try_from(
3978 std::time::SystemTime::now()
3979 .duration_since(std::time::UNIX_EPOCH)
3980 .map(|since| since.as_secs())
3981 .unwrap_or_default(),
3982 )
3983 .unwrap_or_default()
3984 }
3985
3986 #[test]
3987 fn usage_limit_frame_carries_its_structured_reset() -> anyhow::Result<()> {
3988 let parsed = parse_wrapped_websocket_error_event(
3992 r#"{"type":"error","status":429,"error":{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_in_seconds":1800}}"#,
3993 )
3994 .context("expected a wrapped error")?;
3995
3996 assert_eq!(
3997 websocket_error_kind(&parsed),
3998 StreamErrorKind::RateLimited(Some(std::time::Duration::from_mins(30)))
3999 );
4000 Ok(())
4001 }
4002
4003 #[test]
4004 fn usage_limit_reset_falls_back_to_the_absolute_instant() -> anyhow::Result<()> {
4005 let future = unix_now() + 600;
4006 let parsed = parse_wrapped_websocket_error_event(&format!(
4007 r#"{{"type":"error","status":429,"error":{{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_at":{future}}}}}"#,
4008 ))
4009 .context("expected a wrapped error")?;
4010
4011 let StreamErrorKind::RateLimited(Some(delay)) = websocket_error_kind(&parsed) else {
4012 anyhow::bail!("a usage-limit frame must be a rate limit with a delay");
4013 };
4014 assert!(
4017 delay <= std::time::Duration::from_mins(10)
4018 && delay >= std::time::Duration::from_secs(590),
4019 "the absolute reset must convert to a ~600s wait, got {delay:?}"
4020 );
4021 Ok(())
4022 }
4023
4024 #[test]
4025 fn usage_limit_reset_in_the_past_or_absent_reports_no_hint() -> anyhow::Result<()> {
4026 let past = unix_now() - 60;
4029 let elapsed = parse_wrapped_websocket_error_event(&format!(
4030 r#"{{"type":"error","status":429,"error":{{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_at":{past}}}}}"#,
4031 ))
4032 .context("expected a wrapped error")?;
4033 assert_eq!(
4034 websocket_error_kind(&elapsed),
4035 StreamErrorKind::RateLimited(None)
4036 );
4037
4038 let bare = parse_wrapped_websocket_error_event(
4041 r#"{"type":"error","status":429,"error":{"type":"usage_limit_reached","message":"The usage limit has been reached."}}"#,
4042 )
4043 .context("expected a wrapped error")?;
4044 assert_eq!(
4045 websocket_error_kind(&bare),
4046 StreamErrorKind::RateLimited(None)
4047 );
4048
4049 let zero = parse_wrapped_websocket_error_event(
4051 r#"{"type":"error","status":429,"error":{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_in_seconds":0}}"#,
4052 )
4053 .context("expected a wrapped error")?;
4054 assert_eq!(
4055 websocket_error_kind(&zero),
4056 StreamErrorKind::RateLimited(None)
4057 );
4058 Ok(())
4059 }
4060
4061 #[test]
4062 fn structured_reset_wins_over_the_message_prose() -> anyhow::Result<()> {
4063 let parsed = parse_wrapped_websocket_error_event(
4065 r#"{"type":"error","status":429,"error":{"code":"rate_limit_exceeded","message":"Please try again in 5s.","resets_in_seconds":120}}"#,
4066 )
4067 .context("expected a wrapped error")?;
4068
4069 assert_eq!(
4070 websocket_error_kind(&parsed),
4071 StreamErrorKind::RateLimited(Some(std::time::Duration::from_mins(2)))
4072 );
4073 Ok(())
4074 }
4075
4076 #[test]
4077 fn float_encoded_resets_at_still_parses_and_classifies() -> anyhow::Result<()> {
4078 let future = f64::from(u32::try_from(unix_now())?) + 600.5;
4082 let parsed = parse_wrapped_websocket_error_event(&format!(
4083 r#"{{"type":"error","status":429,"error":{{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_at":{future}}}}}"#,
4084 ))
4085 .context("a float resets_at must still parse as a wrapped error")?;
4086
4087 let StreamErrorKind::RateLimited(Some(delay)) = websocket_error_kind(&parsed) else {
4088 anyhow::bail!("a usage-limit frame must classify as a rate limit with a delay");
4089 };
4090 assert!(
4091 delay <= std::time::Duration::from_mins(10)
4092 && delay >= std::time::Duration::from_secs(590),
4093 "the float reset must convert to a ~600s wait, got {delay:?}"
4094 );
4095 Ok(())
4096 }
4097
4098 #[test]
4099 fn codex_http_reset_after_reads_the_structured_body() -> anyhow::Result<()> {
4100 assert_eq!(
4103 codex_http_reset_after(
4104 r#"{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_in_seconds":900}}"#
4105 ),
4106 Some(std::time::Duration::from_mins(15))
4107 );
4108
4109 let future = f64::from(u32::try_from(unix_now())?) + 300.0;
4111 let delay = codex_http_reset_after(&format!(
4112 r#"{{"error":{{"type":"usage_limit_reached","resets_at":{future}}}}}"#,
4113 ))
4114 .context("a float resets_at HTTP body must parse")?;
4115 assert!(
4116 delay <= std::time::Duration::from_mins(5)
4117 && delay >= std::time::Duration::from_secs(290),
4118 "got {delay:?}"
4119 );
4120
4121 assert_eq!(codex_http_reset_after("429 Too Many Requests"), None);
4123 assert_eq!(
4124 codex_http_reset_after(r#"{"error":{"message":"slow down"}}"#),
4125 None
4126 );
4127 let past = f64::from(u32::try_from(unix_now())?) - 60.0;
4128 assert_eq!(
4129 codex_http_reset_after(&format!(r#"{{"error":{{"resets_at":{past}}}}}"#)),
4130 None
4131 );
4132 Ok(())
4133 }
4134
4135 #[test]
4136 fn test_prepare_websocket_request_allows_empty_delta_after_prewarm() {
4137 let request = ApiStreamingRequest {
4138 model: MODEL_GPT53_CODEX.to_string(),
4139 instructions: "system".to_string(),
4140 input: vec![ApiInputItem::Message(ApiMessage {
4141 role: ApiRole::User,
4142 content: ApiMessageContent::Text("first".to_string()),
4143 phase: None,
4144 })],
4145 tools: None,
4146 max_output_tokens: None,
4147 reasoning: None,
4148 tool_choice: Some(ApiToolChoice::Mode("auto")),
4149 parallel_tool_calls: None,
4150 store: false,
4151 text: Some(ApiTextSettings {
4152 verbosity: "medium",
4153 format: None,
4154 }),
4155 include: Some(vec!["reasoning.encrypted_content".to_string()]),
4156 prompt_cache_key: Some("thread-1".to_string()),
4157 stream: true,
4158 };
4159 let session = WebsocketSessionState {
4160 connection: None,
4161 last_request: Some(request.clone()),
4162 last_response_id: Some("resp_prewarm".to_string()),
4163 last_response_items: Vec::new(),
4164 turn_state: None,
4165 prewarmed: true,
4166 websocket_disabled: false,
4167 in_flight: false,
4168 last_used: None,
4169 };
4170
4171 let websocket_request = prepare_websocket_request(&request, &session, true);
4172 assert_eq!(
4173 websocket_request.previous_response_id.as_deref(),
4174 Some("resp_prewarm")
4175 );
4176 assert!(websocket_request.input.is_empty());
4177 }
4178
4179 #[test]
4180 fn test_api_response_deserialization() {
4181 let json = r#"{
4182 "id": "resp_123",
4183 "model": "gpt-5.2-codex",
4184 "output": [
4185 {
4186 "type": "message",
4187 "role": "assistant",
4188 "content": [
4189 {"type": "output_text", "text": "Hello!"}
4190 ]
4191 }
4192 ],
4193 "status": "completed",
4194 "usage": {
4195 "input_tokens": 100,
4196 "output_tokens": 50
4197 }
4198 }"#;
4199
4200 let response: ApiResponse = serde_json::from_str(json).unwrap();
4201 assert_eq!(response.id, "resp_123");
4202 assert_eq!(response.model, "gpt-5.2-codex");
4203 assert_eq!(response.output.len(), 1);
4204 }
4205
4206 #[test]
4207 fn test_api_response_with_function_call() {
4208 let json = r#"{
4209 "id": "resp_456",
4210 "model": "gpt-5.2-codex",
4211 "output": [
4212 {
4213 "type": "function_call",
4214 "call_id": "call_abc",
4215 "name": "read_file",
4216 "arguments": "{\"path\": \"test.txt\"}"
4217 }
4218 ],
4219 "status": "completed"
4220 }"#;
4221
4222 let response: ApiResponse = serde_json::from_str(json).unwrap();
4223 assert_eq!(response.output.len(), 1);
4224
4225 match &response.output[0] {
4226 ApiOutputItem::FunctionCall {
4227 call_id,
4228 name,
4229 arguments,
4230 } => {
4231 assert_eq!(call_id, "call_abc");
4232 assert_eq!(name, "read_file");
4233 assert!(arguments.contains("test.txt"));
4234 }
4235 _ => panic!("Expected FunctionCall"),
4236 }
4237 }
4238
4239 #[test]
4240 fn test_build_api_input_uses_responses_text_types_by_role() {
4241 let request = ChatRequest {
4242 system: "system".to_string(),
4243 messages: vec![
4244 agent_sdk_foundation::llm::Message::user_with_content(vec![ContentBlock::Text {
4245 text: "question".to_string(),
4246 }]),
4247 agent_sdk_foundation::llm::Message {
4248 role: agent_sdk_foundation::llm::Role::Assistant,
4249 content: Content::Blocks(vec![ContentBlock::Text {
4250 text: "answer".to_string(),
4251 }]),
4252 },
4253 ],
4254 tools: None,
4255 max_tokens: 512,
4256 max_tokens_explicit: false,
4257 session_id: None,
4258 cached_content: None,
4259 thinking: None,
4260 tool_choice: None,
4261 response_format: None,
4262 cache: None,
4263 };
4264
4265 let input = build_api_input(&request);
4266 assert_eq!(input.len(), 2);
4267
4268 match &input[0] {
4269 ApiInputItem::Message(ApiMessage {
4270 role: ApiRole::User,
4271 content: ApiMessageContent::Parts(parts),
4272 ..
4273 }) => assert!(matches!(
4274 parts.as_slice(),
4275 [ApiInputContent::InputText { text }] if text == "question"
4276 )),
4277 _ => panic!("expected user message with input_text content"),
4278 }
4279
4280 match &input[1] {
4281 ApiInputItem::Message(ApiMessage {
4282 role: ApiRole::Assistant,
4283 content: ApiMessageContent::Parts(parts),
4284 ..
4285 }) => assert!(matches!(
4286 parts.as_slice(),
4287 [ApiInputContent::OutputText { text }] if text == "answer"
4288 )),
4289 _ => panic!("expected assistant message with output_text content"),
4290 }
4291 }
4292
4293 #[test]
4294 fn test_api_input_content_serialization_uses_current_responses_tags() {
4295 let json = serde_json::to_string(&ApiMessageContent::Parts(vec![
4296 ApiInputContent::InputText {
4297 text: "prompt".to_string(),
4298 },
4299 ApiInputContent::OutputText {
4300 text: "reply".to_string(),
4301 },
4302 ApiInputContent::Image {
4303 image_url: "data:image/png;base64,abc".to_string(),
4304 },
4305 ApiInputContent::File {
4306 filename: "notes.txt".to_string(),
4307 file_data: "data:text/plain;base64,abc".to_string(),
4308 },
4309 ]))
4310 .unwrap();
4311
4312 assert!(json.contains("\"type\":\"input_text\""));
4313 assert!(json.contains("\"type\":\"output_text\""));
4314 assert!(json.contains("\"type\":\"input_image\""));
4315 assert!(json.contains("\"type\":\"input_file\""));
4316 }
4317
4318 #[test]
4319 fn test_build_content_blocks_text() {
4320 let output = vec![ApiOutputItem::Message {
4321 role: "assistant".to_owned(),
4322 phase: Some("final_answer".to_owned()),
4323 content: vec![ApiOutputContent::Text {
4324 text: "Hello!".to_owned(),
4325 }],
4326 }];
4327
4328 let blocks = build_content_blocks(&output);
4329 assert_eq!(blocks.len(), 2);
4330 assert!(matches!(
4331 &blocks[0],
4332 ContentBlock::OpaqueReasoning { data, .. }
4333 if data["phase"] == "final_answer"
4334 ));
4335 assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Hello!"));
4336 }
4337
4338 #[test]
4339 fn assistant_message_phases_round_trip_without_duplicate_text() -> anyhow::Result<()> {
4340 let output: Vec<ApiOutputItem> = serde_json::from_value(serde_json::json!([
4341 {
4342 "type": "message",
4343 "role": "assistant",
4344 "phase": "commentary",
4345 "content": [{"type": "output_text", "text": "Working."}]
4346 },
4347 {
4348 "type": "message",
4349 "role": "assistant",
4350 "phase": "final_answer",
4351 "content": [{"type": "output_text", "text": "Done."}]
4352 }
4353 ]))?;
4354 let blocks = build_content_blocks(&output);
4355 let request =
4356 ChatRequest::new(String::new(), vec![Message::assistant_with_content(blocks)]);
4357 let value = serde_json::to_value(build_api_input(&request))?;
4358 let items = value
4359 .as_array()
4360 .context("Codex input must serialize as an array")?;
4361
4362 assert_eq!(items.len(), 2);
4363 assert_eq!(items[0]["phase"], "commentary");
4364 assert_eq!(items[0]["content"][0]["text"], "Working.");
4365 assert_eq!(items[1]["phase"], "final_answer");
4366 assert_eq!(items[1]["content"][0]["text"], "Done.");
4367 assert_eq!(value.to_string().matches("Working.").count(), 1);
4368 assert_eq!(value.to_string().matches("Done.").count(), 1);
4369
4370 let direct: ApiOutputItem = serde_json::from_value(serde_json::json!({
4371 "type": "message",
4372 "role": "assistant",
4373 "phase": "commentary",
4374 "content": [{"type": "output_text", "text": "Working."}]
4375 }))?;
4376 let direct = output_item_to_input_item(direct)
4377 .context("message output should become a continuation input")?;
4378 assert_eq!(serde_json::to_value(direct)?["phase"], "commentary");
4379 Ok(())
4380 }
4381
4382 #[test]
4383 fn streamed_phase_and_reasoning_summary_preserve_block_order() -> anyhow::Result<()> {
4384 let message: ApiOutputItem = serde_json::from_value(serde_json::json!({
4385 "type": "message",
4386 "role": "assistant",
4387 "phase": "commentary",
4388 "content": [{"type": "output_text", "text": "Working."}]
4389 }))?;
4390 let mut accumulator = crate::streaming::StreamAccumulator::new();
4391 accumulator.apply(&StreamDelta::TextDelta {
4392 delta: "Working.".to_owned(),
4393 block_index: output_block_index(Some(0)),
4394 });
4395 for delta in output_item_stream_deltas(&message, 0, true) {
4396 accumulator.apply(&delta);
4397 }
4398 let message_blocks = accumulator.into_content_blocks();
4399 assert!(matches!(
4400 message_blocks.as_slice(),
4401 [ContentBlock::OpaqueReasoning { data, .. }, ContentBlock::Text { text }]
4402 if data["phase"] == "commentary" && text == "Working."
4403 ));
4404
4405 let reasoning: ApiOutputItem = serde_json::from_value(serde_json::json!({
4406 "type": "reasoning",
4407 "id": "rs_1",
4408 "encrypted_content": "ciphertext",
4409 "summary": [{"type": "summary_text", "text": "Checked."}]
4410 }))?;
4411 let mut accumulator = crate::streaming::StreamAccumulator::new();
4412 for delta in output_item_stream_deltas(&reasoning, 1, true) {
4413 accumulator.apply(&delta);
4414 }
4415 let reasoning_blocks = accumulator.into_content_blocks();
4416 assert!(matches!(
4417 reasoning_blocks.as_slice(),
4418 [ContentBlock::OpaqueReasoning { data, .. }, ContentBlock::Thinking { thinking, .. }]
4419 if data["encrypted_content"] == "ciphertext" && thinking == "Checked."
4420 ));
4421 Ok(())
4422 }
4423
4424 #[test]
4425 fn test_build_content_blocks_function_call() {
4426 let output = vec![ApiOutputItem::FunctionCall {
4427 call_id: "call_123".to_owned(),
4428 name: "test_tool".to_owned(),
4429 arguments: r#"{"key": "value"}"#.to_owned(),
4430 }];
4431
4432 let blocks = build_content_blocks(&output);
4433 assert_eq!(blocks.len(), 1);
4434 assert!(
4435 matches!(&blocks[0], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "test_tool")
4436 );
4437 }
4438
4439 #[test]
4440 fn incomplete_and_refusal_responses_suppress_partial_tools() -> anyhow::Result<()> {
4441 let incomplete: ApiResponse = serde_json::from_value(serde_json::json!({
4442 "id": "resp_incomplete",
4443 "model": "gpt-5.3-codex",
4444 "status": "incomplete",
4445 "incomplete_details": {"reason": "model_context_window_exceeded"},
4446 "output": [{
4447 "type": "function_call",
4448 "call_id": "call_partial",
4449 "name": "lookup",
4450 "arguments": "{"
4451 }]
4452 }))?;
4453 let incomplete = OpenAICodexResponsesProvider::map_response(incomplete);
4454 assert_eq!(
4455 incomplete.stop_reason,
4456 Some(StopReason::ModelContextWindowExceeded)
4457 );
4458 assert!(
4459 !incomplete
4460 .content
4461 .iter()
4462 .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
4463 );
4464
4465 let refusal: ApiResponse = serde_json::from_value(serde_json::json!({
4466 "id": "resp_refusal",
4467 "model": "gpt-5.3-codex",
4468 "status": "completed",
4469 "output": [
4470 {
4471 "type": "function_call",
4472 "call_id": "call_partial",
4473 "name": "lookup",
4474 "arguments": "{}"
4475 },
4476 {
4477 "type": "message",
4478 "role": "assistant",
4479 "phase": "final_answer",
4480 "content": [{"type": "refusal", "refusal": "Cannot comply."}]
4481 }
4482 ]
4483 }))?;
4484 let refusal = OpenAICodexResponsesProvider::map_response(refusal);
4485 assert_eq!(refusal.stop_reason, Some(StopReason::Refusal));
4486 assert!(
4487 !refusal
4488 .content
4489 .iter()
4490 .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
4491 );
4492 assert!(matches!(
4493 refusal.content.last(),
4494 Some(ContentBlock::Text { text }) if text == "Cannot comply."
4495 ));
4496 Ok(())
4497 }
4498
4499 #[test]
4500 fn reasoning_output_item_is_preserved_and_summary_is_visible() -> anyhow::Result<()> {
4501 let raw = serde_json::json!({
4502 "type": "reasoning",
4503 "id": "rs_123",
4504 "status": "completed",
4505 "encrypted_content": "ciphertext",
4506 "summary": [
4507 {"type": "summary_text", "text": "Checked the relevant constraints."}
4508 ]
4509 });
4510 let item: ApiOutputItem = serde_json::from_value(raw.clone())?;
4511 let replay_item = output_item_to_input_item(item);
4512 let Some(replay_item) = replay_item else {
4513 anyhow::bail!("reasoning item was not converted to a replay item");
4514 };
4515 assert_eq!(serde_json::to_value(replay_item)?, raw);
4516
4517 let item: ApiOutputItem = serde_json::from_value(raw.clone())?;
4518 let blocks = build_content_blocks(&[item]);
4519 assert_eq!(blocks.len(), 2);
4520 assert!(matches!(
4521 &blocks[0],
4522 ContentBlock::OpaqueReasoning { provider, data, .. }
4523 if provider == OPENAI_RESPONSES_REASONING_PROVIDER && data == &raw
4524 ));
4525 assert!(matches!(
4526 &blocks[1],
4527 ContentBlock::Thinking { thinking, signature, .. }
4528 if thinking == "Checked the relevant constraints." && signature.is_none()
4529 ));
4530 Ok(())
4531 }
4532
4533 #[test]
4534 fn matching_opaque_reasoning_replays_as_a_top_level_item_in_source_order() -> anyhow::Result<()>
4535 {
4536 let raw = serde_json::json!({
4537 "type": "reasoning",
4538 "id": "rs_123",
4539 "encrypted_content": "ciphertext",
4540 "summary": []
4541 });
4542 let request = ChatRequest::new(
4543 "",
4544 vec![agent_sdk_foundation::llm::Message {
4545 role: agent_sdk_foundation::llm::Role::Assistant,
4546 content: Content::Blocks(vec![
4547 ContentBlock::Text {
4548 text: "before".to_owned(),
4549 },
4550 ContentBlock::OpaqueReasoning {
4551 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
4552 data: raw.clone(),
4553 },
4554 ContentBlock::OpaqueReasoning {
4555 provider: "another-provider".to_owned(),
4556 data: serde_json::json!({"type": "reasoning", "id": "ignored"}),
4557 },
4558 ContentBlock::Text {
4559 text: "after".to_owned(),
4560 },
4561 ]),
4562 }],
4563 );
4564
4565 assert_eq!(
4566 serde_json::to_value(build_api_input(&request))?,
4567 serde_json::json!([
4568 {
4569 "role": "assistant",
4570 "phase": "final_answer",
4571 "content": [{"type": "output_text", "text": "before"}]
4572 },
4573 raw,
4574 {
4575 "role": "assistant",
4576 "phase": "final_answer",
4577 "content": [{"type": "output_text", "text": "after"}]
4578 }
4579 ])
4580 );
4581 Ok(())
4582 }
4583
4584 #[test]
4585 fn usage_maps_cache_write_tokens() -> anyhow::Result<()> {
4586 let usage: ApiUsage = serde_json::from_value(serde_json::json!({
4587 "input_tokens": 2048,
4588 "output_tokens": 128,
4589 "input_tokens_details": {
4590 "cached_tokens": 1024,
4591 "cache_write_tokens": 512
4592 }
4593 }))?;
4594
4595 let usage = usage_from_api_usage(&usage);
4596 assert_eq!(usage.cached_input_tokens, 1024);
4597 assert_eq!(usage.cache_creation_input_tokens, 512);
4598 Ok(())
4599 }
4600
4601 #[test]
4602 fn stream_stop_reason_requires_a_semantic_terminal_event() {
4603 let tool_calls = HashMap::new();
4604 assert!(stop_reason_from_stream_state(&tool_calls, None, false, None).is_none());
4605 assert!(matches!(
4606 stop_reason_from_stream_state(&tool_calls, Some(ApiStatus::Completed), false, None,),
4607 Some(StopReason::EndTurn)
4608 ));
4609 assert!(matches!(
4610 stop_reason_from_stream_state(
4611 &tool_calls,
4612 Some(ApiStatus::Incomplete),
4613 false,
4614 Some("max_output_tokens"),
4615 ),
4616 Some(StopReason::MaxTokens)
4617 ));
4618 }
4619
4620 #[test]
4621 fn test_request_serializes_response_format_text_and_forced_tool_choice() {
4622 let request = ApiStreamingRequest {
4623 model: MODEL_GPT53_CODEX.to_string(),
4624 instructions: String::new(),
4625 input: Vec::new(),
4626 tools: None,
4627 max_output_tokens: None,
4628 reasoning: None,
4629 tool_choice: Some(codex_tool_choice(Some(&ToolChoice::Tool(
4630 "respond".to_owned(),
4631 )))),
4632 parallel_tool_calls: None,
4633 store: false,
4634 text: Some(ApiTextSettings {
4635 verbosity: "medium",
4636 format: Some(ApiResponseTextFormat::from(&ResponseFormat::new(
4637 "person",
4638 serde_json::json!({"type": "object"}),
4639 ))),
4640 }),
4641 include: None,
4642 prompt_cache_key: None,
4643 stream: true,
4644 };
4645
4646 let json = serde_json::to_value(&request).unwrap();
4647 assert_eq!(json["text"]["format"]["type"], "json_schema");
4648 assert_eq!(json["text"]["format"]["name"], "person");
4649 assert_eq!(json["text"]["format"]["strict"], true);
4650 assert_eq!(json["tool_choice"]["type"], "function");
4651 assert_eq!(json["tool_choice"]["name"], "respond");
4652 }
4653
4654 #[test]
4655 fn test_codex_tool_choice_defaults_to_auto() {
4656 assert_eq!(
4657 serde_json::to_value(codex_tool_choice(None)).unwrap(),
4658 serde_json::json!("auto")
4659 );
4660 assert_eq!(
4661 serde_json::to_value(codex_tool_choice(Some(&ToolChoice::Auto))).unwrap(),
4662 serde_json::json!("auto")
4663 );
4664 }
4665
4666 #[test]
4667 fn test_convert_tool_makes_optional_params_nullable() {
4668 let tool = agent_sdk_foundation::llm::Tool {
4669 name: "t".to_string(),
4670 description: "d".to_string(),
4671 input_schema: serde_json::json!({
4672 "type": "object",
4673 "properties": {
4674 "req": {"type": "string"},
4675 "opt": {"type": "string"}
4676 },
4677 "required": ["req"]
4678 }),
4679 display_name: "T".to_string(),
4680 tier: agent_sdk_foundation::ToolTier::Observe,
4681 };
4682
4683 let api_tool = convert_tool(tool);
4684 assert_eq!(api_tool.strict, Some(true));
4685 let schema = api_tool.parameters.unwrap();
4686
4687 let required: Vec<&str> = schema["required"]
4688 .as_array()
4689 .unwrap()
4690 .iter()
4691 .filter_map(|v| v.as_str())
4692 .collect();
4693 assert!(required.contains(&"req"));
4694 assert!(required.contains(&"opt"));
4695
4696 let any_of = schema["properties"]["opt"]["anyOf"].as_array().unwrap();
4699 assert!(
4700 any_of
4701 .iter()
4702 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"))
4703 );
4704 }
4705
4706 #[test]
4707 fn test_convert_tool_disables_strict_for_freeform_object() {
4708 let tool = agent_sdk_foundation::llm::Tool {
4709 name: "t".to_string(),
4710 description: "d".to_string(),
4711 input_schema: serde_json::json!({"type": "object"}),
4712 display_name: "T".to_string(),
4713 tier: agent_sdk_foundation::ToolTier::Observe,
4714 };
4715
4716 let api_tool = convert_tool(tool);
4717 assert_eq!(api_tool.strict, None);
4718 }
4719
4720 #[test]
4721 fn test_emit_accumulated_tool_calls_assigns_distinct_ordered_indices() {
4722 let mut tool_calls = HashMap::new();
4723 tool_calls.insert(
4724 "b".to_string(),
4725 ToolCallAccumulator {
4726 id: "b".to_string(),
4727 name: "second".to_string(),
4728 arguments: "{}".to_string(),
4729 order: 1,
4730 block_index: None,
4731 },
4732 );
4733 tool_calls.insert(
4734 "a".to_string(),
4735 ToolCallAccumulator {
4736 id: "a".to_string(),
4737 name: "first".to_string(),
4738 arguments: "{}".to_string(),
4739 order: 0,
4740 block_index: None,
4741 },
4742 );
4743
4744 let deltas = emit_accumulated_tool_calls(&tool_calls);
4745 let starts: Vec<(String, usize)> = deltas
4746 .iter()
4747 .filter_map(|d| match d {
4748 StreamDelta::ToolUseStart {
4749 name, block_index, ..
4750 } => Some((name.clone(), *block_index)),
4751 _ => None,
4752 })
4753 .collect();
4754 assert_eq!(
4755 starts,
4756 vec![("first".to_string(), 2), ("second".to_string(), 4)]
4757 );
4758 }
4759
4760 use crate::provider::LlmProvider;
4765 use agent_sdk_foundation::llm::{ChatRequest, Message};
4766 use std::sync::atomic::AtomicUsize;
4767 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4768 use tokio::net::TcpListener;
4769
4770 fn oauth_token() -> String {
4773 test_token()
4774 }
4775
4776 fn streaming_request(session_id: &str) -> ChatRequest {
4777 ChatRequest::new("You are helpful.", vec![Message::user("hello")])
4778 .with_max_tokens(1024)
4779 .with_session_id(session_id)
4780 }
4781
4782 async fn read_http_head(stream: &mut tokio::net::TcpStream) -> String {
4784 let mut buf = Vec::new();
4785 let mut byte = [0u8; 1];
4786 while stream.read_exact(&mut byte).await.is_ok() {
4787 buf.push(byte[0]);
4788 if buf.ends_with(b"\r\n\r\n") {
4789 break;
4790 }
4791 if buf.len() > 16 * 1024 {
4792 break;
4793 }
4794 }
4795 String::from_utf8_lossy(&buf).into_owned()
4796 }
4797
4798 const HTTP_SSE_BODY: &str = concat!(
4800 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n",
4801 "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\"}}\n\n",
4802 "data: [DONE]\n\n",
4803 );
4804
4805 async fn spawn_http_only_server_with_body(
4809 sse_body: &'static str,
4810 ) -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
4811 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4812 let addr = listener.local_addr().unwrap();
4813 let ws_attempts = Arc::new(AtomicUsize::new(0));
4814 let http_requests = Arc::new(AtomicUsize::new(0));
4815 let ws_attempts_task = ws_attempts.clone();
4816 let http_requests_task = http_requests.clone();
4817
4818 tokio::spawn(async move {
4819 loop {
4820 let Ok((mut stream, _)) = listener.accept().await else {
4821 break;
4822 };
4823 let ws_attempts = ws_attempts_task.clone();
4824 let http_requests = http_requests_task.clone();
4825 tokio::spawn(async move {
4826 let head = read_http_head(&mut stream).await;
4827 if head.to_ascii_lowercase().contains("upgrade: websocket") {
4828 ws_attempts.fetch_add(1, Ordering::Relaxed);
4829 let _ = stream
4833 .write_all(
4834 b"HTTP/1.1 426 Upgrade Required\r\ncontent-length: 0\r\n\r\n",
4835 )
4836 .await;
4837 return;
4838 }
4839 http_requests.fetch_add(1, Ordering::Relaxed);
4840 let response = format!(
4841 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{}",
4842 sse_body.len(),
4843 sse_body,
4844 );
4845 let _ = stream.write_all(response.as_bytes()).await;
4846 });
4847 }
4848 });
4849
4850 (
4851 format!("http://{addr}/backend-api"),
4852 ws_attempts,
4853 http_requests,
4854 )
4855 }
4856
4857 async fn spawn_http_only_server() -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
4858 spawn_http_only_server_with_body(HTTP_SSE_BODY).await
4859 }
4860
4861 async fn spawn_http_status_server(status_line: &'static str, body: &'static str) -> String {
4864 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4865 let addr = listener.local_addr().unwrap();
4866 tokio::spawn(async move {
4867 loop {
4868 let Ok((mut stream, _)) = listener.accept().await else {
4869 break;
4870 };
4871 tokio::spawn(async move {
4872 let head = read_http_head(&mut stream).await;
4873 if head.to_ascii_lowercase().contains("upgrade: websocket") {
4874 let _ = stream
4875 .write_all(
4876 b"HTTP/1.1 426 Upgrade Required\r\ncontent-length: 0\r\n\r\n",
4877 )
4878 .await;
4879 return;
4880 }
4881 let response = format!(
4882 "HTTP/1.1 {status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{body}",
4883 body.len(),
4884 );
4885 let _ = stream.write_all(response.as_bytes()).await;
4886 });
4887 }
4888 });
4889 format!("http://{addr}/backend-api")
4890 }
4891
4892 #[tokio::test]
4893 async fn http_chat_429_reads_the_structured_reset_body() -> anyhow::Result<()> {
4894 let base_url = spawn_http_status_server(
4897 "429 Too Many Requests",
4898 r#"{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_in_seconds":600}}"#,
4899 )
4900 .await;
4901 let provider = OpenAICodexResponsesProvider::with_base_url(
4902 oauth_token(),
4903 MODEL_GPT53_CODEX.to_string(),
4904 base_url,
4905 );
4906
4907 let outcome = provider
4908 .chat(ChatRequest::new(
4909 "You are helpful.",
4910 vec![Message::user("hi")],
4911 ))
4912 .await?;
4913 assert!(
4914 matches!(
4915 outcome,
4916 ChatOutcome::RateLimited(Some(delay)) if delay == std::time::Duration::from_mins(10)
4917 ),
4918 "the HTTP chat 429 must carry the structured reset, got {outcome:?}"
4919 );
4920 Ok(())
4921 }
4922
4923 #[tokio::test]
4924 async fn http_stream_429_reads_the_structured_reset_body() -> anyhow::Result<()> {
4925 let base_url = spawn_http_status_server(
4928 "429 Too Many Requests",
4929 r#"{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached.","resets_in_seconds":600}}"#,
4930 )
4931 .await;
4932 let provider = OpenAICodexResponsesProvider::with_base_url(
4933 oauth_token(),
4934 MODEL_GPT53_CODEX.to_string(),
4935 base_url,
4936 )
4937 .with_websockets_disabled(true);
4938
4939 let mut stream = std::pin::pin!(provider.chat_stream(streaming_request("session-429")));
4940 let mut kind = None;
4941 while let Some(item) = stream.next().await {
4942 if let StreamDelta::Error { kind: k, .. } = item? {
4943 kind = Some(k);
4944 break;
4945 }
4946 }
4947 assert_eq!(
4948 kind.context("the stream must surface an error")?,
4949 StreamErrorKind::RateLimited(Some(std::time::Duration::from_mins(10)))
4950 );
4951 Ok(())
4952 }
4953
4954 async fn drain_ok(provider: &OpenAICodexResponsesProvider, request: ChatRequest) -> bool {
4956 let mut stream = std::pin::pin!(provider.chat_stream(request));
4957 let mut saw_error = false;
4958 while let Some(item) = stream.next().await {
4959 match item {
4960 Ok(StreamDelta::Error { .. }) | Err(_) => saw_error = true,
4961 Ok(_) => {}
4962 }
4963 }
4964 !saw_error
4965 }
4966
4967 #[tokio::test]
4968 async fn websockets_disabled_builder_goes_straight_to_http() {
4969 let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
4970 let provider = OpenAICodexResponsesProvider::with_base_url(
4971 oauth_token(),
4972 MODEL_GPT53_CODEX.to_string(),
4973 base_url,
4974 )
4975 .with_websockets_disabled(true);
4976
4977 assert!(drain_ok(&provider, streaming_request("session-a")).await);
4978
4979 assert_eq!(
4980 ws_attempts.load(Ordering::Relaxed),
4981 0,
4982 "no websocket upgrade may be attempted when websockets are disabled",
4983 );
4984 assert_eq!(http_requests.load(Ordering::Relaxed), 1);
4985 }
4986
4987 #[tokio::test]
4988 async fn premature_http_stream_termination_is_an_error() {
4989 for (case, sse_body) in [
4990 ("done", "data: [DONE]\n\n"),
4991 (
4992 "eof",
4993 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
4994 ),
4995 ] {
4996 let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
4997 let provider = OpenAICodexResponsesProvider::with_base_url(
4998 oauth_token(),
4999 MODEL_GPT53_CODEX.to_string(),
5000 base_url,
5001 )
5002 .with_websockets_disabled(true);
5003 let mut stream = std::pin::pin!(
5004 provider.chat_stream(streaming_request(&format!("premature-{case}")))
5005 );
5006 let mut saw_error = false;
5007 let mut saw_done = false;
5008 while let Some(item) = stream.next().await {
5009 match item {
5010 Ok(StreamDelta::Error { .. }) | Err(_) => saw_error = true,
5011 Ok(StreamDelta::Done { .. }) => saw_done = true,
5012 Ok(_) => {}
5013 }
5014 }
5015 assert!(saw_error, "case={case} must surface a stream error");
5016 assert!(!saw_done, "case={case} must not synthesize success");
5017 }
5018 }
5019
5020 #[tokio::test]
5021 async fn malformed_http_events_and_items_fail_closed() -> anyhow::Result<()> {
5022 for (case, sse_body) in [
5023 ("event", "data: {not-json}\n\n"),
5024 (
5025 "item",
5026 "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\"}}\n\n",
5027 ),
5028 ] {
5029 let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
5030 let provider = OpenAICodexResponsesProvider::with_base_url(
5031 oauth_token(),
5032 MODEL_GPT53_CODEX.to_owned(),
5033 base_url,
5034 )
5035 .with_websockets_disabled(true);
5036 let mut stream = std::pin::pin!(
5037 provider.chat_stream(streaming_request(&format!("malformed-{case}")))
5038 );
5039 let first = stream
5040 .next()
5041 .await
5042 .context("malformed stream must emit an error")??;
5043 assert!(matches!(
5044 first,
5045 StreamDelta::Error {
5046 kind: StreamErrorKind::ServerError,
5047 ..
5048 }
5049 ));
5050 assert!(stream.next().await.is_none());
5051 }
5052 Ok(())
5053 }
5054
5055 #[tokio::test]
5056 async fn atomic_http_function_call_is_not_dropped() -> anyhow::Result<()> {
5057 let sse_body = concat!(
5058 "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"reasoning\",\"id\":\"rs_1\",\"encrypted_content\":\"ciphertext\",\"summary\":[]}}\n\n",
5059 "data: {\"type\":\"response.output_item.done\",\"output_index\":1,\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"read\",\"arguments\":\"{\\\"path\\\":\\\"src/lib.rs\\\"}\"}}\n\n",
5060 "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n",
5061 "data: [DONE]\n\n",
5062 );
5063 let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
5064 let provider = OpenAICodexResponsesProvider::with_base_url(
5065 oauth_token(),
5066 MODEL_GPT53_CODEX.to_owned(),
5067 base_url,
5068 )
5069 .with_websockets_disabled(true);
5070 let mut stream =
5071 std::pin::pin!(provider.chat_stream(streaming_request("atomic-function-call")));
5072 let mut deltas = Vec::new();
5073 while let Some(delta) = stream.next().await {
5074 deltas.push(delta?);
5075 }
5076
5077 assert!(deltas.iter().any(|delta| matches!(
5078 delta,
5079 StreamDelta::ToolUseStart { id, name, .. }
5080 if id == "call_1" && name == "read"
5081 )));
5082 assert!(deltas.iter().any(|delta| matches!(
5083 delta,
5084 StreamDelta::ToolInputDelta { id, delta, .. }
5085 if id == "call_1" && delta == r#"{"path":"src/lib.rs"}"#
5086 )));
5087 assert!(matches!(
5088 deltas.last(),
5089 Some(StreamDelta::Done {
5090 stop_reason: Some(StopReason::ToolUse)
5091 })
5092 ));
5093 Ok(())
5094 }
5095
5096 #[tokio::test]
5097 async fn non_tool_http_terminals_suppress_partial_tool_calls() -> anyhow::Result<()> {
5098 for (case, sse_body, expected_stop) in [
5099 (
5100 "incomplete",
5101 concat!(
5102 "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"call_id\":\"call_1\",\"name\":\"lookup\",\"delta\":\"{\"}\n\n",
5103 "data: {\"type\":\"response.incomplete\",\"response\":{\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
5104 "data: [DONE]\n\n",
5105 ),
5106 StopReason::MaxTokens,
5107 ),
5108 (
5109 "refusal",
5110 concat!(
5111 "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"call_id\":\"call_1\",\"name\":\"lookup\",\"delta\":\"{}\"}\n\n",
5112 "data: {\"type\":\"response.refusal.delta\",\"output_index\":1,\"delta\":\"Cannot comply.\"}\n\n",
5113 "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n",
5114 "data: [DONE]\n\n",
5115 ),
5116 StopReason::Refusal,
5117 ),
5118 ] {
5119 let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
5120 let provider = OpenAICodexResponsesProvider::with_base_url(
5121 oauth_token(),
5122 MODEL_GPT53_CODEX.to_owned(),
5123 base_url,
5124 )
5125 .with_websockets_disabled(true);
5126 let mut stream = std::pin::pin!(
5127 provider.chat_stream(streaming_request(&format!("terminal-{case}")))
5128 );
5129 let mut deltas = Vec::new();
5130 while let Some(delta) = stream.next().await {
5131 deltas.push(delta?);
5132 }
5133 assert!(matches!(
5134 deltas.last(),
5135 Some(StreamDelta::Done {
5136 stop_reason: Some(stop_reason)
5137 }) if *stop_reason == expected_stop
5138 ));
5139 assert!(!deltas.iter().any(|delta| matches!(
5140 delta,
5141 StreamDelta::ToolUseStart { .. } | StreamDelta::ToolInputDelta { .. }
5142 )));
5143 }
5144 Ok(())
5145 }
5146
5147 #[test]
5148 fn parse_disable_websockets_value_recognizes_truthy_values() {
5149 for value in ["1", "true", "TRUE", " yes ", "on"] {
5150 assert!(
5151 parse_disable_websockets_value(Some(value)),
5152 "value={value:?} should disable websockets",
5153 );
5154 }
5155 for value in ["0", "false", "no", "off", "", "maybe"] {
5156 assert!(
5157 !parse_disable_websockets_value(Some(value)),
5158 "value={value:?} should NOT disable websockets",
5159 );
5160 }
5161 assert!(!parse_disable_websockets_value(None));
5162 }
5163
5164 #[tokio::test]
5165 async fn websockets_disabled_via_env_value_goes_straight_to_http() {
5166 let disabled = parse_disable_websockets_value(Some("1"));
5171 assert!(disabled);
5172
5173 let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
5174 let provider = OpenAICodexResponsesProvider::with_base_url(
5175 oauth_token(),
5176 MODEL_GPT53_CODEX.to_string(),
5177 base_url,
5178 )
5179 .with_websockets_disabled(disabled);
5180
5181 assert!(drain_ok(&provider, streaming_request("session-env")).await);
5182
5183 assert_eq!(
5184 ws_attempts.load(Ordering::Relaxed),
5185 0,
5186 "no websocket upgrade may be attempted when the env var forces HTTP-only",
5187 );
5188 assert_eq!(http_requests.load(Ordering::Relaxed), 1);
5189 }
5190
5191 #[tokio::test]
5192 async fn provider_marked_ws_unhealthy_skips_websocket_on_new_session() {
5193 let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
5194 let provider = OpenAICodexResponsesProvider::with_base_url(
5195 oauth_token(),
5196 MODEL_GPT53_CODEX.to_string(),
5197 base_url,
5198 );
5199
5200 provider.websockets_unhealthy.store(true, Ordering::Relaxed);
5203
5204 assert!(drain_ok(&provider, streaming_request("fresh-session")).await);
5206
5207 assert_eq!(
5208 ws_attempts.load(Ordering::Relaxed),
5209 0,
5210 "a websocket-unhealthy provider must not attempt a new upgrade",
5211 );
5212 assert_eq!(http_requests.load(Ordering::Relaxed), 1);
5213 }
5214
5215 async fn spawn_ws_unauthorized_server() -> (String, Arc<AtomicUsize>) {
5219 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5220 let addr = listener.local_addr().unwrap();
5221 let http_requests = Arc::new(AtomicUsize::new(0));
5222 let http_requests_task = http_requests.clone();
5223
5224 tokio::spawn(async move {
5225 loop {
5226 let Ok((stream, _)) = listener.accept().await else {
5227 break;
5228 };
5229 let http_requests = http_requests_task.clone();
5230 tokio::spawn(async move {
5231 let mut stream = stream;
5232 let mut peek = [0u8; 1024];
5235 let Ok(n) = stream.peek(&mut peek).await else {
5236 return;
5237 };
5238 let head = String::from_utf8_lossy(&peek[..n]).to_ascii_lowercase();
5239
5240 if head.contains("upgrade: websocket") {
5241 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
5244 return;
5245 };
5246 let payload =
5247 r#"{"type":"error","status":401,"error":{"message":"unauthorized"}}"#;
5248 let _ = ws
5249 .send(WebSocketMessage::Text(payload.to_string().into()))
5250 .await;
5251 let _ = ws.send(WebSocketMessage::Close(None)).await;
5252 return;
5253 }
5254
5255 let _ = read_http_head(&mut stream).await;
5257 http_requests.fetch_add(1, Ordering::Relaxed);
5258 let response = format!(
5259 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{}",
5260 HTTP_SSE_BODY.len(),
5261 HTTP_SSE_BODY,
5262 );
5263 let _ = stream.write_all(response.as_bytes()).await;
5264 });
5265 }
5266 });
5267
5268 (format!("http://{addr}/backend-api"), http_requests)
5269 }
5270
5271 #[tokio::test]
5272 async fn ws_unauthorized_disables_session_but_not_provider() {
5273 let (base_url, http_requests) = spawn_ws_unauthorized_server().await;
5274 let provider = OpenAICodexResponsesProvider::with_base_url(
5275 oauth_token(),
5276 MODEL_GPT53_CODEX.to_string(),
5277 base_url,
5278 );
5279
5280 assert!(drain_ok(&provider, streaming_request("auth-session")).await);
5283
5284 assert!(
5288 !provider.websockets_unhealthy.load(Ordering::Relaxed),
5289 "a 401 must not mark the provider websocket-transport-unhealthy",
5290 );
5291 let session = provider.websocket_session("auth-session").await;
5294 assert!(session.lock().await.websocket_disabled);
5295
5296 assert!(http_requests.load(Ordering::Relaxed) >= 1);
5297 }
5298}