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