1use crate::attachments::validate_request_attachments;
8use crate::provider::LlmProvider;
9use crate::streaming::{SseLineBuffer, StreamBox, StreamDelta, StreamErrorKind};
10use agent_sdk_foundation::llm::{
11 ChatOutcome, ChatRequest, ChatResponse, Content, ContentBlock, Effort, ResponseFormat,
12 StopReason, ThinkingConfig, ThinkingMode, ToolChoice, Usage,
13};
14use anyhow::{Context, Result};
15use async_trait::async_trait;
16use base64::Engine;
17use futures::{SinkExt, StreamExt};
18use reqwest::StatusCode;
19use serde::{Deserialize, Serialize};
20use std::collections::{HashMap, HashSet};
21use std::sync::Arc;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::time::{Duration, Instant};
24use tokio::net::TcpStream;
25use tokio::sync::Mutex;
26use tokio::time::timeout;
27use tokio_tungstenite::connect_async;
28use tokio_tungstenite::tungstenite::Message as WebSocketMessage;
29use tokio_tungstenite::tungstenite::client::IntoClientRequest;
30use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
31
32const DEFAULT_BASE_URL: &str = "https://chatgpt.com/backend-api";
33
34const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
36const WEBSOCKET_IO_TIMEOUT: Duration = Duration::from_mins(2);
40const MAX_WEBSOCKET_SESSIONS: usize = 512;
42const OPENAI_CODEX_DISABLE_WEBSOCKETS_ENV: &str = "OPENAI_CODEX_DISABLE_WEBSOCKETS";
48
49fn parse_disable_websockets_value(value: Option<&str>) -> bool {
55 value.is_some_and(|value| {
56 matches!(
57 value.trim().to_ascii_lowercase().as_str(),
58 "1" | "true" | "yes" | "on"
59 )
60 })
61}
62
63fn websockets_disabled_from_env() -> bool {
65 parse_disable_websockets_value(
66 std::env::var(OPENAI_CODEX_DISABLE_WEBSOCKETS_ENV)
67 .ok()
68 .as_deref(),
69 )
70}
71
72fn build_http_client() -> reqwest::Client {
76 reqwest::Client::builder()
77 .connect_timeout(CONNECT_TIMEOUT)
78 .tcp_keepalive(CONNECT_TIMEOUT)
79 .build()
80 .unwrap_or_default()
81}
82const OPENAI_CODEX_JWT_CLAIM_PATH: &str = "https://api.openai.com/auth";
83const OPENAI_CODEX_ORIGINATOR: &str = "codex_cli_rs";
84const OPENAI_CODEX_RESPONSES_BETA_HEADER: &str = "responses=experimental";
85const OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER: &str = "responses_websockets=2026-02-06";
86const OPENAI_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
87const OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE: &str =
88 "websocket_connection_limit_reached";
89const OPENAI_RESPONSES_REASONING_PROVIDER: &str = "openai-responses";
90const OPENAI_MESSAGE_ITEM_TYPE: &str = "message";
91
92pub const MODEL_GPT54: &str = "gpt-5.4";
94
95pub const MODEL_GPT53_CODEX: &str = "gpt-5.3-codex";
97
98pub const MODEL_GPT52_CODEX: &str = "gpt-5.2-codex";
100
101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
103#[serde(rename_all = "lowercase")]
104pub enum ReasoningEffort {
105 Low,
106 #[default]
107 Medium,
108 High,
109 #[serde(rename = "xhigh")]
111 XHigh,
112}
113
114#[derive(Clone)]
119pub struct OpenAICodexResponsesProvider {
120 client: reqwest::Client,
121 api_key: String,
122 model: String,
123 base_url: String,
124 thinking: Option<ThinkingConfig>,
125 account_id: Option<String>,
126 websocket_sessions: Arc<Mutex<HashMap<String, Arc<Mutex<WebsocketSessionState>>>>>,
127 websockets_disabled: bool,
132 websockets_unhealthy: Arc<AtomicBool>,
144}
145
146type CodexWebSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
147
148#[derive(Default)]
149struct WebsocketSessionState {
150 connection: Option<CodexWebSocket>,
151 last_request: Option<ApiStreamingRequest>,
152 last_response_id: Option<String>,
153 last_response_items: Vec<ApiInputItem>,
154 turn_state: Option<String>,
155 prewarmed: bool,
156 websocket_disabled: bool,
157 in_flight: bool,
163 last_used: Option<Instant>,
165}
166
167impl OpenAICodexResponsesProvider {
168 #[must_use]
170 pub fn new(api_key: String, model: String) -> Self {
171 Self {
172 client: build_http_client(),
173 api_key,
174 model,
175 base_url: DEFAULT_BASE_URL.to_owned(),
176 thinking: None,
177 account_id: None,
178 websocket_sessions: Arc::new(Mutex::new(HashMap::new())),
179 websockets_disabled: websockets_disabled_from_env(),
180 websockets_unhealthy: Arc::new(AtomicBool::new(false)),
181 }
182 }
183
184 #[must_use]
186 pub fn with_base_url(api_key: String, model: String, base_url: String) -> Self {
187 Self {
188 client: build_http_client(),
189 api_key,
190 model,
191 base_url,
192 thinking: None,
193 account_id: None,
194 websocket_sessions: Arc::new(Mutex::new(HashMap::new())),
195 websockets_disabled: websockets_disabled_from_env(),
196 websockets_unhealthy: Arc::new(AtomicBool::new(false)),
197 }
198 }
199
200 #[must_use]
202 pub fn gpt53_codex(api_key: String) -> Self {
203 Self::new(api_key, MODEL_GPT53_CODEX.to_owned())
204 }
205
206 #[must_use]
208 pub fn codex(api_key: String) -> Self {
209 Self::gpt53_codex(api_key)
210 }
211
212 #[must_use]
214 pub fn gpt54(api_key: String) -> Self {
215 Self::new(api_key, MODEL_GPT54.to_owned())
216 }
217
218 #[must_use]
220 pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
221 self.thinking = Some(thinking);
222 self
223 }
224
225 #[must_use]
227 pub fn with_account_id(mut self, account_id: impl Into<String>) -> Self {
228 self.account_id = Some(account_id.into());
229 self
230 }
231
232 #[must_use]
234 pub fn with_reasoning_effort(self, effort: ReasoningEffort) -> Self {
235 self.with_thinking(ThinkingConfig::default().with_effort(map_reasoning_effort(effort)))
236 }
237
238 #[must_use]
248 pub const fn with_websockets_disabled(mut self, disabled: bool) -> Self {
249 self.websockets_disabled = disabled;
250 self
251 }
252
253 fn skip_websocket(&self) -> bool {
257 self.websockets_disabled || self.websockets_unhealthy.load(Ordering::Relaxed)
258 }
259
260 const fn max_output_tokens(_request: &ChatRequest) -> Option<u32> {
272 None
273 }
274
275 fn build_headers(
276 &self,
277 streaming: bool,
278 session_id: Option<&str>,
279 turn_state: Option<&str>,
280 ) -> Result<reqwest::header::HeaderMap> {
281 self.build_headers_with_beta(
282 streaming,
283 session_id,
284 OPENAI_CODEX_RESPONSES_BETA_HEADER,
285 turn_state,
286 )
287 }
288
289 fn build_websocket_headers(
290 &self,
291 session_id: Option<&str>,
292 turn_state: Option<&str>,
293 ) -> Result<reqwest::header::HeaderMap> {
294 self.build_headers_with_beta(
295 false,
296 session_id,
297 OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER,
298 turn_state,
299 )
300 }
301
302 fn build_headers_with_beta(
303 &self,
304 streaming: bool,
305 session_id: Option<&str>,
306 beta_header: &'static str,
307 turn_state: Option<&str>,
308 ) -> Result<reqwest::header::HeaderMap> {
309 use reqwest::header::{
310 ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT,
311 };
312
313 let account_id = self
314 .account_id
315 .clone()
316 .map_or_else(|| extract_account_id(&self.api_key), Ok)
317 .context("failed to extract chatgpt account id from OpenAI Codex OAuth token")?;
318
319 let mut headers = HeaderMap::new();
320 headers.insert(
321 AUTHORIZATION,
322 HeaderValue::from_str(&format!("Bearer {}", self.api_key))?,
323 );
324 headers.insert("chatgpt-account-id", HeaderValue::from_str(&account_id)?);
325 headers.insert("OpenAI-Beta", HeaderValue::from_static(beta_header));
326 headers.insert(
327 "originator",
328 HeaderValue::from_static(OPENAI_CODEX_ORIGINATOR),
329 );
330 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
331 headers.insert(
332 USER_AGENT,
333 HeaderValue::from_str(&format!(
334 "{OPENAI_CODEX_ORIGINATOR}/{} ({} {})",
335 env!("CARGO_PKG_VERSION"),
336 std::env::consts::OS,
337 std::env::consts::ARCH,
338 ))?,
339 );
340 if streaming {
341 headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
342 }
343 if let Some(session_id) = session_id {
344 let session_id_header = HeaderValue::from_str(session_id)?;
345 headers.insert("session_id", session_id_header.clone());
346 headers.insert("x-client-request-id", session_id_header);
347 }
348 if let Some(turn_state) = turn_state {
349 headers.insert(
350 OPENAI_CODEX_TURN_STATE_HEADER,
351 HeaderValue::from_str(turn_state)?,
352 );
353 }
354
355 Ok(headers)
356 }
357
358 async fn websocket_session(&self, session_id: &str) -> Arc<Mutex<WebsocketSessionState>> {
359 let mut sessions = self.websocket_sessions.lock().await;
360 if !sessions.contains_key(session_id) && sessions.len() >= MAX_WEBSOCKET_SESSIONS {
361 evict_idle_sessions(&mut sessions);
362 }
363 sessions
364 .entry(session_id.to_string())
365 .or_insert_with(|| Arc::new(Mutex::new(WebsocketSessionState::default())))
366 .clone()
367 }
368
369 async fn connect_websocket(
370 &self,
371 session_id: Option<&str>,
372 turn_state: Option<&str>,
373 ) -> Result<(CodexWebSocket, Option<String>)> {
374 let headers = self.build_websocket_headers(session_id, turn_state)?;
375 let url = codex_websocket_url(&self.base_url)
376 .context("failed to build OpenAI Codex websocket URL")?;
377 let mut request = url
378 .as_str()
379 .into_client_request()
380 .context("failed to build OpenAI Codex websocket request")?;
381 request.headers_mut().extend(headers);
382
383 let (stream, response) = timeout(CONNECT_TIMEOUT, connect_async(request))
384 .await
385 .context("OpenAI Codex websocket connect timed out")?
386 .context("failed to connect OpenAI Codex websocket")?;
387 let turn_state = response
388 .headers()
389 .get(OPENAI_CODEX_TURN_STATE_HEADER)
390 .and_then(|value| value.to_str().ok())
391 .map(ToOwned::to_owned);
392 Ok((stream, turn_state))
393 }
394
395 fn map_response(api_response: ApiResponse) -> ChatResponse {
396 let refused = output_contains_refusal(&api_response.output);
397 let mut content = build_content_blocks(&api_response.output);
398 let has_tool_calls = content
399 .iter()
400 .any(|block| matches!(block, ContentBlock::ToolUse { .. }));
401 let stop_reason = if matches!(api_response.status, Some(ApiStatus::Incomplete)) {
402 Some(
403 api_response
404 .incomplete_details
405 .as_ref()
406 .and_then(|details| details.reason.as_deref())
407 .map_or(StopReason::Unknown, incomplete_stop_reason),
408 )
409 } else if refused {
410 Some(StopReason::Refusal)
411 } else if has_tool_calls {
412 Some(StopReason::ToolUse)
413 } else {
414 api_response.status.map(|status| match status {
415 ApiStatus::Completed => StopReason::EndTurn,
416 ApiStatus::Incomplete
421 | ApiStatus::Failed
422 | ApiStatus::InProgress
423 | ApiStatus::Queued
424 | ApiStatus::Cancelled
425 | ApiStatus::Other => StopReason::Unknown,
426 })
427 };
428
429 if stop_reason != Some(StopReason::ToolUse) {
430 content.retain(|block| !matches!(block, ContentBlock::ToolUse { .. }));
431 }
432
433 ChatResponse {
434 id: api_response.id,
435 content,
436 model: api_response.model,
437 stop_reason,
438 usage: api_response.usage.map_or(
439 Usage {
440 input_tokens: 0,
441 output_tokens: 0,
442 cached_input_tokens: 0,
443 cache_creation_input_tokens: 0,
444 },
445 |usage| usage_from_api_usage(&usage),
446 ),
447 }
448 }
449}
450
451#[async_trait]
452impl LlmProvider for OpenAICodexResponsesProvider {
453 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
454 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
455 Ok(thinking) => thinking,
456 Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
457 };
458 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
459 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
460 }
461 let reasoning = build_api_reasoning(thinking_config.as_ref());
462 let input = build_api_input(&request);
463 let max_output_tokens = Self::max_output_tokens(&request);
464 let prompt_cache_key = request.session_id.as_deref();
465 let tools: Option<Vec<ApiTool>> = request
466 .tools
467 .as_ref()
468 .map(|ts| ts.iter().cloned().map(convert_tool).collect());
469 let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
470 let text_format = request
471 .response_format
472 .as_ref()
473 .map(ApiResponseTextFormat::from);
474 let tool_choice = codex_tool_choice(request.tool_choice.as_ref());
475
476 let api_request = ApiResponsesRequest {
477 model: &self.model,
478 instructions: request.system.as_str(),
479 input: &input,
480 tools: tools.as_deref(),
481 max_output_tokens,
482 reasoning,
483 tool_choice: Some(tool_choice),
484 parallel_tool_calls: parallel_tool_calls.then_some(true),
485 store: false,
486 text: Some(ApiTextSettings {
487 verbosity: "medium",
488 format: text_format,
489 }),
490 include: Some(&["reasoning.encrypted_content"]),
491 prompt_cache_key,
492 };
493
494 log::debug!(
495 "OpenAI Codex request model={} max_tokens={}",
496 self.model,
497 request.max_tokens
498 );
499
500 let response = self
501 .client
502 .post(codex_url(&self.base_url))
503 .headers(self.build_headers(false, request.session_id.as_deref(), None)?)
504 .json(&api_request)
505 .send()
506 .await
507 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
508
509 let status = response.status();
510 let retry_after = if status == StatusCode::TOO_MANY_REQUESTS {
512 crate::http::retry_after_from_headers(response.headers())
513 } else {
514 None
515 };
516 let bytes = response
517 .bytes()
518 .await
519 .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
520
521 log::debug!(
522 "OpenAI Codex response status={} body_len={}",
523 status,
524 bytes.len()
525 );
526
527 if status == StatusCode::TOO_MANY_REQUESTS {
528 return Ok(ChatOutcome::RateLimited(retry_after));
529 }
530
531 if status.is_server_error() {
532 let body = String::from_utf8_lossy(&bytes);
533 log::error!("OpenAI Codex server error status={status} body={body}");
534 return Ok(ChatOutcome::ServerError(body.into_owned()));
535 }
536
537 if status.is_client_error() {
538 let body = String::from_utf8_lossy(&bytes);
539 log::warn!("OpenAI Codex client error status={status} body={body}");
540 return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
541 }
542
543 let api_response: ApiResponse = serde_json::from_slice(&bytes)
544 .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
545
546 if matches!(api_response.status, Some(ApiStatus::Failed)) {
551 let message = api_response
552 .error
553 .and_then(|error| error.message)
554 .unwrap_or_else(|| "OpenAI Codex reported status=failed".to_owned());
555 log::error!("OpenAI Codex generation failed: {message}");
556 return Ok(ChatOutcome::ServerError(message));
557 }
558
559 Ok(ChatOutcome::Success(Self::map_response(api_response)))
560 }
561
562 #[allow(clippy::too_many_lines)]
563 fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
564 Box::pin(async_stream::stream! {
565 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
566 Ok(thinking) => thinking,
567 Err(error) => {
568 yield Ok(StreamDelta::Error {
569 message: error.to_string(),
570 kind: StreamErrorKind::InvalidRequest,
571 });
572 return;
573 }
574 };
575 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
576 yield Ok(StreamDelta::Error {
577 message: error.to_string(),
578 kind: StreamErrorKind::InvalidRequest,
579 });
580 return;
581 }
582
583 let reasoning = build_api_reasoning(thinking_config.as_ref());
584 let input = build_api_input(&request);
585 let max_output_tokens = Self::max_output_tokens(&request);
586 let tools: Option<Vec<ApiTool>> = request
587 .tools
588 .as_ref()
589 .map(|ts| ts.iter().cloned().map(convert_tool).collect());
590 let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
591 let text_format = request.response_format.as_ref().map(ApiResponseTextFormat::from);
592 let tool_choice = codex_tool_choice(request.tool_choice.as_ref());
593 let api_request = ApiStreamingRequest {
594 model: self.model.clone(),
595 instructions: request.system.clone(),
596 input,
597 tools,
598 max_output_tokens,
599 reasoning,
600 tool_choice: Some(tool_choice),
601 parallel_tool_calls: parallel_tool_calls.then_some(true),
602 store: false,
603 text: Some(ApiTextSettings { verbosity: "medium", format: text_format }),
604 include: Some(vec!["reasoning.encrypted_content".to_string()]),
605 prompt_cache_key: request.session_id.clone(),
606 stream: true,
607 };
608
609 log::debug!("OpenAI Codex streaming request model={} max_tokens={}", self.model, request.max_tokens);
610
611 let mut sse_turn_state: Option<String> = None;
612
613 if let Some(session_id) = request.session_id.as_deref().filter(|_| !self.skip_websocket()) {
619 let session = self.websocket_session(session_id).await;
620 let mut websocket_session = session.lock().await;
621
622 let mark_websocket_transport_unhealthy = || {
629 self.websockets_unhealthy.store(true, Ordering::Relaxed);
630 };
631
632 if websocket_session.in_flight {
638 log::warn!(
639 "OpenAI Codex session {session_id} had an abandoned in-flight turn; resetting websocket state"
640 );
641 reset_websocket_connection(&mut websocket_session);
642 websocket_session.last_request = None;
643 websocket_session.last_response_id = None;
644 websocket_session.last_response_items.clear();
645 }
646 websocket_session.in_flight = true;
647 websocket_session.last_used = Some(Instant::now());
648
649 if !websocket_session.websocket_disabled {
650 'websocket_attempts: for attempt in 0..2 {
651 if websocket_session.connection.is_none() {
652 match self
653 .connect_websocket(
654 Some(session_id),
655 websocket_session.turn_state.as_deref(),
656 )
657 .await
658 {
659 Ok((connection, turn_state)) => {
660 websocket_session.connection = Some(connection);
661 if let Some(turn_state) = turn_state {
662 websocket_session.turn_state = Some(turn_state);
663 }
664 websocket_session.prewarmed = false;
665 }
666 Err(error) => {
667 log::warn!(
668 "OpenAI Codex websocket connect failed on attempt {}: {error:#}",
669 attempt + 1,
670 );
671 if attempt == 1 {
672 websocket_session.websocket_disabled = true;
673 mark_websocket_transport_unhealthy();
674 }
675 continue;
676 }
677 }
678 }
679
680 if websocket_session.connection.is_some()
681 && websocket_session.last_request.is_none()
682 && !websocket_session.prewarmed
683 {
684 let mut warmup_request = ApiWebsocketRequest::from(&api_request);
685 warmup_request.generate = Some(false);
686 let warmup_payload = match serde_json::to_string(&warmup_request) {
687 Ok(payload) => payload,
688 Err(error) => {
689 yield Ok(StreamDelta::Error {
690 message: format!(
691 "failed to encode websocket warmup request: {error}"
692 ),
693 kind: StreamErrorKind::InvalidRequest,
694 });
695 return;
696 }
697 };
698
699 let warmup_send_result = if let Some(connection) =
700 websocket_session.connection.as_mut()
701 {
702 timeout(
703 WEBSOCKET_IO_TIMEOUT,
704 connection.send(WebSocketMessage::Text(warmup_payload.into())),
705 )
706 .await
707 .unwrap_or(Err(
708 tokio_tungstenite::tungstenite::Error::ConnectionClosed,
709 ))
710 } else {
711 Err(tokio_tungstenite::tungstenite::Error::ConnectionClosed)
712 };
713
714 if let Err(error) = warmup_send_result {
715 log::warn!(
716 "OpenAI Codex websocket warmup send failed on attempt {}: {error}",
717 attempt + 1,
718 );
719 reset_websocket_connection(&mut websocket_session);
720 if attempt == 1 {
721 websocket_session.websocket_disabled = true;
722 mark_websocket_transport_unhealthy();
723 }
724 continue;
725 }
726
727 let mut warmup_response_id: Option<String> = None;
728 let mut warmup_response_items = Vec::new();
729
730 loop {
731 let message_result = if let Some(connection) =
732 websocket_session.connection.as_mut()
733 {
734 timeout(WEBSOCKET_IO_TIMEOUT, connection.next()).await.unwrap_or_else(|_| {
735 log::warn!("OpenAI Codex websocket warmup read timed out");
736 None
737 })
738 } else {
739 None
740 };
741 let Some(message_result) = message_result else {
742 log::warn!(
743 "OpenAI Codex websocket warmup closed before completion on attempt {}",
744 attempt + 1,
745 );
746 reset_websocket_connection(&mut websocket_session);
747 if attempt == 1 {
748 websocket_session.websocket_disabled = true;
749 mark_websocket_transport_unhealthy();
750 }
751 continue 'websocket_attempts;
752 };
753
754 let message = match message_result {
755 Ok(message) => message,
756 Err(error) => {
757 log::warn!(
758 "OpenAI Codex websocket warmup failed on attempt {}: {error}",
759 attempt + 1,
760 );
761 reset_websocket_connection(&mut websocket_session);
762 if attempt == 1 {
763 websocket_session.websocket_disabled = true;
764 mark_websocket_transport_unhealthy();
765 }
766 continue 'websocket_attempts;
767 }
768 };
769
770 match message {
771 WebSocketMessage::Text(text) => {
772 if let Some((status, message)) =
773 parse_wrapped_websocket_error_event(&text)
774 {
775 log::warn!(
776 "OpenAI Codex websocket warmup wrapped error on attempt {} status={} message={message}",
777 attempt + 1,
778 status,
779 );
780 if status == StatusCode::UNAUTHORIZED
781 || status == StatusCode::UPGRADE_REQUIRED
782 || status.is_client_error()
783 {
784 websocket_session.websocket_disabled = true;
785 }
786 reset_websocket_connection(&mut websocket_session);
787 continue 'websocket_attempts;
788 }
789 let event = match decode_stream_event(&text) {
790 Ok(event) => event,
791 Err(error) => {
792 reset_websocket_connection(
793 &mut websocket_session,
794 );
795 yield Ok(StreamDelta::Error {
796 message: error.to_string(),
797 kind: StreamErrorKind::ServerError,
798 });
799 return;
800 }
801 };
802 match event.r#type.as_str() {
803 "response.output_item.done" => {
804 let item = match decode_output_item(event.item) {
805 Ok(item) => item,
806 Err(error) => {
807 reset_websocket_connection(
808 &mut websocket_session,
809 );
810 yield Ok(StreamDelta::Error {
811 message: error.to_string(),
812 kind: StreamErrorKind::ServerError,
813 });
814 return;
815 }
816 };
817 if let Some(item) = output_item_to_input_item(item) {
818 warmup_response_items.push(item);
819 }
820 }
821 "response.completed" | "response.done" => {
822 if let Some(resp) = event.response
823 && let Some(id) = resp.id
824 {
825 warmup_response_id = Some(id);
826 }
827 websocket_session.last_request =
828 Some(api_request.clone());
829 websocket_session.last_response_id =
830 warmup_response_id;
831 websocket_session.last_response_items =
832 warmup_response_items;
833 websocket_session.prewarmed = true;
834 break;
835 }
836 "response.incomplete" | "response.failed" => {
837 log::warn!(
838 "OpenAI Codex websocket warmup returned {} on attempt {}",
839 event.r#type,
840 attempt + 1,
841 );
842 reset_websocket_connection(&mut websocket_session);
843 if attempt == 1 {
844 websocket_session.websocket_disabled = true;
845 mark_websocket_transport_unhealthy();
846 }
847 continue 'websocket_attempts;
848 }
849 _ => {}
850 }
851 }
852 WebSocketMessage::Binary(bytes) => {
853 let text = match String::from_utf8(bytes.to_vec()) {
854 Ok(text) => text,
855 Err(error) => {
856 reset_websocket_connection(
857 &mut websocket_session,
858 );
859 yield Ok(StreamDelta::Error {
860 message: format!(
861 "invalid OpenAI Codex websocket UTF-8: {error}"
862 ),
863 kind: StreamErrorKind::ServerError,
864 });
865 return;
866 }
867 };
868 if let Some((status, message)) =
869 parse_wrapped_websocket_error_event(&text)
870 {
871 log::warn!(
872 "OpenAI Codex websocket warmup wrapped error on attempt {} status={} message={message}",
873 attempt + 1,
874 status,
875 );
876 if status == StatusCode::UNAUTHORIZED
877 || status == StatusCode::UPGRADE_REQUIRED
878 || status.is_client_error()
879 {
880 websocket_session.websocket_disabled = true;
881 }
882 reset_websocket_connection(&mut websocket_session);
883 continue 'websocket_attempts;
884 }
885
886 let event = match decode_stream_event(&text) {
887 Ok(event) => event,
888 Err(error) => {
889 reset_websocket_connection(
890 &mut websocket_session,
891 );
892 yield Ok(StreamDelta::Error {
893 message: error.to_string(),
894 kind: StreamErrorKind::ServerError,
895 });
896 return;
897 }
898 };
899 match event.r#type.as_str() {
900 "response.output_item.done" => {
901 let item = match decode_output_item(event.item) {
902 Ok(item) => item,
903 Err(error) => {
904 reset_websocket_connection(
905 &mut websocket_session,
906 );
907 yield Ok(StreamDelta::Error {
908 message: error.to_string(),
909 kind: StreamErrorKind::ServerError,
910 });
911 return;
912 }
913 };
914 if let Some(item) = output_item_to_input_item(item) {
915 warmup_response_items.push(item);
916 }
917 }
918 "response.completed" | "response.done" => {
919 if let Some(resp) = event.response
920 && let Some(id) = resp.id
921 {
922 warmup_response_id = Some(id);
923 }
924 websocket_session.last_request =
925 Some(api_request.clone());
926 websocket_session.last_response_id =
927 warmup_response_id;
928 websocket_session.last_response_items =
929 warmup_response_items;
930 websocket_session.prewarmed = true;
931 break;
932 }
933 "response.incomplete" | "response.failed" => {
934 log::warn!(
935 "OpenAI Codex websocket warmup returned {} on attempt {}",
936 event.r#type,
937 attempt + 1,
938 );
939 reset_websocket_connection(&mut websocket_session);
940 if attempt == 1 {
941 websocket_session.websocket_disabled = true;
942 mark_websocket_transport_unhealthy();
943 }
944 continue 'websocket_attempts;
945 }
946 _ => {}
947 }
948 }
949 WebSocketMessage::Ping(payload) => {
950 if let Some(connection) =
951 websocket_session.connection.as_mut()
952 && let Err(error) = connection
953 .send(WebSocketMessage::Pong(payload))
954 .await
955 {
956 log::warn!(
957 "OpenAI Codex websocket warmup pong failed on attempt {}: {error}",
958 attempt + 1,
959 );
960 reset_websocket_connection(&mut websocket_session);
961 if attempt == 1 {
962 websocket_session.websocket_disabled = true;
963 mark_websocket_transport_unhealthy();
964 }
965 continue 'websocket_attempts;
966 }
967 }
968 WebSocketMessage::Pong(_) | WebSocketMessage::Frame(_) => {}
969 WebSocketMessage::Close(_) => {
970 log::warn!(
971 "OpenAI Codex websocket warmup closed on attempt {}",
972 attempt + 1,
973 );
974 reset_websocket_connection(&mut websocket_session);
975 if attempt == 1 {
976 websocket_session.websocket_disabled = true;
977 mark_websocket_transport_unhealthy();
978 }
979 continue 'websocket_attempts;
980 }
981 }
982 }
983 }
984
985 let websocket_request = prepare_websocket_request(
986 &api_request,
987 &websocket_session,
988 websocket_session.prewarmed,
989 );
990 let request_payload = match serde_json::to_string(&websocket_request) {
991 Ok(payload) => payload,
992 Err(error) => {
993 yield Ok(StreamDelta::Error {
994 message: format!(
995 "failed to encode websocket request: {error}"
996 ),
997 kind: StreamErrorKind::InvalidRequest,
998 });
999 return;
1000 }
1001 };
1002
1003 let send_result = if let Some(connection) = websocket_session.connection.as_mut() {
1004 timeout(
1005 WEBSOCKET_IO_TIMEOUT,
1006 connection.send(WebSocketMessage::Text(request_payload.into())),
1007 )
1008 .await
1009 .unwrap_or(Err(
1010 tokio_tungstenite::tungstenite::Error::ConnectionClosed,
1011 ))
1012 } else {
1013 Err(tokio_tungstenite::tungstenite::Error::ConnectionClosed)
1014 };
1015
1016 if let Err(error) = send_result {
1017 log::warn!(
1018 "OpenAI Codex websocket send failed on attempt {}: {error}",
1019 attempt + 1,
1020 );
1021 reset_websocket_connection(&mut websocket_session);
1022 if attempt == 1 {
1023 websocket_session.websocket_disabled = true;
1024 mark_websocket_transport_unhealthy();
1025 }
1026 continue;
1027 }
1028
1029 let mut usage: Option<Usage> = None;
1030 let mut tool_calls: HashMap<String, ToolCallAccumulator> = HashMap::new();
1031 let mut response_id: Option<String> = None;
1032 let mut response_items = Vec::new();
1033 let mut streamed_reasoning_summaries = HashSet::new();
1034 let mut emitted_output = false;
1035 let mut refused = false;
1036
1037 loop {
1038 let message_result = if let Some(connection) =
1039 websocket_session.connection.as_mut()
1040 {
1041 timeout(WEBSOCKET_IO_TIMEOUT, connection.next()).await.unwrap_or_else(|_| {
1042 log::warn!("OpenAI Codex websocket read timed out");
1043 None
1044 })
1045 } else {
1046 None
1047 };
1048 let Some(message_result) = message_result else {
1049 if emitted_output {
1050 reset_websocket_connection(&mut websocket_session);
1051 yield Ok(StreamDelta::Error {
1052 message: "websocket closed before response.completed"
1053 .to_string(),
1054 kind: StreamErrorKind::ServerError,
1055 });
1056 return;
1057 }
1058 reset_websocket_connection(&mut websocket_session);
1059 if attempt == 1 {
1060 websocket_session.websocket_disabled = true;
1061 mark_websocket_transport_unhealthy();
1062 }
1063 continue 'websocket_attempts;
1064 };
1065
1066 let message = match message_result {
1067 Ok(message) => message,
1068 Err(error) => {
1069 if emitted_output {
1070 reset_websocket_connection(&mut websocket_session);
1071 yield Ok(StreamDelta::Error {
1072 message: format!("websocket error: {error}"),
1073 kind: StreamErrorKind::ServerError,
1074 });
1075 return;
1076 }
1077 reset_websocket_connection(&mut websocket_session);
1078 if attempt == 1 {
1079 websocket_session.websocket_disabled = true;
1080 mark_websocket_transport_unhealthy();
1081 }
1082 continue 'websocket_attempts;
1083 }
1084 };
1085
1086 match message {
1087 WebSocketMessage::Text(text) => {
1088 if let Some((status, message)) =
1089 parse_wrapped_websocket_error_event(&text)
1090 {
1091 let kind = if status == StatusCode::TOO_MANY_REQUESTS {
1092 StreamErrorKind::RateLimited
1093 } else if status.is_server_error() {
1094 StreamErrorKind::ServerError
1095 } else {
1096 StreamErrorKind::InvalidRequest
1097 };
1098 if emitted_output {
1099 reset_websocket_connection(&mut websocket_session);
1100 yield Ok(StreamDelta::Error {
1101 message,
1102 kind,
1103 });
1104 return;
1105 }
1106 if status == StatusCode::UNAUTHORIZED
1107 || status == StatusCode::UPGRADE_REQUIRED
1108 || status.is_client_error()
1109 {
1110 websocket_session.websocket_disabled = true;
1111 }
1112 reset_websocket_connection(&mut websocket_session);
1113 continue 'websocket_attempts;
1114 }
1115 let event = match decode_stream_event(&text) {
1116 Ok(event) => event,
1117 Err(error) => {
1118 reset_websocket_connection(&mut websocket_session);
1119 yield Ok(StreamDelta::Error {
1120 message: error.to_string(),
1121 kind: StreamErrorKind::ServerError,
1122 });
1123 return;
1124 }
1125 };
1126 match event.r#type.as_str() {
1127 "response.output_text.delta" => {
1128 if let Some(delta) = event.delta {
1129 emitted_output = true;
1130 yield Ok(StreamDelta::TextDelta {
1131 delta,
1132 block_index: output_block_index(event.output_index),
1133 });
1134 }
1135 }
1136 "response.refusal.delta" => {
1137 refused = true;
1138 if let Some(delta) = event.delta {
1139 emitted_output = true;
1140 yield Ok(StreamDelta::TextDelta {
1141 delta,
1142 block_index: output_block_index(
1143 event.output_index,
1144 ),
1145 });
1146 }
1147 }
1148 "response.reasoning_summary_text.delta" => {
1149 if let Some(delta) = event.delta {
1150 let output_index = event.output_index.unwrap_or(0);
1151 streamed_reasoning_summaries.insert(output_index);
1152 emitted_output = true;
1153 yield Ok(StreamDelta::ThinkingDelta {
1154 delta,
1155 block_index: reasoning_summary_block_index(
1156 Some(output_index),
1157 ),
1158 });
1159 }
1160 }
1161 "response.function_call_arguments.delta" => {
1162 let block_index = event
1163 .output_index
1164 .map(|index| index.saturating_mul(2));
1165 if let (Some(call_id), Some(delta)) =
1166 (event.call_id, event.delta)
1167 {
1168 emitted_output = true;
1169 let order = tool_calls.len();
1170 let acc = tool_calls
1171 .entry(call_id.clone())
1172 .or_insert_with(|| ToolCallAccumulator {
1173 id: call_id,
1174 name: event.name.unwrap_or_default(),
1175 arguments: String::new(),
1176 order,
1177 block_index,
1178 });
1179 acc.arguments.push_str(&delta);
1180 }
1181 }
1182 "response.output_item.done" => {
1183 let item = match decode_output_item(event.item) {
1184 Ok(item) => item,
1185 Err(error) => {
1186 reset_websocket_connection(
1187 &mut websocket_session,
1188 );
1189 yield Ok(StreamDelta::Error {
1190 message: error.to_string(),
1191 kind: StreamErrorKind::ServerError,
1192 });
1193 return;
1194 }
1195 };
1196 let block_index = event.output_index.unwrap_or(0);
1197 accumulate_completed_tool_call(
1198 &item,
1199 block_index,
1200 &mut tool_calls,
1201 );
1202 let include_summary = !streamed_reasoning_summaries
1203 .contains(&block_index);
1204 for delta in output_item_stream_deltas(
1205 &item,
1206 block_index,
1207 include_summary,
1208 ) {
1209 emitted_output = true;
1210 yield Ok(delta);
1211 }
1212 if let Some(item) = output_item_to_input_item(item) {
1213 response_items.push(item);
1214 }
1215 }
1216 "response.completed"
1217 | "response.incomplete"
1218 | "response.done" => {
1219 let response_status = event
1220 .response
1221 .as_ref()
1222 .and_then(|response| response.status);
1223 let incomplete_reason = event
1224 .response
1225 .as_ref()
1226 .and_then(|response| {
1227 response.incomplete_details.as_ref()
1228 })
1229 .and_then(|details| details.reason.clone());
1230 if let Some(resp) = event.response {
1231 if let Some(u) = resp.usage {
1232 usage = Some(usage_from_api_usage(&u));
1233 }
1234 if let Some(id) = resp.id {
1235 response_id = Some(id);
1236 }
1237 }
1238 let final_status = match event.r#type.as_str() {
1239 "response.incomplete" => {
1240 Some(ApiStatus::Incomplete)
1241 }
1242 "response.done" => response_status
1243 .or(Some(ApiStatus::Completed)),
1244 _ => Some(ApiStatus::Completed),
1245 };
1246 let stop_reason = stop_reason_from_stream_state(
1247 &tool_calls,
1248 final_status,
1249 refused,
1250 incomplete_reason.as_deref(),
1251 );
1252 if stop_reason == Some(StopReason::ToolUse) {
1253 for delta in
1254 emit_accumulated_tool_calls(&tool_calls)
1255 {
1256 yield Ok(delta);
1257 }
1258 }
1259 if let Some(u) = usage.take() {
1260 yield Ok(StreamDelta::Usage(u));
1261 }
1262 websocket_session.last_request = Some(api_request.clone());
1263 websocket_session.last_response_id = response_id;
1264 websocket_session.last_response_items = response_items;
1265 websocket_session.prewarmed = false;
1266 websocket_session.in_flight = false;
1270 yield Ok(StreamDelta::Done {
1271 stop_reason,
1272 });
1273 return;
1274 }
1275 "response.failed" => {
1276 websocket_session.last_request = None;
1277 websocket_session.last_response_id = None;
1278 websocket_session.last_response_items.clear();
1279 websocket_session.prewarmed = false;
1280 let message = event
1281 .response
1282 .and_then(|resp| resp.error)
1283 .and_then(|error| error.message)
1284 .unwrap_or_else(|| {
1285 "Codex response failed".to_string()
1286 });
1287 yield Ok(StreamDelta::Error {
1288 message,
1289 kind: StreamErrorKind::ServerError,
1290 });
1291 return;
1292 }
1293 _ => {}
1294 }
1295 }
1296 WebSocketMessage::Binary(bytes) => {
1297 let text = match String::from_utf8(bytes.to_vec()) {
1298 Ok(text) => text,
1299 Err(error) => {
1300 reset_websocket_connection(&mut websocket_session);
1301 yield Ok(StreamDelta::Error {
1302 message: format!(
1303 "invalid OpenAI Codex websocket UTF-8: {error}"
1304 ),
1305 kind: StreamErrorKind::ServerError,
1306 });
1307 return;
1308 }
1309 };
1310 if let Some((status, message)) =
1311 parse_wrapped_websocket_error_event(&text)
1312 {
1313 let kind = if status == StatusCode::TOO_MANY_REQUESTS {
1314 StreamErrorKind::RateLimited
1315 } else if status.is_server_error() {
1316 StreamErrorKind::ServerError
1317 } else {
1318 StreamErrorKind::InvalidRequest
1319 };
1320 if emitted_output {
1321 reset_websocket_connection(&mut websocket_session);
1322 yield Ok(StreamDelta::Error {
1323 message,
1324 kind,
1325 });
1326 return;
1327 }
1328 if status == StatusCode::UNAUTHORIZED
1329 || status == StatusCode::UPGRADE_REQUIRED
1330 || status.is_client_error()
1331 {
1332 websocket_session.websocket_disabled = true;
1333 }
1334 reset_websocket_connection(&mut websocket_session);
1335 continue 'websocket_attempts;
1336 }
1337
1338 let event = match decode_stream_event(&text) {
1339 Ok(event) => event,
1340 Err(error) => {
1341 reset_websocket_connection(
1342 &mut websocket_session,
1343 );
1344 yield Ok(StreamDelta::Error {
1345 message: error.to_string(),
1346 kind: StreamErrorKind::ServerError,
1347 });
1348 return;
1349 }
1350 };
1351 match event.r#type.as_str() {
1352 "response.output_text.delta" => {
1353 if let Some(delta) = event.delta {
1354 emitted_output = true;
1355 yield Ok(StreamDelta::TextDelta {
1356 delta,
1357 block_index: output_block_index(event.output_index),
1358 });
1359 }
1360 }
1361 "response.refusal.delta" => {
1362 refused = true;
1363 if let Some(delta) = event.delta {
1364 emitted_output = true;
1365 yield Ok(StreamDelta::TextDelta {
1366 delta,
1367 block_index: output_block_index(
1368 event.output_index,
1369 ),
1370 });
1371 }
1372 }
1373 "response.reasoning_summary_text.delta" => {
1374 if let Some(delta) = event.delta {
1375 let output_index =
1376 event.output_index.unwrap_or(0);
1377 streamed_reasoning_summaries
1378 .insert(output_index);
1379 emitted_output = true;
1380 yield Ok(StreamDelta::ThinkingDelta {
1381 delta,
1382 block_index:
1383 reasoning_summary_block_index(Some(
1384 output_index,
1385 )),
1386 });
1387 }
1388 }
1389 "response.function_call_arguments.delta" => {
1390 let block_index = event
1391 .output_index
1392 .map(|index| index.saturating_mul(2));
1393 if let (Some(call_id), Some(delta)) =
1394 (event.call_id, event.delta)
1395 {
1396 emitted_output = true;
1397 let order = tool_calls.len();
1398 let acc = tool_calls
1399 .entry(call_id.clone())
1400 .or_insert_with(|| ToolCallAccumulator {
1401 id: call_id,
1402 name: event.name.unwrap_or_default(),
1403 arguments: String::new(),
1404 order,
1405 block_index,
1406 });
1407 acc.arguments.push_str(&delta);
1408 }
1409 }
1410 "response.output_item.done" => {
1411 let item =
1412 match decode_output_item(event.item) {
1413 Ok(item) => item,
1414 Err(error) => {
1415 reset_websocket_connection(
1416 &mut websocket_session,
1417 );
1418 yield Ok(StreamDelta::Error {
1419 message: error.to_string(),
1420 kind: StreamErrorKind::ServerError,
1421 });
1422 return;
1423 }
1424 };
1425 let block_index =
1426 event.output_index.unwrap_or(0);
1427 accumulate_completed_tool_call(
1428 &item,
1429 block_index,
1430 &mut tool_calls,
1431 );
1432 let include_summary =
1433 !streamed_reasoning_summaries
1434 .contains(&block_index);
1435 for delta in output_item_stream_deltas(
1436 &item,
1437 block_index,
1438 include_summary,
1439 ) {
1440 emitted_output = true;
1441 yield Ok(delta);
1442 }
1443 if let Some(item) =
1444 output_item_to_input_item(item)
1445 {
1446 response_items.push(item);
1447 }
1448 }
1449 "response.completed"
1450 | "response.incomplete"
1451 | "response.done" => {
1452 let response_status = event
1453 .response
1454 .as_ref()
1455 .and_then(|response| response.status);
1456 let incomplete_reason = event
1457 .response
1458 .as_ref()
1459 .and_then(|response| {
1460 response.incomplete_details.as_ref()
1461 })
1462 .and_then(|details| details.reason.clone());
1463 if let Some(resp) = event.response {
1464 if let Some(u) = resp.usage {
1465 usage = Some(usage_from_api_usage(&u));
1466 }
1467 if let Some(id) = resp.id {
1468 response_id = Some(id);
1469 }
1470 }
1471 let final_status =
1472 match event.r#type.as_str() {
1473 "response.incomplete" => {
1474 Some(ApiStatus::Incomplete)
1475 }
1476 "response.done" => response_status
1477 .or(Some(ApiStatus::Completed)),
1478 _ => Some(ApiStatus::Completed),
1479 };
1480 let stop_reason =
1481 stop_reason_from_stream_state(
1482 &tool_calls,
1483 final_status,
1484 refused,
1485 incomplete_reason.as_deref(),
1486 );
1487 if stop_reason
1488 == Some(StopReason::ToolUse)
1489 {
1490 for delta in
1491 emit_accumulated_tool_calls(&tool_calls)
1492 {
1493 yield Ok(delta);
1494 }
1495 }
1496 if let Some(u) = usage.take() {
1497 yield Ok(StreamDelta::Usage(u));
1498 }
1499 websocket_session.last_request =
1500 Some(api_request.clone());
1501 websocket_session.last_response_id = response_id;
1502 websocket_session.last_response_items =
1503 response_items;
1504 websocket_session.prewarmed = false;
1505 websocket_session.in_flight = false;
1509 yield Ok(StreamDelta::Done {
1510 stop_reason,
1511 });
1512 return;
1513 }
1514 "response.failed" => {
1515 websocket_session.last_request = None;
1516 websocket_session.last_response_id = None;
1517 websocket_session.last_response_items.clear();
1518 websocket_session.prewarmed = false;
1519 let message = event
1520 .response
1521 .and_then(|resp| resp.error)
1522 .and_then(|error| error.message)
1523 .unwrap_or_else(|| {
1524 "Codex response failed".to_string()
1525 });
1526 yield Ok(StreamDelta::Error {
1527 message,
1528 kind: StreamErrorKind::ServerError,
1529 });
1530 return;
1531 }
1532 _ => {}
1533 }
1534 }
1535 WebSocketMessage::Ping(payload) => {
1536 if let Some(connection) = websocket_session.connection.as_mut()
1537 && let Err(error) = connection
1538 .send(WebSocketMessage::Pong(payload))
1539 .await
1540 {
1541 if emitted_output {
1542 reset_websocket_connection(&mut websocket_session);
1543 yield Ok(StreamDelta::Error {
1544 message: format!("websocket pong failed: {error}"),
1545 kind: StreamErrorKind::ServerError,
1546 });
1547 return;
1548 }
1549 reset_websocket_connection(&mut websocket_session);
1550 if attempt == 1 {
1551 websocket_session.websocket_disabled = true;
1552 mark_websocket_transport_unhealthy();
1553 }
1554 continue 'websocket_attempts;
1555 }
1556 }
1557 WebSocketMessage::Pong(_) | WebSocketMessage::Frame(_) => {}
1558 WebSocketMessage::Close(_) => {
1559 if emitted_output {
1560 reset_websocket_connection(&mut websocket_session);
1561 yield Ok(StreamDelta::Error {
1562 message: "websocket closed before response.completed"
1563 .to_string(),
1564 kind: StreamErrorKind::ServerError,
1565 });
1566 return;
1567 }
1568 reset_websocket_connection(&mut websocket_session);
1569 if attempt == 1 {
1570 websocket_session.websocket_disabled = true;
1571 mark_websocket_transport_unhealthy();
1572 }
1573 continue 'websocket_attempts;
1574 }
1575 }
1576 }
1577 }
1578 }
1579 websocket_session.in_flight = false;
1583 sse_turn_state = websocket_session.turn_state.clone();
1584 drop(websocket_session);
1585 }
1586
1587 let headers = match self.build_headers(
1588 true,
1589 request.session_id.as_deref(),
1590 sse_turn_state.as_deref(),
1591 ) {
1592 Ok(headers) => headers,
1593 Err(error) => {
1594 yield Ok(StreamDelta::Error {
1595 message: error.to_string(),
1596 kind: StreamErrorKind::InvalidRequest,
1597 });
1598 return;
1599 }
1600 };
1601
1602 let Ok(response) = self.client
1603 .post(codex_url(&self.base_url))
1604 .headers(headers)
1605 .json(&api_request)
1606 .send()
1607 .await
1608 else {
1609 yield Err(anyhow::anyhow!("request failed"));
1610 return;
1611 };
1612
1613 let status = response.status();
1614 if !status.is_success() {
1615 let body = response.text().await.unwrap_or_default();
1616 let kind = if status == StatusCode::TOO_MANY_REQUESTS {
1617 StreamErrorKind::RateLimited
1618 } else if status.is_server_error() {
1619 StreamErrorKind::ServerError
1620 } else {
1621 StreamErrorKind::InvalidRequest
1622 };
1623 log::warn!("OpenAI Codex error status={status} body={body}");
1624 yield Ok(StreamDelta::Error { message: body, kind });
1625 return;
1626 }
1627
1628 if let Some(session_id) = request.session_id.as_deref() {
1629 let turn_state = response
1630 .headers()
1631 .get(OPENAI_CODEX_TURN_STATE_HEADER)
1632 .and_then(|value| value.to_str().ok())
1633 .map(ToOwned::to_owned);
1634 if let Some(turn_state) = turn_state {
1635 let session = self.websocket_session(session_id).await;
1636 let mut websocket_session = session.lock().await;
1637 websocket_session.turn_state = Some(turn_state);
1638 }
1639 }
1640
1641 let mut sse = SseLineBuffer::new();
1642 let mut stream = response.bytes_stream();
1643 let mut usage: Option<Usage> = None;
1644 let mut tool_calls: HashMap<String, ToolCallAccumulator> = HashMap::new();
1645 let mut final_status: Option<ApiStatus> = None;
1646 let mut streamed_reasoning_summaries = HashSet::new();
1647 let mut refused = false;
1648 let mut incomplete_reason: Option<String> = None;
1649
1650 while let Some(chunk_result) = stream.next().await {
1651 let Ok(chunk) = chunk_result else {
1652 yield Err(anyhow::anyhow!("stream error"));
1653 return;
1654 };
1655 sse.extend(&chunk);
1656
1657 while let Some(line) = sse.next_line() {
1658 let line = line.trim();
1659 if line.is_empty() {
1660 continue;
1661 }
1662
1663 let Some(data) = line.strip_prefix("data: ") else {
1664 continue;
1665 };
1666
1667 if data == "[DONE]" {
1668 let Some(stop_reason) =
1669 stop_reason_from_stream_state(
1670 &tool_calls,
1671 final_status,
1672 refused,
1673 incomplete_reason.as_deref(),
1674 )
1675 else {
1676 yield Ok(StreamDelta::Error {
1677 message: "OpenAI Codex stream sent [DONE] before a terminal response event"
1678 .to_owned(),
1679 kind: StreamErrorKind::ServerError,
1680 });
1681 return;
1682 };
1683 if stop_reason == StopReason::ToolUse {
1684 for delta in emit_accumulated_tool_calls(&tool_calls) {
1685 yield Ok(delta);
1686 }
1687 }
1688 if let Some(u) = usage.take() {
1689 yield Ok(StreamDelta::Usage(u));
1690 }
1691 yield Ok(StreamDelta::Done {
1692 stop_reason: Some(stop_reason),
1693 });
1694 return;
1695 }
1696
1697 let event = match decode_stream_event(data) {
1698 Ok(event) => event,
1699 Err(error) => {
1700 yield Ok(StreamDelta::Error {
1701 message: format!(
1702 "invalid OpenAI Codex Responses stream event: {error}"
1703 ),
1704 kind: StreamErrorKind::ServerError,
1705 });
1706 return;
1707 }
1708 };
1709 match event.r#type.as_str() {
1710 "response.output_text.delta" => {
1711 if let Some(delta) = event.delta {
1712 yield Ok(StreamDelta::TextDelta {
1713 delta,
1714 block_index: output_block_index(event.output_index),
1715 });
1716 }
1717 }
1718 "response.refusal.delta" => {
1719 refused = true;
1720 if let Some(delta) = event.delta {
1721 yield Ok(StreamDelta::TextDelta {
1722 delta,
1723 block_index: output_block_index(event.output_index),
1724 });
1725 }
1726 }
1727 "response.reasoning_summary_text.delta" => {
1728 if let Some(delta) = event.delta {
1729 let output_index = event.output_index.unwrap_or(0);
1730 streamed_reasoning_summaries.insert(output_index);
1731 yield Ok(StreamDelta::ThinkingDelta {
1732 delta,
1733 block_index: reasoning_summary_block_index(Some(
1734 output_index,
1735 )),
1736 });
1737 }
1738 }
1739 "response.function_call_arguments.delta" => {
1740 let block_index = event
1741 .output_index
1742 .map(|index| index.saturating_mul(2));
1743 if let (Some(call_id), Some(delta)) = (event.call_id, event.delta) {
1744 let order = tool_calls.len();
1745 let acc = tool_calls.entry(call_id.clone()).or_insert_with(|| {
1746 ToolCallAccumulator {
1747 id: call_id,
1748 name: event.name.unwrap_or_default(),
1749 arguments: String::new(),
1750 order,
1751 block_index,
1752 }
1753 });
1754 acc.arguments.push_str(&delta);
1755 }
1756 }
1757 "response.output_item.done" => {
1758 let item = match decode_output_item(event.item) {
1759 Ok(item) => item,
1760 Err(error) => {
1761 yield Ok(StreamDelta::Error {
1762 message: error.to_string(),
1763 kind: StreamErrorKind::ServerError,
1764 });
1765 return;
1766 }
1767 };
1768 let block_index = event.output_index.unwrap_or(0);
1769 accumulate_completed_tool_call(
1770 &item,
1771 block_index,
1772 &mut tool_calls,
1773 );
1774 let include_summary =
1775 !streamed_reasoning_summaries.contains(&block_index);
1776 for delta in output_item_stream_deltas(
1777 &item,
1778 block_index,
1779 include_summary,
1780 ) {
1781 yield Ok(delta);
1782 }
1783 }
1784 "response.completed" | "response.incomplete" | "response.done" => {
1785 let response_status = event
1786 .response
1787 .as_ref()
1788 .and_then(|response| response.status);
1789 incomplete_reason = event
1790 .response
1791 .as_ref()
1792 .and_then(|response| response.incomplete_details.as_ref())
1793 .and_then(|details| details.reason.clone());
1794 if let Some(resp) = event.response
1795 && let Some(u) = resp.usage
1796 {
1797 usage = Some(usage_from_api_usage(&u));
1798 }
1799 final_status = match event.r#type.as_str() {
1800 "response.incomplete" => Some(ApiStatus::Incomplete),
1801 "response.done" => {
1802 response_status.or(Some(ApiStatus::Completed))
1803 }
1804 _ => Some(ApiStatus::Completed),
1805 };
1806 }
1807 "response.failed" => {
1808 let message = event
1809 .response
1810 .and_then(|resp| resp.error)
1811 .and_then(|error| error.message)
1812 .unwrap_or_else(|| "Codex response failed".to_string());
1813 yield Ok(StreamDelta::Error {
1814 message,
1815 kind: StreamErrorKind::ServerError,
1816 });
1817 return;
1818 }
1819 _ => {}
1820 }
1821 }
1822 }
1823
1824 let Some(stop_reason) = stop_reason_from_stream_state(
1825 &tool_calls,
1826 final_status,
1827 refused,
1828 incomplete_reason.as_deref(),
1829 ) else {
1830 yield Ok(StreamDelta::Error {
1831 message: "OpenAI Codex stream ended before a terminal response event".to_owned(),
1832 kind: StreamErrorKind::ServerError,
1833 });
1834 return;
1835 };
1836 if stop_reason == StopReason::ToolUse {
1837 for delta in emit_accumulated_tool_calls(&tool_calls) {
1838 yield Ok(delta);
1839 }
1840 }
1841 if let Some(u) = usage {
1842 yield Ok(StreamDelta::Usage(u));
1843 }
1844 yield Ok(StreamDelta::Done {
1845 stop_reason: Some(stop_reason),
1846 });
1847 })
1848 }
1849
1850 fn model(&self) -> &str {
1851 &self.model
1852 }
1853
1854 fn provider(&self) -> &'static str {
1855 "openai-codex"
1856 }
1857
1858 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
1859 self.thinking.as_ref()
1860 }
1861}
1862
1863fn build_api_input(request: &ChatRequest) -> Vec<ApiInputItem> {
1868 let mut items = Vec::new();
1869
1870 for msg in &request.messages {
1873 let role = match msg.role {
1874 agent_sdk_foundation::llm::Role::User => ApiRole::User,
1875 agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
1876 };
1877 match &msg.content {
1878 Content::Text(text) => {
1879 items.push(ApiInputItem::Message(ApiMessage {
1880 role,
1881 content: ApiMessageContent::Text(text.clone()),
1882 phase: api_message_phase(role, false),
1883 }));
1884 }
1885 Content::Blocks(blocks) => append_block_input(&mut items, role, blocks),
1886 }
1887 }
1888
1889 items
1890}
1891
1892fn append_block_input(items: &mut Vec<ApiInputItem>, role: ApiRole, blocks: &[ContentBlock]) {
1893 let mut content_parts = Vec::new();
1894 let mut phase = api_message_phase(
1895 role,
1896 blocks
1897 .iter()
1898 .any(|block| matches!(block, ContentBlock::ToolUse { .. })),
1899 );
1900
1901 for block in blocks {
1902 match block {
1903 ContentBlock::Text { text } => {
1904 let part = if matches!(role, ApiRole::Assistant) {
1905 ApiInputContent::OutputText { text: text.clone() }
1906 } else {
1907 ApiInputContent::InputText { text: text.clone() }
1908 };
1909 content_parts.push(part);
1910 }
1911 ContentBlock::OpaqueReasoning { provider, data }
1912 if matches!(role, ApiRole::Assistant)
1913 && is_message_state_marker(provider, data) =>
1914 {
1915 flush_message_parts(items, role, phase.clone(), &mut content_parts);
1916 phase = data
1917 .get("phase")
1918 .and_then(serde_json::Value::as_str)
1919 .map(ToOwned::to_owned);
1920 }
1921 ContentBlock::OpaqueReasoning { provider, data }
1922 if provider == OPENAI_RESPONSES_REASONING_PROVIDER
1923 && data.get("type").and_then(serde_json::Value::as_str)
1924 == Some("reasoning") =>
1925 {
1926 flush_message_parts(items, role, phase.clone(), &mut content_parts);
1927 items.push(ApiInputItem::OpaqueReasoning(data.clone()));
1928 }
1929 ContentBlock::Thinking { .. }
1930 | ContentBlock::RedactedThinking { .. }
1931 | ContentBlock::OpaqueReasoning { .. } => {}
1932 ContentBlock::Image { source } => content_parts.push(ApiInputContent::Image {
1933 image_url: format!("data:{};base64,{}", source.media_type, source.data),
1934 }),
1935 ContentBlock::Document { source } => content_parts.push(ApiInputContent::File {
1936 filename: suggested_filename(&source.media_type),
1937 file_data: format!("data:{};base64,{}", source.media_type, source.data),
1938 }),
1939 ContentBlock::ToolUse {
1940 id, name, input, ..
1941 } => {
1942 flush_message_parts(items, role, phase.clone(), &mut content_parts);
1943 items.push(ApiInputItem::FunctionCall(ApiFunctionCall::new(
1944 id.clone(),
1945 name.clone(),
1946 serde_json::to_string(input).unwrap_or_default(),
1947 )));
1948 }
1949 ContentBlock::ToolResult {
1950 tool_use_id,
1951 content,
1952 ..
1953 } => {
1954 flush_message_parts(items, role, phase.clone(), &mut content_parts);
1955 items.push(ApiInputItem::FunctionCallOutput(
1956 ApiFunctionCallOutput::new(tool_use_id.clone(), content.clone()),
1957 ));
1958 }
1959 _ => log::warn!("Skipping unrecognized OpenAI Responses content block"),
1960 }
1961 }
1962
1963 flush_message_parts(items, role, phase, &mut content_parts);
1964}
1965
1966fn api_message_phase(role: ApiRole, has_tool_use: bool) -> Option<String> {
1967 match (role, has_tool_use) {
1968 (ApiRole::Assistant, true) => Some("commentary".to_owned()),
1969 (ApiRole::Assistant, false) => Some("final_answer".to_owned()),
1970 (ApiRole::User, _) => None,
1971 }
1972}
1973
1974fn message_state_marker(role: &str, phase: Option<&str>) -> serde_json::Value {
1975 let mut marker = serde_json::Map::new();
1976 marker.insert(
1977 "type".to_owned(),
1978 serde_json::Value::String(OPENAI_MESSAGE_ITEM_TYPE.to_owned()),
1979 );
1980 marker.insert(
1981 "role".to_owned(),
1982 serde_json::Value::String(role.to_owned()),
1983 );
1984 if let Some(phase) = phase {
1985 marker.insert(
1986 "phase".to_owned(),
1987 serde_json::Value::String(phase.to_owned()),
1988 );
1989 }
1990 serde_json::Value::Object(marker)
1991}
1992
1993fn is_message_state_marker(provider: &str, data: &serde_json::Value) -> bool {
1994 provider == OPENAI_RESPONSES_REASONING_PROVIDER
1995 && data.get("type").and_then(serde_json::Value::as_str) == Some(OPENAI_MESSAGE_ITEM_TYPE)
1996 && data.get("content").is_none()
1997}
1998
1999fn flush_message_parts(
2000 items: &mut Vec<ApiInputItem>,
2001 role: ApiRole,
2002 phase: Option<String>,
2003 content_parts: &mut Vec<ApiInputContent>,
2004) {
2005 if content_parts.is_empty() {
2006 return;
2007 }
2008 items.push(ApiInputItem::Message(ApiMessage {
2009 role,
2010 content: ApiMessageContent::Parts(std::mem::take(content_parts)),
2011 phase,
2012 }));
2013}
2014
2015fn fix_schema_for_strict_mode(schema: &mut serde_json::Value) {
2023 let Some(obj) = schema.as_object_mut() else {
2024 return;
2025 };
2026
2027 let is_object_type = obj
2029 .get("type")
2030 .is_some_and(|t| t.as_str() == Some("object"));
2031
2032 if is_object_type {
2033 obj.insert(
2035 "additionalProperties".to_owned(),
2036 serde_json::Value::Bool(false),
2037 );
2038
2039 obj.entry("properties".to_owned())
2041 .or_insert_with(|| serde_json::json!({}));
2042 obj.entry("required".to_owned())
2043 .or_insert_with(|| serde_json::json!([]));
2044
2045 let originally_required: std::collections::HashSet<String> = obj
2047 .get("required")
2048 .and_then(|v| v.as_array())
2049 .map(|arr| {
2050 arr.iter()
2051 .filter_map(|v| v.as_str().map(String::from))
2052 .collect()
2053 })
2054 .unwrap_or_default();
2055
2056 if let Some(serde_json::Value::Object(props)) = obj.get_mut("properties") {
2058 for (key, prop_schema) in props.iter_mut() {
2059 if !originally_required.contains(key) {
2060 make_nullable(prop_schema);
2061 }
2062 }
2063 }
2064
2065 if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
2067 let all_keys: Vec<serde_json::Value> = props
2068 .keys()
2069 .map(|k| serde_json::Value::String(k.clone()))
2070 .collect();
2071 obj.insert("required".to_owned(), serde_json::Value::Array(all_keys));
2072 }
2073 }
2074
2075 if let Some(props) = obj.get_mut("properties")
2077 && let Some(props_obj) = props.as_object_mut()
2078 {
2079 for prop_schema in props_obj.values_mut() {
2080 fix_schema_for_strict_mode(prop_schema);
2081 }
2082 }
2083
2084 if let Some(items) = obj.get_mut("items") {
2086 fix_schema_for_strict_mode(items);
2087 }
2088
2089 for key in ["anyOf", "oneOf", "allOf"] {
2091 if let Some(arr) = obj.get_mut(key)
2092 && let Some(arr_items) = arr.as_array_mut()
2093 {
2094 for item in arr_items {
2095 fix_schema_for_strict_mode(item);
2096 }
2097 }
2098 }
2099}
2100
2101fn make_nullable(schema: &mut serde_json::Value) {
2105 if let Some(any_of) = schema
2106 .as_object_mut()
2107 .and_then(|o| o.get_mut("anyOf"))
2108 .and_then(|v| v.as_array_mut())
2109 {
2110 let has_null = any_of
2111 .iter()
2112 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
2113 if !has_null {
2114 any_of.push(serde_json::json!({"type": "null"}));
2115 }
2116 return;
2117 }
2118
2119 let original = schema.clone();
2120 *schema = serde_json::json!({
2121 "anyOf": [original, {"type": "null"}]
2122 });
2123}
2124
2125fn has_freeform_object(schema: &serde_json::Value) -> bool {
2129 let Some(obj) = schema.as_object() else {
2130 return false;
2131 };
2132
2133 let is_object = obj
2134 .get("type")
2135 .is_some_and(|t| t.as_str() == Some("object"));
2136
2137 if is_object && !obj.contains_key("properties") {
2138 return true;
2139 }
2140
2141 if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
2142 for prop in props.values() {
2143 if has_freeform_object(prop) {
2144 return true;
2145 }
2146 }
2147 }
2148
2149 if let Some(items) = obj.get("items")
2150 && has_freeform_object(items)
2151 {
2152 return true;
2153 }
2154
2155 for key in ["anyOf", "oneOf", "allOf"] {
2156 if let Some(arr) = obj.get(key).and_then(|v| v.as_array()) {
2157 for item in arr {
2158 if has_freeform_object(item) {
2159 return true;
2160 }
2161 }
2162 }
2163 }
2164
2165 false
2166}
2167
2168fn convert_tool(tool: agent_sdk_foundation::llm::Tool) -> ApiTool {
2169 let mut schema = tool.input_schema;
2174 let use_strict = if has_freeform_object(&schema) {
2175 log::debug!(
2176 "Tool '{}' has free-form object schema — disabling strict mode",
2177 tool.name
2178 );
2179 None
2180 } else {
2181 fix_schema_for_strict_mode(&mut schema);
2182 Some(true)
2183 };
2184
2185 ApiTool {
2186 r#type: "function".to_owned(),
2187 name: tool.name,
2188 description: Some(tool.description),
2189 parameters: Some(schema),
2190 strict: use_strict,
2191 }
2192}
2193
2194fn suggested_filename(media_type: &str) -> String {
2195 match media_type {
2196 "application/pdf" => "attachment.pdf".to_string(),
2197 "image/png" => "image.png".to_string(),
2198 "image/jpeg" => "image.jpg".to_string(),
2199 "image/gif" => "image.gif".to_string(),
2200 "image/webp" => "image.webp".to_string(),
2201 _ => "attachment.bin".to_string(),
2202 }
2203}
2204
2205fn reasoning_output_item(fields: &serde_json::Map<String, serde_json::Value>) -> serde_json::Value {
2206 let mut item = fields.clone();
2207 item.insert(
2208 "type".to_owned(),
2209 serde_json::Value::String("reasoning".to_owned()),
2210 );
2211 serde_json::Value::Object(item)
2212}
2213
2214fn reasoning_summary_texts(fields: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
2215 fields
2216 .get("summary")
2217 .and_then(serde_json::Value::as_array)
2218 .into_iter()
2219 .flatten()
2220 .filter(|summary| {
2221 summary.get("type").and_then(serde_json::Value::as_str) == Some("summary_text")
2222 })
2223 .filter_map(|summary| {
2224 summary
2225 .get("text")
2226 .and_then(serde_json::Value::as_str)
2227 .filter(|text| !text.is_empty())
2228 .map(ToOwned::to_owned)
2229 })
2230 .collect()
2231}
2232
2233fn build_content_blocks(output: &[ApiOutputItem]) -> Vec<ContentBlock> {
2234 let mut blocks = Vec::new();
2235
2236 for item in output {
2237 match item {
2238 ApiOutputItem::Message {
2239 role,
2240 phase,
2241 content,
2242 } => {
2243 blocks.push(ContentBlock::OpaqueReasoning {
2244 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2245 data: message_state_marker(role, phase.as_deref()),
2246 });
2247 for c in content {
2248 match c {
2249 ApiOutputContent::Text { text }
2250 | ApiOutputContent::Refusal { refusal: text }
2251 if !text.is_empty() =>
2252 {
2253 blocks.push(ContentBlock::Text { text: text.clone() });
2254 }
2255 ApiOutputContent::Text { .. }
2256 | ApiOutputContent::Refusal { .. }
2257 | ApiOutputContent::Unknown => {}
2258 }
2259 }
2260 }
2261 ApiOutputItem::FunctionCall {
2262 call_id,
2263 name,
2264 arguments,
2265 ..
2266 } => {
2267 let input =
2268 serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
2269 blocks.push(ContentBlock::ToolUse {
2270 id: call_id.clone(),
2271 name: name.clone(),
2272 input,
2273 thought_signature: None,
2274 });
2275 }
2276 ApiOutputItem::Reasoning { fields } => {
2277 blocks.push(ContentBlock::OpaqueReasoning {
2278 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2279 data: reasoning_output_item(fields),
2280 });
2281 blocks.extend(reasoning_summary_texts(fields).into_iter().map(|thinking| {
2282 ContentBlock::Thinking {
2283 thinking,
2284 signature: None,
2285 }
2286 }));
2287 }
2288 ApiOutputItem::Unknown => {
2289 }
2291 }
2292 }
2293
2294 blocks
2295}
2296
2297fn output_contains_refusal(output: &[ApiOutputItem]) -> bool {
2298 output.iter().any(|item| {
2299 matches!(
2300 item,
2301 ApiOutputItem::Message { content, .. }
2302 if content
2303 .iter()
2304 .any(|content| matches!(content, ApiOutputContent::Refusal { .. }))
2305 )
2306 })
2307}
2308
2309fn build_api_reasoning(thinking: Option<&ThinkingConfig>) -> Option<ApiReasoning> {
2310 thinking
2311 .and_then(resolve_reasoning_effort)
2312 .map(|effort| ApiReasoning { effort })
2313}
2314
2315const fn resolve_reasoning_effort(config: &ThinkingConfig) -> Option<ReasoningEffort> {
2316 if let Some(effort) = config.effort {
2317 return Some(map_effort(effort));
2318 }
2319
2320 match &config.mode {
2321 ThinkingMode::Adaptive => None,
2322 ThinkingMode::Enabled { budget_tokens } => Some(map_budget_to_reasoning(*budget_tokens)),
2323 }
2324}
2325
2326const fn map_effort(effort: Effort) -> ReasoningEffort {
2327 match effort {
2328 Effort::Low => ReasoningEffort::Low,
2329 Effort::Medium => ReasoningEffort::Medium,
2330 Effort::High => ReasoningEffort::High,
2331 Effort::Max => ReasoningEffort::XHigh,
2332 }
2333}
2334
2335const fn map_reasoning_effort(effort: ReasoningEffort) -> Effort {
2336 match effort {
2337 ReasoningEffort::Low => Effort::Low,
2338 ReasoningEffort::Medium => Effort::Medium,
2339 ReasoningEffort::High => Effort::High,
2340 ReasoningEffort::XHigh => Effort::Max,
2341 }
2342}
2343
2344const fn map_budget_to_reasoning(budget_tokens: u32) -> ReasoningEffort {
2345 if budget_tokens <= 4_096 {
2346 ReasoningEffort::Low
2347 } else if budget_tokens <= 16_384 {
2348 ReasoningEffort::Medium
2349 } else if budget_tokens <= 32_768 {
2350 ReasoningEffort::High
2351 } else {
2352 ReasoningEffort::XHigh
2353 }
2354}
2355
2356fn codex_url(base_url: &str) -> String {
2357 let normalized = base_url.trim_end_matches('/');
2358 if normalized.ends_with("/codex/responses") {
2359 normalized.to_string()
2360 } else if normalized.ends_with("/codex") {
2361 format!("{normalized}/responses")
2362 } else {
2363 format!("{normalized}/codex/responses")
2364 }
2365}
2366
2367fn codex_websocket_url(base_url: &str) -> Result<url::Url> {
2368 let mut url = url::Url::parse(&codex_url(base_url))
2369 .context("failed to parse OpenAI Codex websocket URL")?;
2370
2371 let scheme = match url.scheme() {
2372 "http" => Some("ws"),
2373 "https" => Some("wss"),
2374 _ => None,
2375 };
2376
2377 if let Some(scheme) = scheme {
2378 let _ = url.set_scheme(scheme);
2379 }
2380
2381 Ok(url)
2382}
2383
2384fn extract_account_id(token: &str) -> Result<String> {
2385 let payload = token
2386 .split('.')
2387 .nth(1)
2388 .ok_or_else(|| anyhow::anyhow!("invalid OpenAI Codex OAuth token"))?;
2389 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
2390 .decode(payload)
2391 .context("failed to decode OpenAI Codex token payload")?;
2392 let payload: serde_json::Value =
2393 serde_json::from_slice(&decoded).context("failed to parse OpenAI Codex token payload")?;
2394 payload
2395 .get(OPENAI_CODEX_JWT_CLAIM_PATH)
2396 .and_then(|value| value.get("chatgpt_account_id"))
2397 .and_then(serde_json::Value::as_str)
2398 .map(ToOwned::to_owned)
2399 .ok_or_else(|| anyhow::anyhow!("chatgpt_account_id missing from OpenAI Codex token"))
2400}
2401
2402fn is_empty(value: &str) -> bool {
2403 value.trim().is_empty()
2404}
2405
2406struct ToolCallAccumulator {
2411 id: String,
2412 name: String,
2413 arguments: String,
2414 order: usize,
2417 block_index: Option<usize>,
2419}
2420
2421fn usage_from_api_usage(usage: &ApiUsage) -> Usage {
2422 Usage {
2423 input_tokens: usage.input_tokens,
2424 output_tokens: usage.output_tokens,
2425 cached_input_tokens: usage
2426 .input_tokens_details
2427 .as_ref()
2428 .map_or(0, |details| details.cached_tokens),
2429 cache_creation_input_tokens: usage
2430 .input_tokens_details
2431 .as_ref()
2432 .map_or(0, |details| details.cache_write_tokens),
2433 }
2434}
2435
2436fn output_block_index(output_index: Option<usize>) -> usize {
2437 output_index.unwrap_or(0).saturating_mul(2)
2438}
2439
2440fn reasoning_summary_block_index(output_index: Option<usize>) -> usize {
2441 output_block_index(output_index).saturating_add(1)
2442}
2443
2444fn decode_stream_event(data: &str) -> Result<ApiStreamEvent> {
2445 serde_json::from_str(data).with_context(|| {
2446 let mut snippet: String = data.chars().take(512).collect();
2450 if data.chars().count() > 512 {
2451 snippet.push('…');
2452 }
2453 format!("invalid OpenAI Codex Responses stream event: {snippet}")
2454 })
2455}
2456
2457fn decode_output_item(item: Option<serde_json::Value>) -> Result<ApiOutputItem> {
2458 let item = item.context("OpenAI Codex output_item.done omitted item")?;
2459 serde_json::from_value(item).context("invalid OpenAI Codex output item")
2460}
2461
2462fn output_item_stream_deltas(
2463 item: &ApiOutputItem,
2464 output_index: usize,
2465 include_summary: bool,
2466) -> Vec<StreamDelta> {
2467 let block_index = output_index.saturating_mul(2);
2468 match item {
2469 ApiOutputItem::Message { role, phase, .. } => {
2470 vec![StreamDelta::OpaqueReasoning {
2471 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2472 data: message_state_marker(role, phase.as_deref()),
2473 block_index,
2474 }]
2475 }
2476 ApiOutputItem::Reasoning { fields } => {
2477 let mut deltas = vec![StreamDelta::OpaqueReasoning {
2478 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2479 data: reasoning_output_item(fields),
2480 block_index,
2481 }];
2482 if include_summary {
2483 let summary_block_index = block_index.saturating_add(1);
2484 deltas.extend(reasoning_summary_texts(fields).into_iter().map(|delta| {
2485 StreamDelta::ThinkingDelta {
2486 delta,
2487 block_index: summary_block_index,
2488 }
2489 }));
2490 }
2491 deltas
2492 }
2493 ApiOutputItem::FunctionCall { .. } | ApiOutputItem::Unknown => Vec::new(),
2494 }
2495}
2496
2497fn accumulate_completed_tool_call(
2498 item: &ApiOutputItem,
2499 output_index: usize,
2500 tool_calls: &mut HashMap<String, ToolCallAccumulator>,
2501) {
2502 let ApiOutputItem::FunctionCall {
2503 call_id,
2504 name,
2505 arguments,
2506 } = item
2507 else {
2508 return;
2509 };
2510
2511 let order = tool_calls.len();
2512 let accumulator = tool_calls
2513 .entry(call_id.clone())
2514 .or_insert_with(|| ToolCallAccumulator {
2515 id: call_id.clone(),
2516 name: name.clone(),
2517 arguments: String::new(),
2518 order,
2519 block_index: Some(output_index.saturating_mul(2)),
2520 });
2521 accumulator.name.clone_from(name);
2522 accumulator.arguments.clone_from(arguments);
2523 accumulator.block_index = Some(output_index.saturating_mul(2));
2524}
2525
2526fn emit_accumulated_tool_calls(
2527 tool_calls: &HashMap<String, ToolCallAccumulator>,
2528) -> Vec<StreamDelta> {
2529 let mut accs: Vec<&ToolCallAccumulator> = tool_calls.values().collect();
2534 accs.sort_by_key(|acc| (acc.block_index.unwrap_or(usize::MAX), acc.order));
2535
2536 let mut deltas = Vec::with_capacity(accs.len() * 2);
2537 for (idx, acc) in accs.iter().enumerate() {
2538 let block_index = acc
2539 .block_index
2540 .unwrap_or_else(|| idx.saturating_add(1).saturating_mul(2));
2541 deltas.push(StreamDelta::ToolUseStart {
2542 id: acc.id.clone(),
2543 name: acc.name.clone(),
2544 block_index,
2545 thought_signature: None,
2546 });
2547 deltas.push(StreamDelta::ToolInputDelta {
2548 id: acc.id.clone(),
2549 delta: acc.arguments.clone(),
2550 block_index,
2551 });
2552 }
2553 deltas
2554}
2555
2556fn stop_reason_from_stream_state(
2557 tool_calls: &HashMap<String, ToolCallAccumulator>,
2558 status: Option<ApiStatus>,
2559 refused: bool,
2560 incomplete_reason: Option<&str>,
2561) -> Option<StopReason> {
2562 let status = status?;
2563 Some(match status {
2564 ApiStatus::Incomplete => {
2565 incomplete_reason.map_or(StopReason::Unknown, incomplete_stop_reason)
2566 }
2567 ApiStatus::Completed if refused => StopReason::Refusal,
2568 ApiStatus::Completed if !tool_calls.is_empty() => StopReason::ToolUse,
2569 ApiStatus::Completed => StopReason::EndTurn,
2570 ApiStatus::Failed
2575 | ApiStatus::InProgress
2576 | ApiStatus::Queued
2577 | ApiStatus::Cancelled
2578 | ApiStatus::Other => StopReason::Unknown,
2579 })
2580}
2581
2582fn incomplete_stop_reason(reason: &str) -> StopReason {
2583 match reason {
2584 "max_output_tokens" => StopReason::MaxTokens,
2585 "content_filter" => StopReason::Refusal,
2586 "model_context_window_exceeded" => StopReason::ModelContextWindowExceeded,
2587 _ => StopReason::Unknown,
2588 }
2589}
2590
2591fn reset_websocket_connection(session: &mut WebsocketSessionState) {
2592 session.connection = None;
2593 if session.prewarmed {
2594 session.last_request = None;
2595 session.last_response_id = None;
2596 session.last_response_items.clear();
2597 }
2598 session.prewarmed = 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
2624fn parse_wrapped_websocket_error_event(payload: &str) -> Option<(StatusCode, String)> {
2625 let event: ApiWrappedWebsocketErrorEvent = serde_json::from_str(payload).ok()?;
2626 if event.kind != "error" {
2627 return None;
2628 }
2629
2630 if event.error.as_ref().and_then(|error| error.code.as_deref())
2631 == Some(OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE)
2632 {
2633 let message = event
2634 .error
2635 .and_then(|error| error.message)
2636 .unwrap_or_else(|| "Responses websocket connection limit reached".to_string());
2637 return Some((StatusCode::TOO_MANY_REQUESTS, message));
2638 }
2639
2640 let status = StatusCode::from_u16(event.status?).ok()?;
2641 let message = event
2642 .error
2643 .and_then(|error| error.message)
2644 .unwrap_or_else(|| payload.to_string());
2645 if status.is_success() {
2646 None
2647 } else {
2648 Some((status, message))
2649 }
2650}
2651
2652fn output_item_to_input_item(item: ApiOutputItem) -> Option<ApiInputItem> {
2653 match item {
2654 ApiOutputItem::Message {
2655 role,
2656 phase,
2657 content,
2658 } => {
2659 let role = if role == "user" {
2660 ApiRole::User
2661 } else {
2662 ApiRole::Assistant
2663 };
2664 let parts: Vec<ApiInputContent> = content
2665 .into_iter()
2666 .filter_map(|content| match content {
2667 ApiOutputContent::Text { text }
2668 | ApiOutputContent::Refusal { refusal: text }
2669 if !text.is_empty() =>
2670 {
2671 Some(ApiInputContent::OutputText { text })
2672 }
2673 ApiOutputContent::Unknown
2674 | ApiOutputContent::Text { .. }
2675 | ApiOutputContent::Refusal { .. } => None,
2676 })
2677 .collect();
2678 if parts.is_empty() {
2679 None
2680 } else {
2681 Some(ApiInputItem::Message(ApiMessage {
2682 role,
2683 content: ApiMessageContent::Parts(parts),
2684 phase,
2685 }))
2686 }
2687 }
2688 ApiOutputItem::FunctionCall {
2689 call_id,
2690 name,
2691 arguments,
2692 } => Some(ApiInputItem::FunctionCall(ApiFunctionCall::new(
2693 call_id, name, arguments,
2694 ))),
2695 ApiOutputItem::Reasoning { fields } => Some(ApiInputItem::OpaqueReasoning(
2696 reasoning_output_item(&fields),
2697 )),
2698 ApiOutputItem::Unknown => None,
2699 }
2700}
2701
2702fn prepare_websocket_request(
2703 request: &ApiStreamingRequest,
2704 session: &WebsocketSessionState,
2705 allow_empty_delta: bool,
2706) -> ApiWebsocketRequest {
2707 let mut websocket_request = ApiWebsocketRequest::from(request);
2708
2709 let Some(last_request) = session.last_request.as_ref() else {
2710 return websocket_request;
2711 };
2712 let Some(last_response_id) = session.last_response_id.as_ref() else {
2713 return websocket_request;
2714 };
2715
2716 let mut previous_without_input = last_request.clone();
2717 previous_without_input.input.clear();
2718 let mut current_without_input = request.clone();
2719 current_without_input.input.clear();
2720 if previous_without_input != current_without_input {
2721 return websocket_request;
2722 }
2723
2724 let mut baseline = last_request.input.clone();
2725 baseline.extend(session.last_response_items.clone());
2726 if request.input.starts_with(&baseline)
2727 && (allow_empty_delta || baseline.len() < request.input.len())
2728 {
2729 websocket_request.previous_response_id = Some(last_response_id.clone());
2730 websocket_request.input = request.input[baseline.len()..].to_vec();
2731 }
2732
2733 websocket_request
2734}
2735
2736#[derive(Serialize)]
2741struct ApiResponsesRequest<'a> {
2742 model: &'a str,
2743 #[serde(skip_serializing_if = "is_empty")]
2744 instructions: &'a str,
2745 input: &'a [ApiInputItem],
2746 #[serde(skip_serializing_if = "Option::is_none")]
2747 tools: Option<&'a [ApiTool]>,
2748 #[serde(skip_serializing_if = "Option::is_none")]
2749 max_output_tokens: Option<u32>,
2750 #[serde(skip_serializing_if = "Option::is_none")]
2751 reasoning: Option<ApiReasoning>,
2752 #[serde(skip_serializing_if = "Option::is_none")]
2753 tool_choice: Option<ApiToolChoice>,
2754 #[serde(skip_serializing_if = "Option::is_none")]
2755 parallel_tool_calls: Option<bool>,
2756 store: bool,
2757 #[serde(skip_serializing_if = "Option::is_none")]
2758 text: Option<ApiTextSettings>,
2759 #[serde(skip_serializing_if = "Option::is_none")]
2760 include: Option<&'a [&'static str]>,
2761 #[serde(skip_serializing_if = "Option::is_none")]
2762 prompt_cache_key: Option<&'a str>,
2763}
2764
2765#[derive(Clone, PartialEq, Serialize)]
2766struct ApiStreamingRequest {
2767 model: String,
2768 #[serde(skip_serializing_if = "String::is_empty")]
2769 instructions: String,
2770 input: Vec<ApiInputItem>,
2771 #[serde(skip_serializing_if = "Option::is_none")]
2772 tools: Option<Vec<ApiTool>>,
2773 #[serde(skip_serializing_if = "Option::is_none")]
2774 max_output_tokens: Option<u32>,
2775 #[serde(skip_serializing_if = "Option::is_none")]
2776 reasoning: Option<ApiReasoning>,
2777 #[serde(skip_serializing_if = "Option::is_none")]
2778 tool_choice: Option<ApiToolChoice>,
2779 #[serde(skip_serializing_if = "Option::is_none")]
2780 parallel_tool_calls: Option<bool>,
2781 store: bool,
2782 #[serde(skip_serializing_if = "Option::is_none")]
2783 text: Option<ApiTextSettings>,
2784 #[serde(skip_serializing_if = "Option::is_none")]
2785 include: Option<Vec<String>>,
2786 #[serde(skip_serializing_if = "Option::is_none")]
2787 prompt_cache_key: Option<String>,
2788 stream: bool,
2789}
2790
2791#[derive(Clone, Serialize)]
2792struct ApiWebsocketRequest {
2793 #[serde(rename = "type")]
2794 kind: &'static str,
2795 model: String,
2796 #[serde(skip_serializing_if = "String::is_empty")]
2797 instructions: String,
2798 #[serde(skip_serializing_if = "Option::is_none")]
2799 previous_response_id: Option<String>,
2800 input: Vec<ApiInputItem>,
2801 #[serde(skip_serializing_if = "Option::is_none")]
2802 tools: Option<Vec<ApiTool>>,
2803 #[serde(skip_serializing_if = "Option::is_none")]
2804 max_output_tokens: Option<u32>,
2805 #[serde(skip_serializing_if = "Option::is_none")]
2806 reasoning: Option<ApiReasoning>,
2807 #[serde(skip_serializing_if = "Option::is_none")]
2808 tool_choice: Option<ApiToolChoice>,
2809 #[serde(skip_serializing_if = "Option::is_none")]
2810 parallel_tool_calls: Option<bool>,
2811 store: bool,
2812 #[serde(skip_serializing_if = "Option::is_none")]
2813 text: Option<ApiTextSettings>,
2814 #[serde(skip_serializing_if = "Option::is_none")]
2815 include: Option<Vec<String>>,
2816 #[serde(skip_serializing_if = "Option::is_none")]
2817 prompt_cache_key: Option<String>,
2818 stream: bool,
2819 #[serde(skip_serializing_if = "Option::is_none")]
2820 generate: Option<bool>,
2821}
2822
2823impl From<&ApiStreamingRequest> for ApiWebsocketRequest {
2824 fn from(request: &ApiStreamingRequest) -> Self {
2825 Self {
2826 kind: "response.create",
2827 model: request.model.clone(),
2828 instructions: request.instructions.clone(),
2829 previous_response_id: None,
2830 input: request.input.clone(),
2831 tools: request.tools.clone(),
2832 max_output_tokens: request.max_output_tokens,
2833 reasoning: request.reasoning.clone(),
2834 tool_choice: request.tool_choice.clone(),
2835 parallel_tool_calls: request.parallel_tool_calls,
2836 store: request.store,
2837 text: request.text.clone(),
2838 include: request.include.clone(),
2839 prompt_cache_key: request.prompt_cache_key.clone(),
2840 stream: request.stream,
2841 generate: None,
2842 }
2843 }
2844}
2845
2846#[derive(Clone, PartialEq, Serialize)]
2847struct ApiTextSettings {
2848 verbosity: &'static str,
2849 #[serde(skip_serializing_if = "Option::is_none")]
2852 format: Option<ApiResponseTextFormat>,
2853}
2854
2855#[derive(Clone, PartialEq, Serialize)]
2856struct ApiResponseTextFormat {
2857 #[serde(rename = "type")]
2858 format_type: &'static str,
2859 name: String,
2860 schema: serde_json::Value,
2861 strict: bool,
2862}
2863
2864impl From<&ResponseFormat> for ApiResponseTextFormat {
2865 fn from(rf: &ResponseFormat) -> Self {
2866 Self {
2867 format_type: "json_schema",
2868 name: rf.name.clone(),
2869 schema: rf.schema.clone(),
2870 strict: rf.strict,
2871 }
2872 }
2873}
2874
2875#[derive(Clone, PartialEq, Serialize)]
2880#[serde(untagged)]
2881enum ApiToolChoice {
2882 Mode(&'static str),
2883 Function {
2884 #[serde(rename = "type")]
2885 choice_type: &'static str,
2886 name: String,
2887 },
2888}
2889
2890fn codex_tool_choice(tool_choice: Option<&ToolChoice>) -> ApiToolChoice {
2893 match tool_choice {
2894 Some(ToolChoice::Tool(name)) => ApiToolChoice::Function {
2895 choice_type: "function",
2896 name: name.clone(),
2897 },
2898 _ => ApiToolChoice::Mode("auto"),
2899 }
2900}
2901
2902#[derive(Clone, PartialEq, Serialize)]
2903struct ApiReasoning {
2904 effort: ReasoningEffort,
2905}
2906
2907#[derive(Clone, PartialEq, Serialize)]
2908#[serde(untagged)]
2909enum ApiInputItem {
2910 Message(ApiMessage),
2911 FunctionCall(ApiFunctionCall),
2912 FunctionCallOutput(ApiFunctionCallOutput),
2913 OpaqueReasoning(serde_json::Value),
2914}
2915
2916#[derive(Clone, PartialEq, Serialize)]
2917struct ApiMessage {
2918 role: ApiRole,
2919 content: ApiMessageContent,
2920 #[serde(skip_serializing_if = "Option::is_none")]
2921 phase: Option<String>,
2922}
2923
2924#[derive(Clone, Copy, PartialEq, Serialize)]
2925#[serde(rename_all = "lowercase")]
2926enum ApiRole {
2927 User,
2928 Assistant,
2929}
2930
2931#[derive(Clone, PartialEq, Serialize)]
2932#[serde(untagged)]
2933enum ApiMessageContent {
2934 Text(String),
2935 Parts(Vec<ApiInputContent>),
2936}
2937
2938#[derive(Clone, PartialEq, Serialize)]
2939#[serde(tag = "type")]
2940enum ApiInputContent {
2941 #[serde(rename = "input_text")]
2942 InputText { text: String },
2943 #[serde(rename = "output_text")]
2944 OutputText { text: String },
2945 #[serde(rename = "input_image")]
2946 Image { image_url: String },
2947 #[serde(rename = "input_file")]
2948 File { filename: String, file_data: String },
2949}
2950
2951#[derive(Clone, PartialEq, Serialize)]
2952struct ApiFunctionCall {
2953 r#type: &'static str,
2954 call_id: String,
2955 name: String,
2956 arguments: String,
2957}
2958
2959impl ApiFunctionCall {
2960 const fn new(call_id: String, name: String, arguments: String) -> Self {
2961 Self {
2962 r#type: "function_call",
2963 call_id,
2964 name,
2965 arguments,
2966 }
2967 }
2968}
2969
2970#[derive(Clone, PartialEq, Serialize)]
2971struct ApiFunctionCallOutput {
2972 r#type: &'static str,
2973 call_id: String,
2974 output: String,
2975}
2976
2977impl ApiFunctionCallOutput {
2978 const fn new(call_id: String, output: String) -> Self {
2979 Self {
2980 r#type: "function_call_output",
2981 call_id,
2982 output,
2983 }
2984 }
2985}
2986
2987#[derive(Clone, PartialEq, Serialize)]
2988struct ApiTool {
2989 r#type: String,
2990 name: String,
2991 #[serde(skip_serializing_if = "Option::is_none")]
2992 description: Option<String>,
2993 #[serde(skip_serializing_if = "Option::is_none")]
2994 parameters: Option<serde_json::Value>,
2995 #[serde(skip_serializing_if = "Option::is_none")]
2996 strict: Option<bool>,
2997}
2998
2999#[derive(Deserialize)]
3004struct ApiResponse {
3005 id: String,
3006 model: String,
3007 output: Vec<ApiOutputItem>,
3008 #[serde(default)]
3009 status: Option<ApiStatus>,
3010 #[serde(default)]
3011 usage: Option<ApiUsage>,
3012 #[serde(default)]
3013 error: Option<ApiErrorBody>,
3014 #[serde(default)]
3015 incomplete_details: Option<ApiIncompleteDetails>,
3016}
3017
3018#[derive(Deserialize)]
3019struct ApiIncompleteDetails {
3020 #[serde(default)]
3021 reason: Option<String>,
3022}
3023
3024#[derive(Clone, Copy, Deserialize)]
3025#[serde(rename_all = "snake_case")]
3026enum ApiStatus {
3027 Completed,
3028 Incomplete,
3029 Failed,
3030 InProgress,
3035 Queued,
3036 Cancelled,
3037 #[serde(other)]
3040 Other,
3041}
3042
3043#[derive(Deserialize)]
3044struct ApiUsage {
3045 input_tokens: u32,
3046 output_tokens: u32,
3047 #[serde(default)]
3048 input_tokens_details: Option<ApiInputTokensDetails>,
3049}
3050
3051#[derive(Deserialize)]
3052struct ApiInputTokensDetails {
3053 #[serde(default)]
3054 cached_tokens: u32,
3055 #[serde(default)]
3056 cache_write_tokens: u32,
3057}
3058
3059#[derive(Deserialize)]
3060#[serde(tag = "type")]
3061enum ApiOutputItem {
3062 #[serde(rename = "message")]
3063 Message {
3064 role: String,
3065 #[serde(default)]
3066 phase: Option<String>,
3067 content: Vec<ApiOutputContent>,
3068 },
3069 #[serde(rename = "function_call")]
3070 FunctionCall {
3071 call_id: String,
3072 name: String,
3073 arguments: String,
3074 },
3075 #[serde(rename = "reasoning")]
3076 Reasoning {
3077 #[serde(flatten)]
3078 fields: serde_json::Map<String, serde_json::Value>,
3079 },
3080 #[serde(other)]
3081 Unknown,
3082}
3083
3084#[derive(Deserialize)]
3085#[serde(tag = "type")]
3086enum ApiOutputContent {
3087 #[serde(rename = "output_text")]
3088 Text { text: String },
3089 #[serde(rename = "refusal")]
3090 Refusal { refusal: String },
3091 #[serde(other)]
3092 Unknown,
3093}
3094
3095#[derive(Deserialize)]
3100struct ApiStreamEvent {
3101 r#type: String,
3102 #[serde(default)]
3103 output_index: Option<usize>,
3104 #[serde(default)]
3105 delta: Option<String>,
3106 #[serde(default)]
3107 call_id: Option<String>,
3108 #[serde(default)]
3109 name: Option<String>,
3110 #[serde(default)]
3111 item: Option<serde_json::Value>,
3112 #[serde(default)]
3113 response: Option<ApiStreamResponse>,
3114}
3115
3116#[derive(Deserialize)]
3117struct ApiStreamResponse {
3118 #[serde(default)]
3119 id: Option<String>,
3120 #[serde(default)]
3121 usage: Option<ApiUsage>,
3122 #[serde(default)]
3123 error: Option<ApiErrorBody>,
3124 #[serde(default)]
3125 status: Option<ApiStatus>,
3126 #[serde(default)]
3127 incomplete_details: Option<ApiIncompleteDetails>,
3128}
3129
3130#[derive(Deserialize)]
3131struct ApiErrorBody {
3132 #[serde(default)]
3133 message: Option<String>,
3134}
3135
3136#[derive(Deserialize)]
3137struct ApiWrappedWebsocketErrorBody {
3138 #[serde(default)]
3139 code: Option<String>,
3140 #[serde(default)]
3141 message: Option<String>,
3142}
3143
3144#[derive(Deserialize)]
3145struct ApiWrappedWebsocketErrorEvent {
3146 #[serde(rename = "type")]
3147 kind: String,
3148 #[serde(alias = "status_code")]
3149 status: Option<u16>,
3150 #[serde(default)]
3151 error: Option<ApiWrappedWebsocketErrorBody>,
3152}
3153
3154#[cfg(test)]
3159mod tests {
3160 use super::*;
3161
3162 #[test]
3163 fn test_model_constant() {
3164 assert_eq!(MODEL_GPT54, "gpt-5.4");
3165 assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
3166 assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
3167 }
3168
3169 #[test]
3170 fn test_codex_factory() {
3171 let provider = OpenAICodexResponsesProvider::codex("test-key".to_string());
3172 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
3173 assert_eq!(provider.provider(), "openai-codex");
3174 }
3175
3176 #[test]
3177 fn test_gpt54_factory() {
3178 let provider = OpenAICodexResponsesProvider::gpt54("test-key".to_string());
3179 assert_eq!(provider.model(), MODEL_GPT54);
3180 assert_eq!(provider.provider(), "openai-codex");
3181 }
3182
3183 #[test]
3184 fn test_gpt53_codex_factory() {
3185 let provider = OpenAICodexResponsesProvider::gpt53_codex("test-key".to_string());
3186 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
3187 assert_eq!(provider.provider(), "openai-codex");
3188 }
3189
3190 #[test]
3191 fn test_reasoning_effort_serialization() {
3192 let low = serde_json::to_string(&ReasoningEffort::Low).unwrap();
3193 assert_eq!(low, "\"low\"");
3194
3195 let xhigh = serde_json::to_string(&ReasoningEffort::XHigh).unwrap();
3196 assert_eq!(xhigh, "\"xhigh\"");
3197 }
3198
3199 #[test]
3200 fn test_with_reasoning_effort() {
3201 let provider = OpenAICodexResponsesProvider::codex("test-key".to_string())
3202 .with_reasoning_effort(ReasoningEffort::High);
3203 let thinking = provider.thinking.as_ref().unwrap();
3204 assert!(matches!(thinking.effort, Some(Effort::High)));
3205 }
3206
3207 #[test]
3208 fn test_build_api_reasoning_uses_explicit_effort() {
3209 let reasoning =
3210 build_api_reasoning(Some(&ThinkingConfig::adaptive_with_effort(Effort::Low))).unwrap();
3211 assert!(matches!(reasoning.effort, ReasoningEffort::Low));
3212 }
3213
3214 #[test]
3215 fn test_build_api_reasoning_omits_adaptive_without_effort() {
3216 assert!(build_api_reasoning(Some(&ThinkingConfig::adaptive())).is_none());
3217 }
3218
3219 #[test]
3220 fn test_openai_responses_accepts_adaptive_thinking() {
3221 let provider = OpenAICodexResponsesProvider::codex("test-key".to_string());
3222 assert!(
3223 provider
3224 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
3225 .is_ok()
3226 );
3227 }
3228
3229 #[test]
3230 fn test_api_tool_serialization() {
3231 let tool = ApiTool {
3232 r#type: "function".to_owned(),
3233 name: "get_weather".to_owned(),
3234 description: Some("Get weather".to_owned()),
3235 parameters: Some(serde_json::json!({"type": "object"})),
3236 strict: Some(true),
3237 };
3238
3239 let json = serde_json::to_string(&tool).unwrap();
3240 assert!(json.contains("\"type\":\"function\""));
3241 assert!(json.contains("\"name\":\"get_weather\""));
3242 assert!(json.contains("\"strict\":true"));
3243 }
3244
3245 fn test_token() -> String {
3246 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
3247 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(
3248 r#"{{"{OPENAI_CODEX_JWT_CLAIM_PATH}":{{"chatgpt_account_id":"acct_123"}}}}"#
3249 ));
3250 format!("{header}.{payload}.sig")
3251 }
3252
3253 #[test]
3254 fn test_build_headers_match_codex_style_defaults() -> anyhow::Result<()> {
3255 let provider = OpenAICodexResponsesProvider::codex(test_token());
3256
3257 let headers = provider.build_headers(true, Some("session-123"), None)?;
3258 assert_eq!(headers.get("originator").unwrap(), OPENAI_CODEX_ORIGINATOR);
3259 assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123");
3260 assert_eq!(headers.get("session_id").unwrap(), "session-123");
3261 assert_eq!(headers.get("x-client-request-id").unwrap(), "session-123");
3262 assert_eq!(
3263 headers.get("OpenAI-Beta").unwrap(),
3264 OPENAI_CODEX_RESPONSES_BETA_HEADER
3265 );
3266
3267 Ok(())
3268 }
3269
3270 #[test]
3271 fn test_build_websocket_headers_match_codex_style_defaults() -> anyhow::Result<()> {
3272 let provider = OpenAICodexResponsesProvider::codex(test_token());
3273
3274 let headers = provider.build_websocket_headers(Some("session-123"), Some("turn-1"))?;
3275 assert_eq!(headers.get("originator").unwrap(), OPENAI_CODEX_ORIGINATOR);
3276 assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123");
3277 assert_eq!(headers.get("session_id").unwrap(), "session-123");
3278 assert_eq!(headers.get("x-client-request-id").unwrap(), "session-123");
3279 assert_eq!(
3280 headers.get(OPENAI_CODEX_TURN_STATE_HEADER).unwrap(),
3281 "turn-1"
3282 );
3283 assert_eq!(
3284 headers.get("OpenAI-Beta").unwrap(),
3285 OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER,
3286 );
3287
3288 Ok(())
3289 }
3290
3291 #[test]
3292 fn test_build_headers_uses_configured_account_id_without_jwt_decode() -> anyhow::Result<()> {
3293 let provider = OpenAICodexResponsesProvider::codex("not-a-jwt".to_string())
3294 .with_account_id("acct_stored");
3295
3296 let headers = provider.build_headers(true, Some("session-123"), Some("turn-1"))?;
3297 assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_stored");
3298 assert_eq!(
3299 headers.get(OPENAI_CODEX_TURN_STATE_HEADER).unwrap(),
3300 "turn-1"
3301 );
3302
3303 Ok(())
3304 }
3305
3306 #[test]
3307 fn test_request_serialization_includes_store_false() {
3308 let request = ApiStreamingRequest {
3309 model: MODEL_GPT53_CODEX.to_string(),
3310 instructions: "system".to_string(),
3311 input: Vec::new(),
3312 tools: None,
3313 max_output_tokens: None,
3314 reasoning: None,
3315 tool_choice: Some(ApiToolChoice::Mode("auto")),
3316 parallel_tool_calls: Some(true),
3317 store: false,
3318 text: Some(ApiTextSettings {
3319 verbosity: "medium",
3320 format: None,
3321 }),
3322 include: Some(vec!["reasoning.encrypted_content".to_string()]),
3323 prompt_cache_key: Some("session-123".to_string()),
3324 stream: true,
3325 };
3326
3327 let json = serde_json::to_string(&request).unwrap();
3328 assert!(json.contains("\"store\":false"));
3329 assert!(json.contains("\"stream\":true"));
3330 }
3331
3332 #[test]
3333 fn test_prepare_websocket_request_uses_previous_response_id_for_incremental_input() {
3334 let request = ApiStreamingRequest {
3335 model: MODEL_GPT53_CODEX.to_string(),
3336 instructions: "system".to_string(),
3337 input: vec![
3338 ApiInputItem::Message(ApiMessage {
3339 role: ApiRole::User,
3340 content: ApiMessageContent::Text("first".to_string()),
3341 phase: None,
3342 }),
3343 ApiInputItem::Message(ApiMessage {
3344 role: ApiRole::Assistant,
3345 content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
3346 text: "answer".to_string(),
3347 }]),
3348 phase: Some("final_answer".to_owned()),
3349 }),
3350 ApiInputItem::Message(ApiMessage {
3351 role: ApiRole::User,
3352 content: ApiMessageContent::Text("follow up".to_string()),
3353 phase: None,
3354 }),
3355 ],
3356 tools: None,
3357 max_output_tokens: None,
3358 reasoning: None,
3359 tool_choice: Some(ApiToolChoice::Mode("auto")),
3360 parallel_tool_calls: None,
3361 store: false,
3362 text: Some(ApiTextSettings {
3363 verbosity: "medium",
3364 format: None,
3365 }),
3366 include: Some(vec!["reasoning.encrypted_content".to_string()]),
3367 prompt_cache_key: Some("thread-1".to_string()),
3368 stream: true,
3369 };
3370 let previous_request = ApiStreamingRequest {
3371 input: vec![ApiInputItem::Message(ApiMessage {
3372 role: ApiRole::User,
3373 content: ApiMessageContent::Text("first".to_string()),
3374 phase: None,
3375 })],
3376 ..request.clone()
3377 };
3378 let session = WebsocketSessionState {
3379 connection: None,
3380 last_request: Some(previous_request),
3381 last_response_id: Some("resp_prev".to_string()),
3382 last_response_items: vec![ApiInputItem::Message(ApiMessage {
3383 role: ApiRole::Assistant,
3384 content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
3385 text: "answer".to_string(),
3386 }]),
3387 phase: Some("final_answer".to_owned()),
3388 })],
3389 turn_state: None,
3390 prewarmed: false,
3391 websocket_disabled: false,
3392 in_flight: false,
3393 last_used: None,
3394 };
3395
3396 let websocket_request = prepare_websocket_request(&request, &session, false);
3397 assert_eq!(
3398 websocket_request.previous_response_id.as_deref(),
3399 Some("resp_prev")
3400 );
3401 assert_eq!(websocket_request.input.len(), 1);
3402 match &websocket_request.input[0] {
3403 ApiInputItem::Message(ApiMessage {
3404 role: ApiRole::User,
3405 content: ApiMessageContent::Text(text),
3406 ..
3407 }) => assert_eq!(text, "follow up"),
3408 _ => panic!("expected incremental follow-up user message"),
3409 }
3410 }
3411
3412 #[test]
3413 fn test_parse_wrapped_websocket_error_event_maps_http_status() {
3414 let payload = r#"{"type":"error","status":401,"error":{"message":"unauthorized"}}"#;
3415 let parsed = parse_wrapped_websocket_error_event(payload);
3416 assert_eq!(
3417 parsed,
3418 Some((StatusCode::UNAUTHORIZED, "unauthorized".to_string())),
3419 );
3420 }
3421
3422 #[test]
3423 fn test_parse_wrapped_websocket_error_event_maps_connection_limit() {
3424 let payload = format!(
3425 r#"{{"type":"error","status":429,"error":{{"code":"{OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE}","message":"limit"}}}}"#,
3426 );
3427 let parsed = parse_wrapped_websocket_error_event(&payload);
3428 assert_eq!(
3429 parsed,
3430 Some((StatusCode::TOO_MANY_REQUESTS, "limit".to_string())),
3431 );
3432 }
3433
3434 #[test]
3435 fn test_prepare_websocket_request_allows_empty_delta_after_prewarm() {
3436 let request = ApiStreamingRequest {
3437 model: MODEL_GPT53_CODEX.to_string(),
3438 instructions: "system".to_string(),
3439 input: vec![ApiInputItem::Message(ApiMessage {
3440 role: ApiRole::User,
3441 content: ApiMessageContent::Text("first".to_string()),
3442 phase: None,
3443 })],
3444 tools: None,
3445 max_output_tokens: None,
3446 reasoning: None,
3447 tool_choice: Some(ApiToolChoice::Mode("auto")),
3448 parallel_tool_calls: None,
3449 store: false,
3450 text: Some(ApiTextSettings {
3451 verbosity: "medium",
3452 format: None,
3453 }),
3454 include: Some(vec!["reasoning.encrypted_content".to_string()]),
3455 prompt_cache_key: Some("thread-1".to_string()),
3456 stream: true,
3457 };
3458 let session = WebsocketSessionState {
3459 connection: None,
3460 last_request: Some(request.clone()),
3461 last_response_id: Some("resp_prewarm".to_string()),
3462 last_response_items: Vec::new(),
3463 turn_state: None,
3464 prewarmed: true,
3465 websocket_disabled: false,
3466 in_flight: false,
3467 last_used: None,
3468 };
3469
3470 let websocket_request = prepare_websocket_request(&request, &session, true);
3471 assert_eq!(
3472 websocket_request.previous_response_id.as_deref(),
3473 Some("resp_prewarm")
3474 );
3475 assert!(websocket_request.input.is_empty());
3476 }
3477
3478 #[test]
3479 fn test_api_response_deserialization() {
3480 let json = r#"{
3481 "id": "resp_123",
3482 "model": "gpt-5.2-codex",
3483 "output": [
3484 {
3485 "type": "message",
3486 "role": "assistant",
3487 "content": [
3488 {"type": "output_text", "text": "Hello!"}
3489 ]
3490 }
3491 ],
3492 "status": "completed",
3493 "usage": {
3494 "input_tokens": 100,
3495 "output_tokens": 50
3496 }
3497 }"#;
3498
3499 let response: ApiResponse = serde_json::from_str(json).unwrap();
3500 assert_eq!(response.id, "resp_123");
3501 assert_eq!(response.model, "gpt-5.2-codex");
3502 assert_eq!(response.output.len(), 1);
3503 }
3504
3505 #[test]
3506 fn test_api_response_with_function_call() {
3507 let json = r#"{
3508 "id": "resp_456",
3509 "model": "gpt-5.2-codex",
3510 "output": [
3511 {
3512 "type": "function_call",
3513 "call_id": "call_abc",
3514 "name": "read_file",
3515 "arguments": "{\"path\": \"test.txt\"}"
3516 }
3517 ],
3518 "status": "completed"
3519 }"#;
3520
3521 let response: ApiResponse = serde_json::from_str(json).unwrap();
3522 assert_eq!(response.output.len(), 1);
3523
3524 match &response.output[0] {
3525 ApiOutputItem::FunctionCall {
3526 call_id,
3527 name,
3528 arguments,
3529 } => {
3530 assert_eq!(call_id, "call_abc");
3531 assert_eq!(name, "read_file");
3532 assert!(arguments.contains("test.txt"));
3533 }
3534 _ => panic!("Expected FunctionCall"),
3535 }
3536 }
3537
3538 #[test]
3539 fn test_build_api_input_uses_responses_text_types_by_role() {
3540 let request = ChatRequest {
3541 system: "system".to_string(),
3542 messages: vec![
3543 agent_sdk_foundation::llm::Message::user_with_content(vec![ContentBlock::Text {
3544 text: "question".to_string(),
3545 }]),
3546 agent_sdk_foundation::llm::Message {
3547 role: agent_sdk_foundation::llm::Role::Assistant,
3548 content: Content::Blocks(vec![ContentBlock::Text {
3549 text: "answer".to_string(),
3550 }]),
3551 },
3552 ],
3553 tools: None,
3554 max_tokens: 512,
3555 max_tokens_explicit: false,
3556 session_id: None,
3557 cached_content: None,
3558 thinking: None,
3559 tool_choice: None,
3560 response_format: None,
3561 cache: None,
3562 };
3563
3564 let input = build_api_input(&request);
3565 assert_eq!(input.len(), 2);
3566
3567 match &input[0] {
3568 ApiInputItem::Message(ApiMessage {
3569 role: ApiRole::User,
3570 content: ApiMessageContent::Parts(parts),
3571 ..
3572 }) => assert!(matches!(
3573 parts.as_slice(),
3574 [ApiInputContent::InputText { text }] if text == "question"
3575 )),
3576 _ => panic!("expected user message with input_text content"),
3577 }
3578
3579 match &input[1] {
3580 ApiInputItem::Message(ApiMessage {
3581 role: ApiRole::Assistant,
3582 content: ApiMessageContent::Parts(parts),
3583 ..
3584 }) => assert!(matches!(
3585 parts.as_slice(),
3586 [ApiInputContent::OutputText { text }] if text == "answer"
3587 )),
3588 _ => panic!("expected assistant message with output_text content"),
3589 }
3590 }
3591
3592 #[test]
3593 fn test_api_input_content_serialization_uses_current_responses_tags() {
3594 let json = serde_json::to_string(&ApiMessageContent::Parts(vec![
3595 ApiInputContent::InputText {
3596 text: "prompt".to_string(),
3597 },
3598 ApiInputContent::OutputText {
3599 text: "reply".to_string(),
3600 },
3601 ApiInputContent::Image {
3602 image_url: "data:image/png;base64,abc".to_string(),
3603 },
3604 ApiInputContent::File {
3605 filename: "notes.txt".to_string(),
3606 file_data: "data:text/plain;base64,abc".to_string(),
3607 },
3608 ]))
3609 .unwrap();
3610
3611 assert!(json.contains("\"type\":\"input_text\""));
3612 assert!(json.contains("\"type\":\"output_text\""));
3613 assert!(json.contains("\"type\":\"input_image\""));
3614 assert!(json.contains("\"type\":\"input_file\""));
3615 }
3616
3617 #[test]
3618 fn test_build_content_blocks_text() {
3619 let output = vec![ApiOutputItem::Message {
3620 role: "assistant".to_owned(),
3621 phase: Some("final_answer".to_owned()),
3622 content: vec![ApiOutputContent::Text {
3623 text: "Hello!".to_owned(),
3624 }],
3625 }];
3626
3627 let blocks = build_content_blocks(&output);
3628 assert_eq!(blocks.len(), 2);
3629 assert!(matches!(
3630 &blocks[0],
3631 ContentBlock::OpaqueReasoning { data, .. }
3632 if data["phase"] == "final_answer"
3633 ));
3634 assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Hello!"));
3635 }
3636
3637 #[test]
3638 fn assistant_message_phases_round_trip_without_duplicate_text() -> anyhow::Result<()> {
3639 let output: Vec<ApiOutputItem> = serde_json::from_value(serde_json::json!([
3640 {
3641 "type": "message",
3642 "role": "assistant",
3643 "phase": "commentary",
3644 "content": [{"type": "output_text", "text": "Working."}]
3645 },
3646 {
3647 "type": "message",
3648 "role": "assistant",
3649 "phase": "final_answer",
3650 "content": [{"type": "output_text", "text": "Done."}]
3651 }
3652 ]))?;
3653 let blocks = build_content_blocks(&output);
3654 let request =
3655 ChatRequest::new(String::new(), vec![Message::assistant_with_content(blocks)]);
3656 let value = serde_json::to_value(build_api_input(&request))?;
3657 let items = value
3658 .as_array()
3659 .context("Codex input must serialize as an array")?;
3660
3661 assert_eq!(items.len(), 2);
3662 assert_eq!(items[0]["phase"], "commentary");
3663 assert_eq!(items[0]["content"][0]["text"], "Working.");
3664 assert_eq!(items[1]["phase"], "final_answer");
3665 assert_eq!(items[1]["content"][0]["text"], "Done.");
3666 assert_eq!(value.to_string().matches("Working.").count(), 1);
3667 assert_eq!(value.to_string().matches("Done.").count(), 1);
3668
3669 let direct: ApiOutputItem = serde_json::from_value(serde_json::json!({
3670 "type": "message",
3671 "role": "assistant",
3672 "phase": "commentary",
3673 "content": [{"type": "output_text", "text": "Working."}]
3674 }))?;
3675 let direct = output_item_to_input_item(direct)
3676 .context("message output should become a continuation input")?;
3677 assert_eq!(serde_json::to_value(direct)?["phase"], "commentary");
3678 Ok(())
3679 }
3680
3681 #[test]
3682 fn streamed_phase_and_reasoning_summary_preserve_block_order() -> anyhow::Result<()> {
3683 let message: ApiOutputItem = serde_json::from_value(serde_json::json!({
3684 "type": "message",
3685 "role": "assistant",
3686 "phase": "commentary",
3687 "content": [{"type": "output_text", "text": "Working."}]
3688 }))?;
3689 let mut accumulator = crate::streaming::StreamAccumulator::new();
3690 accumulator.apply(&StreamDelta::TextDelta {
3691 delta: "Working.".to_owned(),
3692 block_index: output_block_index(Some(0)),
3693 });
3694 for delta in output_item_stream_deltas(&message, 0, true) {
3695 accumulator.apply(&delta);
3696 }
3697 let message_blocks = accumulator.into_content_blocks();
3698 assert!(matches!(
3699 message_blocks.as_slice(),
3700 [ContentBlock::OpaqueReasoning { data, .. }, ContentBlock::Text { text }]
3701 if data["phase"] == "commentary" && text == "Working."
3702 ));
3703
3704 let reasoning: ApiOutputItem = serde_json::from_value(serde_json::json!({
3705 "type": "reasoning",
3706 "id": "rs_1",
3707 "encrypted_content": "ciphertext",
3708 "summary": [{"type": "summary_text", "text": "Checked."}]
3709 }))?;
3710 let mut accumulator = crate::streaming::StreamAccumulator::new();
3711 for delta in output_item_stream_deltas(&reasoning, 1, true) {
3712 accumulator.apply(&delta);
3713 }
3714 let reasoning_blocks = accumulator.into_content_blocks();
3715 assert!(matches!(
3716 reasoning_blocks.as_slice(),
3717 [ContentBlock::OpaqueReasoning { data, .. }, ContentBlock::Thinking { thinking, .. }]
3718 if data["encrypted_content"] == "ciphertext" && thinking == "Checked."
3719 ));
3720 Ok(())
3721 }
3722
3723 #[test]
3724 fn test_build_content_blocks_function_call() {
3725 let output = vec![ApiOutputItem::FunctionCall {
3726 call_id: "call_123".to_owned(),
3727 name: "test_tool".to_owned(),
3728 arguments: r#"{"key": "value"}"#.to_owned(),
3729 }];
3730
3731 let blocks = build_content_blocks(&output);
3732 assert_eq!(blocks.len(), 1);
3733 assert!(
3734 matches!(&blocks[0], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "test_tool")
3735 );
3736 }
3737
3738 #[test]
3739 fn incomplete_and_refusal_responses_suppress_partial_tools() -> anyhow::Result<()> {
3740 let incomplete: ApiResponse = serde_json::from_value(serde_json::json!({
3741 "id": "resp_incomplete",
3742 "model": "gpt-5.3-codex",
3743 "status": "incomplete",
3744 "incomplete_details": {"reason": "model_context_window_exceeded"},
3745 "output": [{
3746 "type": "function_call",
3747 "call_id": "call_partial",
3748 "name": "lookup",
3749 "arguments": "{"
3750 }]
3751 }))?;
3752 let incomplete = OpenAICodexResponsesProvider::map_response(incomplete);
3753 assert_eq!(
3754 incomplete.stop_reason,
3755 Some(StopReason::ModelContextWindowExceeded)
3756 );
3757 assert!(
3758 !incomplete
3759 .content
3760 .iter()
3761 .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
3762 );
3763
3764 let refusal: ApiResponse = serde_json::from_value(serde_json::json!({
3765 "id": "resp_refusal",
3766 "model": "gpt-5.3-codex",
3767 "status": "completed",
3768 "output": [
3769 {
3770 "type": "function_call",
3771 "call_id": "call_partial",
3772 "name": "lookup",
3773 "arguments": "{}"
3774 },
3775 {
3776 "type": "message",
3777 "role": "assistant",
3778 "phase": "final_answer",
3779 "content": [{"type": "refusal", "refusal": "Cannot comply."}]
3780 }
3781 ]
3782 }))?;
3783 let refusal = OpenAICodexResponsesProvider::map_response(refusal);
3784 assert_eq!(refusal.stop_reason, Some(StopReason::Refusal));
3785 assert!(
3786 !refusal
3787 .content
3788 .iter()
3789 .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
3790 );
3791 assert!(matches!(
3792 refusal.content.last(),
3793 Some(ContentBlock::Text { text }) if text == "Cannot comply."
3794 ));
3795 Ok(())
3796 }
3797
3798 #[test]
3799 fn reasoning_output_item_is_preserved_and_summary_is_visible() -> anyhow::Result<()> {
3800 let raw = serde_json::json!({
3801 "type": "reasoning",
3802 "id": "rs_123",
3803 "status": "completed",
3804 "encrypted_content": "ciphertext",
3805 "summary": [
3806 {"type": "summary_text", "text": "Checked the relevant constraints."}
3807 ]
3808 });
3809 let item: ApiOutputItem = serde_json::from_value(raw.clone())?;
3810 let replay_item = output_item_to_input_item(item);
3811 let Some(replay_item) = replay_item else {
3812 anyhow::bail!("reasoning item was not converted to a replay item");
3813 };
3814 assert_eq!(serde_json::to_value(replay_item)?, raw);
3815
3816 let item: ApiOutputItem = serde_json::from_value(raw.clone())?;
3817 let blocks = build_content_blocks(&[item]);
3818 assert_eq!(blocks.len(), 2);
3819 assert!(matches!(
3820 &blocks[0],
3821 ContentBlock::OpaqueReasoning { provider, data, .. }
3822 if provider == OPENAI_RESPONSES_REASONING_PROVIDER && data == &raw
3823 ));
3824 assert!(matches!(
3825 &blocks[1],
3826 ContentBlock::Thinking { thinking, signature, .. }
3827 if thinking == "Checked the relevant constraints." && signature.is_none()
3828 ));
3829 Ok(())
3830 }
3831
3832 #[test]
3833 fn matching_opaque_reasoning_replays_as_a_top_level_item_in_source_order() -> anyhow::Result<()>
3834 {
3835 let raw = serde_json::json!({
3836 "type": "reasoning",
3837 "id": "rs_123",
3838 "encrypted_content": "ciphertext",
3839 "summary": []
3840 });
3841 let request = ChatRequest::new(
3842 "",
3843 vec![agent_sdk_foundation::llm::Message {
3844 role: agent_sdk_foundation::llm::Role::Assistant,
3845 content: Content::Blocks(vec![
3846 ContentBlock::Text {
3847 text: "before".to_owned(),
3848 },
3849 ContentBlock::OpaqueReasoning {
3850 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
3851 data: raw.clone(),
3852 },
3853 ContentBlock::OpaqueReasoning {
3854 provider: "another-provider".to_owned(),
3855 data: serde_json::json!({"type": "reasoning", "id": "ignored"}),
3856 },
3857 ContentBlock::Text {
3858 text: "after".to_owned(),
3859 },
3860 ]),
3861 }],
3862 );
3863
3864 assert_eq!(
3865 serde_json::to_value(build_api_input(&request))?,
3866 serde_json::json!([
3867 {
3868 "role": "assistant",
3869 "phase": "final_answer",
3870 "content": [{"type": "output_text", "text": "before"}]
3871 },
3872 raw,
3873 {
3874 "role": "assistant",
3875 "phase": "final_answer",
3876 "content": [{"type": "output_text", "text": "after"}]
3877 }
3878 ])
3879 );
3880 Ok(())
3881 }
3882
3883 #[test]
3884 fn usage_maps_cache_write_tokens() -> anyhow::Result<()> {
3885 let usage: ApiUsage = serde_json::from_value(serde_json::json!({
3886 "input_tokens": 2048,
3887 "output_tokens": 128,
3888 "input_tokens_details": {
3889 "cached_tokens": 1024,
3890 "cache_write_tokens": 512
3891 }
3892 }))?;
3893
3894 let usage = usage_from_api_usage(&usage);
3895 assert_eq!(usage.cached_input_tokens, 1024);
3896 assert_eq!(usage.cache_creation_input_tokens, 512);
3897 Ok(())
3898 }
3899
3900 #[test]
3901 fn stream_stop_reason_requires_a_semantic_terminal_event() {
3902 let tool_calls = HashMap::new();
3903 assert!(stop_reason_from_stream_state(&tool_calls, None, false, None).is_none());
3904 assert!(matches!(
3905 stop_reason_from_stream_state(&tool_calls, Some(ApiStatus::Completed), false, None,),
3906 Some(StopReason::EndTurn)
3907 ));
3908 assert!(matches!(
3909 stop_reason_from_stream_state(
3910 &tool_calls,
3911 Some(ApiStatus::Incomplete),
3912 false,
3913 Some("max_output_tokens"),
3914 ),
3915 Some(StopReason::MaxTokens)
3916 ));
3917 }
3918
3919 #[test]
3920 fn test_request_serializes_response_format_text_and_forced_tool_choice() {
3921 let request = ApiStreamingRequest {
3922 model: MODEL_GPT53_CODEX.to_string(),
3923 instructions: String::new(),
3924 input: Vec::new(),
3925 tools: None,
3926 max_output_tokens: None,
3927 reasoning: None,
3928 tool_choice: Some(codex_tool_choice(Some(&ToolChoice::Tool(
3929 "respond".to_owned(),
3930 )))),
3931 parallel_tool_calls: None,
3932 store: false,
3933 text: Some(ApiTextSettings {
3934 verbosity: "medium",
3935 format: Some(ApiResponseTextFormat::from(&ResponseFormat::new(
3936 "person",
3937 serde_json::json!({"type": "object"}),
3938 ))),
3939 }),
3940 include: None,
3941 prompt_cache_key: None,
3942 stream: true,
3943 };
3944
3945 let json = serde_json::to_value(&request).unwrap();
3946 assert_eq!(json["text"]["format"]["type"], "json_schema");
3947 assert_eq!(json["text"]["format"]["name"], "person");
3948 assert_eq!(json["text"]["format"]["strict"], true);
3949 assert_eq!(json["tool_choice"]["type"], "function");
3950 assert_eq!(json["tool_choice"]["name"], "respond");
3951 }
3952
3953 #[test]
3954 fn test_codex_tool_choice_defaults_to_auto() {
3955 assert_eq!(
3956 serde_json::to_value(codex_tool_choice(None)).unwrap(),
3957 serde_json::json!("auto")
3958 );
3959 assert_eq!(
3960 serde_json::to_value(codex_tool_choice(Some(&ToolChoice::Auto))).unwrap(),
3961 serde_json::json!("auto")
3962 );
3963 }
3964
3965 #[test]
3966 fn test_convert_tool_makes_optional_params_nullable() {
3967 let tool = agent_sdk_foundation::llm::Tool {
3968 name: "t".to_string(),
3969 description: "d".to_string(),
3970 input_schema: serde_json::json!({
3971 "type": "object",
3972 "properties": {
3973 "req": {"type": "string"},
3974 "opt": {"type": "string"}
3975 },
3976 "required": ["req"]
3977 }),
3978 display_name: "T".to_string(),
3979 tier: agent_sdk_foundation::ToolTier::Observe,
3980 };
3981
3982 let api_tool = convert_tool(tool);
3983 assert_eq!(api_tool.strict, Some(true));
3984 let schema = api_tool.parameters.unwrap();
3985
3986 let required: Vec<&str> = schema["required"]
3987 .as_array()
3988 .unwrap()
3989 .iter()
3990 .filter_map(|v| v.as_str())
3991 .collect();
3992 assert!(required.contains(&"req"));
3993 assert!(required.contains(&"opt"));
3994
3995 let any_of = schema["properties"]["opt"]["anyOf"].as_array().unwrap();
3998 assert!(
3999 any_of
4000 .iter()
4001 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"))
4002 );
4003 }
4004
4005 #[test]
4006 fn test_convert_tool_disables_strict_for_freeform_object() {
4007 let tool = agent_sdk_foundation::llm::Tool {
4008 name: "t".to_string(),
4009 description: "d".to_string(),
4010 input_schema: serde_json::json!({"type": "object"}),
4011 display_name: "T".to_string(),
4012 tier: agent_sdk_foundation::ToolTier::Observe,
4013 };
4014
4015 let api_tool = convert_tool(tool);
4016 assert_eq!(api_tool.strict, None);
4017 }
4018
4019 #[test]
4020 fn test_emit_accumulated_tool_calls_assigns_distinct_ordered_indices() {
4021 let mut tool_calls = HashMap::new();
4022 tool_calls.insert(
4023 "b".to_string(),
4024 ToolCallAccumulator {
4025 id: "b".to_string(),
4026 name: "second".to_string(),
4027 arguments: "{}".to_string(),
4028 order: 1,
4029 block_index: None,
4030 },
4031 );
4032 tool_calls.insert(
4033 "a".to_string(),
4034 ToolCallAccumulator {
4035 id: "a".to_string(),
4036 name: "first".to_string(),
4037 arguments: "{}".to_string(),
4038 order: 0,
4039 block_index: None,
4040 },
4041 );
4042
4043 let deltas = emit_accumulated_tool_calls(&tool_calls);
4044 let starts: Vec<(String, usize)> = deltas
4045 .iter()
4046 .filter_map(|d| match d {
4047 StreamDelta::ToolUseStart {
4048 name, block_index, ..
4049 } => Some((name.clone(), *block_index)),
4050 _ => None,
4051 })
4052 .collect();
4053 assert_eq!(
4054 starts,
4055 vec![("first".to_string(), 2), ("second".to_string(), 4)]
4056 );
4057 }
4058
4059 use crate::provider::LlmProvider;
4064 use agent_sdk_foundation::llm::{ChatRequest, Message};
4065 use std::sync::atomic::AtomicUsize;
4066 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4067 use tokio::net::TcpListener;
4068
4069 fn oauth_token() -> String {
4072 test_token()
4073 }
4074
4075 fn streaming_request(session_id: &str) -> ChatRequest {
4076 ChatRequest::new("You are helpful.", vec![Message::user("hello")])
4077 .with_max_tokens(1024)
4078 .with_session_id(session_id)
4079 }
4080
4081 async fn read_http_head(stream: &mut tokio::net::TcpStream) -> String {
4083 let mut buf = Vec::new();
4084 let mut byte = [0u8; 1];
4085 while stream.read_exact(&mut byte).await.is_ok() {
4086 buf.push(byte[0]);
4087 if buf.ends_with(b"\r\n\r\n") {
4088 break;
4089 }
4090 if buf.len() > 16 * 1024 {
4091 break;
4092 }
4093 }
4094 String::from_utf8_lossy(&buf).into_owned()
4095 }
4096
4097 const HTTP_SSE_BODY: &str = concat!(
4099 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n",
4100 "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\"}}\n\n",
4101 "data: [DONE]\n\n",
4102 );
4103
4104 async fn spawn_http_only_server_with_body(
4108 sse_body: &'static str,
4109 ) -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
4110 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4111 let addr = listener.local_addr().unwrap();
4112 let ws_attempts = Arc::new(AtomicUsize::new(0));
4113 let http_requests = Arc::new(AtomicUsize::new(0));
4114 let ws_attempts_task = ws_attempts.clone();
4115 let http_requests_task = http_requests.clone();
4116
4117 tokio::spawn(async move {
4118 loop {
4119 let Ok((mut stream, _)) = listener.accept().await else {
4120 break;
4121 };
4122 let ws_attempts = ws_attempts_task.clone();
4123 let http_requests = http_requests_task.clone();
4124 tokio::spawn(async move {
4125 let head = read_http_head(&mut stream).await;
4126 if head.to_ascii_lowercase().contains("upgrade: websocket") {
4127 ws_attempts.fetch_add(1, Ordering::Relaxed);
4128 let _ = stream
4132 .write_all(
4133 b"HTTP/1.1 426 Upgrade Required\r\ncontent-length: 0\r\n\r\n",
4134 )
4135 .await;
4136 return;
4137 }
4138 http_requests.fetch_add(1, Ordering::Relaxed);
4139 let response = format!(
4140 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{}",
4141 sse_body.len(),
4142 sse_body,
4143 );
4144 let _ = stream.write_all(response.as_bytes()).await;
4145 });
4146 }
4147 });
4148
4149 (
4150 format!("http://{addr}/backend-api"),
4151 ws_attempts,
4152 http_requests,
4153 )
4154 }
4155
4156 async fn spawn_http_only_server() -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
4157 spawn_http_only_server_with_body(HTTP_SSE_BODY).await
4158 }
4159
4160 async fn drain_ok(provider: &OpenAICodexResponsesProvider, request: ChatRequest) -> bool {
4162 let mut stream = std::pin::pin!(provider.chat_stream(request));
4163 let mut saw_error = false;
4164 while let Some(item) = stream.next().await {
4165 match item {
4166 Ok(StreamDelta::Error { .. }) | Err(_) => saw_error = true,
4167 Ok(_) => {}
4168 }
4169 }
4170 !saw_error
4171 }
4172
4173 #[tokio::test]
4174 async fn websockets_disabled_builder_goes_straight_to_http() {
4175 let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
4176 let provider = OpenAICodexResponsesProvider::with_base_url(
4177 oauth_token(),
4178 MODEL_GPT53_CODEX.to_string(),
4179 base_url,
4180 )
4181 .with_websockets_disabled(true);
4182
4183 assert!(drain_ok(&provider, streaming_request("session-a")).await);
4184
4185 assert_eq!(
4186 ws_attempts.load(Ordering::Relaxed),
4187 0,
4188 "no websocket upgrade may be attempted when websockets are disabled",
4189 );
4190 assert_eq!(http_requests.load(Ordering::Relaxed), 1);
4191 }
4192
4193 #[tokio::test]
4194 async fn premature_http_stream_termination_is_an_error() {
4195 for (case, sse_body) in [
4196 ("done", "data: [DONE]\n\n"),
4197 (
4198 "eof",
4199 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
4200 ),
4201 ] {
4202 let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
4203 let provider = OpenAICodexResponsesProvider::with_base_url(
4204 oauth_token(),
4205 MODEL_GPT53_CODEX.to_string(),
4206 base_url,
4207 )
4208 .with_websockets_disabled(true);
4209 let mut stream = std::pin::pin!(
4210 provider.chat_stream(streaming_request(&format!("premature-{case}")))
4211 );
4212 let mut saw_error = false;
4213 let mut saw_done = false;
4214 while let Some(item) = stream.next().await {
4215 match item {
4216 Ok(StreamDelta::Error { .. }) | Err(_) => saw_error = true,
4217 Ok(StreamDelta::Done { .. }) => saw_done = true,
4218 Ok(_) => {}
4219 }
4220 }
4221 assert!(saw_error, "case={case} must surface a stream error");
4222 assert!(!saw_done, "case={case} must not synthesize success");
4223 }
4224 }
4225
4226 #[tokio::test]
4227 async fn malformed_http_events_and_items_fail_closed() -> anyhow::Result<()> {
4228 for (case, sse_body) in [
4229 ("event", "data: {not-json}\n\n"),
4230 (
4231 "item",
4232 "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\"}}\n\n",
4233 ),
4234 ] {
4235 let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
4236 let provider = OpenAICodexResponsesProvider::with_base_url(
4237 oauth_token(),
4238 MODEL_GPT53_CODEX.to_owned(),
4239 base_url,
4240 )
4241 .with_websockets_disabled(true);
4242 let mut stream = std::pin::pin!(
4243 provider.chat_stream(streaming_request(&format!("malformed-{case}")))
4244 );
4245 let first = stream
4246 .next()
4247 .await
4248 .context("malformed stream must emit an error")??;
4249 assert!(matches!(
4250 first,
4251 StreamDelta::Error {
4252 kind: StreamErrorKind::ServerError,
4253 ..
4254 }
4255 ));
4256 assert!(stream.next().await.is_none());
4257 }
4258 Ok(())
4259 }
4260
4261 #[tokio::test]
4262 async fn atomic_http_function_call_is_not_dropped() -> anyhow::Result<()> {
4263 let sse_body = concat!(
4264 "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"reasoning\",\"id\":\"rs_1\",\"encrypted_content\":\"ciphertext\",\"summary\":[]}}\n\n",
4265 "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",
4266 "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n",
4267 "data: [DONE]\n\n",
4268 );
4269 let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
4270 let provider = OpenAICodexResponsesProvider::with_base_url(
4271 oauth_token(),
4272 MODEL_GPT53_CODEX.to_owned(),
4273 base_url,
4274 )
4275 .with_websockets_disabled(true);
4276 let mut stream =
4277 std::pin::pin!(provider.chat_stream(streaming_request("atomic-function-call")));
4278 let mut deltas = Vec::new();
4279 while let Some(delta) = stream.next().await {
4280 deltas.push(delta?);
4281 }
4282
4283 assert!(deltas.iter().any(|delta| matches!(
4284 delta,
4285 StreamDelta::ToolUseStart { id, name, .. }
4286 if id == "call_1" && name == "read"
4287 )));
4288 assert!(deltas.iter().any(|delta| matches!(
4289 delta,
4290 StreamDelta::ToolInputDelta { id, delta, .. }
4291 if id == "call_1" && delta == r#"{"path":"src/lib.rs"}"#
4292 )));
4293 assert!(matches!(
4294 deltas.last(),
4295 Some(StreamDelta::Done {
4296 stop_reason: Some(StopReason::ToolUse)
4297 })
4298 ));
4299 Ok(())
4300 }
4301
4302 #[tokio::test]
4303 async fn non_tool_http_terminals_suppress_partial_tool_calls() -> anyhow::Result<()> {
4304 for (case, sse_body, expected_stop) in [
4305 (
4306 "incomplete",
4307 concat!(
4308 "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"call_id\":\"call_1\",\"name\":\"lookup\",\"delta\":\"{\"}\n\n",
4309 "data: {\"type\":\"response.incomplete\",\"response\":{\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
4310 "data: [DONE]\n\n",
4311 ),
4312 StopReason::MaxTokens,
4313 ),
4314 (
4315 "refusal",
4316 concat!(
4317 "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"call_id\":\"call_1\",\"name\":\"lookup\",\"delta\":\"{}\"}\n\n",
4318 "data: {\"type\":\"response.refusal.delta\",\"output_index\":1,\"delta\":\"Cannot comply.\"}\n\n",
4319 "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n",
4320 "data: [DONE]\n\n",
4321 ),
4322 StopReason::Refusal,
4323 ),
4324 ] {
4325 let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
4326 let provider = OpenAICodexResponsesProvider::with_base_url(
4327 oauth_token(),
4328 MODEL_GPT53_CODEX.to_owned(),
4329 base_url,
4330 )
4331 .with_websockets_disabled(true);
4332 let mut stream = std::pin::pin!(
4333 provider.chat_stream(streaming_request(&format!("terminal-{case}")))
4334 );
4335 let mut deltas = Vec::new();
4336 while let Some(delta) = stream.next().await {
4337 deltas.push(delta?);
4338 }
4339 assert!(matches!(
4340 deltas.last(),
4341 Some(StreamDelta::Done {
4342 stop_reason: Some(stop_reason)
4343 }) if *stop_reason == expected_stop
4344 ));
4345 assert!(!deltas.iter().any(|delta| matches!(
4346 delta,
4347 StreamDelta::ToolUseStart { .. } | StreamDelta::ToolInputDelta { .. }
4348 )));
4349 }
4350 Ok(())
4351 }
4352
4353 #[test]
4354 fn parse_disable_websockets_value_recognizes_truthy_values() {
4355 for value in ["1", "true", "TRUE", " yes ", "on"] {
4356 assert!(
4357 parse_disable_websockets_value(Some(value)),
4358 "value={value:?} should disable websockets",
4359 );
4360 }
4361 for value in ["0", "false", "no", "off", "", "maybe"] {
4362 assert!(
4363 !parse_disable_websockets_value(Some(value)),
4364 "value={value:?} should NOT disable websockets",
4365 );
4366 }
4367 assert!(!parse_disable_websockets_value(None));
4368 }
4369
4370 #[tokio::test]
4371 async fn websockets_disabled_via_env_value_goes_straight_to_http() {
4372 let disabled = parse_disable_websockets_value(Some("1"));
4377 assert!(disabled);
4378
4379 let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
4380 let provider = OpenAICodexResponsesProvider::with_base_url(
4381 oauth_token(),
4382 MODEL_GPT53_CODEX.to_string(),
4383 base_url,
4384 )
4385 .with_websockets_disabled(disabled);
4386
4387 assert!(drain_ok(&provider, streaming_request("session-env")).await);
4388
4389 assert_eq!(
4390 ws_attempts.load(Ordering::Relaxed),
4391 0,
4392 "no websocket upgrade may be attempted when the env var forces HTTP-only",
4393 );
4394 assert_eq!(http_requests.load(Ordering::Relaxed), 1);
4395 }
4396
4397 #[tokio::test]
4398 async fn provider_marked_ws_unhealthy_skips_websocket_on_new_session() {
4399 let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
4400 let provider = OpenAICodexResponsesProvider::with_base_url(
4401 oauth_token(),
4402 MODEL_GPT53_CODEX.to_string(),
4403 base_url,
4404 );
4405
4406 provider.websockets_unhealthy.store(true, Ordering::Relaxed);
4409
4410 assert!(drain_ok(&provider, streaming_request("fresh-session")).await);
4412
4413 assert_eq!(
4414 ws_attempts.load(Ordering::Relaxed),
4415 0,
4416 "a websocket-unhealthy provider must not attempt a new upgrade",
4417 );
4418 assert_eq!(http_requests.load(Ordering::Relaxed), 1);
4419 }
4420
4421 async fn spawn_ws_unauthorized_server() -> (String, Arc<AtomicUsize>) {
4425 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4426 let addr = listener.local_addr().unwrap();
4427 let http_requests = Arc::new(AtomicUsize::new(0));
4428 let http_requests_task = http_requests.clone();
4429
4430 tokio::spawn(async move {
4431 loop {
4432 let Ok((stream, _)) = listener.accept().await else {
4433 break;
4434 };
4435 let http_requests = http_requests_task.clone();
4436 tokio::spawn(async move {
4437 let mut stream = stream;
4438 let mut peek = [0u8; 1024];
4441 let Ok(n) = stream.peek(&mut peek).await else {
4442 return;
4443 };
4444 let head = String::from_utf8_lossy(&peek[..n]).to_ascii_lowercase();
4445
4446 if head.contains("upgrade: websocket") {
4447 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
4450 return;
4451 };
4452 let payload =
4453 r#"{"type":"error","status":401,"error":{"message":"unauthorized"}}"#;
4454 let _ = ws
4455 .send(WebSocketMessage::Text(payload.to_string().into()))
4456 .await;
4457 let _ = ws.send(WebSocketMessage::Close(None)).await;
4458 return;
4459 }
4460
4461 let _ = read_http_head(&mut stream).await;
4463 http_requests.fetch_add(1, Ordering::Relaxed);
4464 let response = format!(
4465 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{}",
4466 HTTP_SSE_BODY.len(),
4467 HTTP_SSE_BODY,
4468 );
4469 let _ = stream.write_all(response.as_bytes()).await;
4470 });
4471 }
4472 });
4473
4474 (format!("http://{addr}/backend-api"), http_requests)
4475 }
4476
4477 #[tokio::test]
4478 async fn ws_unauthorized_disables_session_but_not_provider() {
4479 let (base_url, http_requests) = spawn_ws_unauthorized_server().await;
4480 let provider = OpenAICodexResponsesProvider::with_base_url(
4481 oauth_token(),
4482 MODEL_GPT53_CODEX.to_string(),
4483 base_url,
4484 );
4485
4486 assert!(drain_ok(&provider, streaming_request("auth-session")).await);
4489
4490 assert!(
4494 !provider.websockets_unhealthy.load(Ordering::Relaxed),
4495 "a 401 must not mark the provider websocket-transport-unhealthy",
4496 );
4497 let session = provider.websocket_session("auth-session").await;
4500 assert!(session.lock().await.websocket_disabled);
4501
4502 assert!(http_requests.load(Ordering::Relaxed) >= 1);
4503 }
4504}