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 let include_summary = !streamed_reasoning_summaries
1198 .contains(&block_index);
1199 for delta in output_item_stream_deltas(
1200 &item,
1201 block_index,
1202 include_summary,
1203 ) {
1204 emitted_output = true;
1205 yield Ok(delta);
1206 }
1207 if let Some(item) = output_item_to_input_item(item) {
1208 response_items.push(item);
1209 }
1210 }
1211 "response.completed"
1212 | "response.incomplete"
1213 | "response.done" => {
1214 let response_status = event
1215 .response
1216 .as_ref()
1217 .and_then(|response| response.status);
1218 let incomplete_reason = event
1219 .response
1220 .as_ref()
1221 .and_then(|response| {
1222 response.incomplete_details.as_ref()
1223 })
1224 .and_then(|details| details.reason.clone());
1225 if let Some(resp) = event.response {
1226 if let Some(u) = resp.usage {
1227 usage = Some(usage_from_api_usage(&u));
1228 }
1229 if let Some(id) = resp.id {
1230 response_id = Some(id);
1231 }
1232 }
1233 let final_status = match event.r#type.as_str() {
1234 "response.incomplete" => {
1235 Some(ApiStatus::Incomplete)
1236 }
1237 "response.done" => response_status
1238 .or(Some(ApiStatus::Completed)),
1239 _ => Some(ApiStatus::Completed),
1240 };
1241 let stop_reason = stop_reason_from_stream_state(
1242 &tool_calls,
1243 final_status,
1244 refused,
1245 incomplete_reason.as_deref(),
1246 );
1247 if stop_reason == Some(StopReason::ToolUse) {
1248 for delta in
1249 emit_accumulated_tool_calls(&tool_calls)
1250 {
1251 yield Ok(delta);
1252 }
1253 }
1254 if let Some(u) = usage.take() {
1255 yield Ok(StreamDelta::Usage(u));
1256 }
1257 websocket_session.last_request = Some(api_request.clone());
1258 websocket_session.last_response_id = response_id;
1259 websocket_session.last_response_items = response_items;
1260 websocket_session.prewarmed = false;
1261 websocket_session.in_flight = false;
1265 yield Ok(StreamDelta::Done {
1266 stop_reason,
1267 });
1268 return;
1269 }
1270 "response.failed" => {
1271 websocket_session.last_request = None;
1272 websocket_session.last_response_id = None;
1273 websocket_session.last_response_items.clear();
1274 websocket_session.prewarmed = false;
1275 let message = event
1276 .response
1277 .and_then(|resp| resp.error)
1278 .and_then(|error| error.message)
1279 .unwrap_or_else(|| {
1280 "Codex response failed".to_string()
1281 });
1282 yield Ok(StreamDelta::Error {
1283 message,
1284 kind: StreamErrorKind::ServerError,
1285 });
1286 return;
1287 }
1288 _ => {}
1289 }
1290 }
1291 WebSocketMessage::Binary(bytes) => {
1292 let text = match String::from_utf8(bytes.to_vec()) {
1293 Ok(text) => text,
1294 Err(error) => {
1295 reset_websocket_connection(&mut websocket_session);
1296 yield Ok(StreamDelta::Error {
1297 message: format!(
1298 "invalid OpenAI Codex websocket UTF-8: {error}"
1299 ),
1300 kind: StreamErrorKind::ServerError,
1301 });
1302 return;
1303 }
1304 };
1305 if let Some((status, message)) =
1306 parse_wrapped_websocket_error_event(&text)
1307 {
1308 let kind = if status == StatusCode::TOO_MANY_REQUESTS {
1309 StreamErrorKind::RateLimited
1310 } else if status.is_server_error() {
1311 StreamErrorKind::ServerError
1312 } else {
1313 StreamErrorKind::InvalidRequest
1314 };
1315 if emitted_output {
1316 reset_websocket_connection(&mut websocket_session);
1317 yield Ok(StreamDelta::Error {
1318 message,
1319 kind,
1320 });
1321 return;
1322 }
1323 if status == StatusCode::UNAUTHORIZED
1324 || status == StatusCode::UPGRADE_REQUIRED
1325 || status.is_client_error()
1326 {
1327 websocket_session.websocket_disabled = true;
1328 }
1329 reset_websocket_connection(&mut websocket_session);
1330 continue 'websocket_attempts;
1331 }
1332
1333 let event = match decode_stream_event(&text) {
1334 Ok(event) => event,
1335 Err(error) => {
1336 reset_websocket_connection(
1337 &mut websocket_session,
1338 );
1339 yield Ok(StreamDelta::Error {
1340 message: error.to_string(),
1341 kind: StreamErrorKind::ServerError,
1342 });
1343 return;
1344 }
1345 };
1346 match event.r#type.as_str() {
1347 "response.output_text.delta" => {
1348 if let Some(delta) = event.delta {
1349 emitted_output = true;
1350 yield Ok(StreamDelta::TextDelta {
1351 delta,
1352 block_index: output_block_index(event.output_index),
1353 });
1354 }
1355 }
1356 "response.refusal.delta" => {
1357 refused = true;
1358 if let Some(delta) = event.delta {
1359 emitted_output = true;
1360 yield Ok(StreamDelta::TextDelta {
1361 delta,
1362 block_index: output_block_index(
1363 event.output_index,
1364 ),
1365 });
1366 }
1367 }
1368 "response.reasoning_summary_text.delta" => {
1369 if let Some(delta) = event.delta {
1370 let output_index =
1371 event.output_index.unwrap_or(0);
1372 streamed_reasoning_summaries
1373 .insert(output_index);
1374 emitted_output = true;
1375 yield Ok(StreamDelta::ThinkingDelta {
1376 delta,
1377 block_index:
1378 reasoning_summary_block_index(Some(
1379 output_index,
1380 )),
1381 });
1382 }
1383 }
1384 "response.function_call_arguments.delta" => {
1385 let block_index = event
1386 .output_index
1387 .map(|index| index.saturating_mul(2));
1388 if let (Some(call_id), Some(delta)) =
1389 (event.call_id, event.delta)
1390 {
1391 emitted_output = true;
1392 let order = tool_calls.len();
1393 let acc = tool_calls
1394 .entry(call_id.clone())
1395 .or_insert_with(|| ToolCallAccumulator {
1396 id: call_id,
1397 name: event.name.unwrap_or_default(),
1398 arguments: String::new(),
1399 order,
1400 block_index,
1401 });
1402 acc.arguments.push_str(&delta);
1403 }
1404 }
1405 "response.output_item.done" => {
1406 let item =
1407 match decode_output_item(event.item) {
1408 Ok(item) => item,
1409 Err(error) => {
1410 reset_websocket_connection(
1411 &mut websocket_session,
1412 );
1413 yield Ok(StreamDelta::Error {
1414 message: error.to_string(),
1415 kind: StreamErrorKind::ServerError,
1416 });
1417 return;
1418 }
1419 };
1420 let block_index =
1421 event.output_index.unwrap_or(0);
1422 let include_summary =
1423 !streamed_reasoning_summaries
1424 .contains(&block_index);
1425 for delta in output_item_stream_deltas(
1426 &item,
1427 block_index,
1428 include_summary,
1429 ) {
1430 emitted_output = true;
1431 yield Ok(delta);
1432 }
1433 if let Some(item) =
1434 output_item_to_input_item(item)
1435 {
1436 response_items.push(item);
1437 }
1438 }
1439 "response.completed"
1440 | "response.incomplete"
1441 | "response.done" => {
1442 let response_status = event
1443 .response
1444 .as_ref()
1445 .and_then(|response| response.status);
1446 let incomplete_reason = event
1447 .response
1448 .as_ref()
1449 .and_then(|response| {
1450 response.incomplete_details.as_ref()
1451 })
1452 .and_then(|details| details.reason.clone());
1453 if let Some(resp) = event.response {
1454 if let Some(u) = resp.usage {
1455 usage = Some(usage_from_api_usage(&u));
1456 }
1457 if let Some(id) = resp.id {
1458 response_id = Some(id);
1459 }
1460 }
1461 let final_status =
1462 match event.r#type.as_str() {
1463 "response.incomplete" => {
1464 Some(ApiStatus::Incomplete)
1465 }
1466 "response.done" => response_status
1467 .or(Some(ApiStatus::Completed)),
1468 _ => Some(ApiStatus::Completed),
1469 };
1470 let stop_reason =
1471 stop_reason_from_stream_state(
1472 &tool_calls,
1473 final_status,
1474 refused,
1475 incomplete_reason.as_deref(),
1476 );
1477 if stop_reason
1478 == Some(StopReason::ToolUse)
1479 {
1480 for delta in
1481 emit_accumulated_tool_calls(&tool_calls)
1482 {
1483 yield Ok(delta);
1484 }
1485 }
1486 if let Some(u) = usage.take() {
1487 yield Ok(StreamDelta::Usage(u));
1488 }
1489 websocket_session.last_request =
1490 Some(api_request.clone());
1491 websocket_session.last_response_id = response_id;
1492 websocket_session.last_response_items =
1493 response_items;
1494 websocket_session.prewarmed = false;
1495 websocket_session.in_flight = false;
1499 yield Ok(StreamDelta::Done {
1500 stop_reason,
1501 });
1502 return;
1503 }
1504 "response.failed" => {
1505 websocket_session.last_request = None;
1506 websocket_session.last_response_id = None;
1507 websocket_session.last_response_items.clear();
1508 websocket_session.prewarmed = false;
1509 let message = event
1510 .response
1511 .and_then(|resp| resp.error)
1512 .and_then(|error| error.message)
1513 .unwrap_or_else(|| {
1514 "Codex response failed".to_string()
1515 });
1516 yield Ok(StreamDelta::Error {
1517 message,
1518 kind: StreamErrorKind::ServerError,
1519 });
1520 return;
1521 }
1522 _ => {}
1523 }
1524 }
1525 WebSocketMessage::Ping(payload) => {
1526 if let Some(connection) = websocket_session.connection.as_mut()
1527 && let Err(error) = connection
1528 .send(WebSocketMessage::Pong(payload))
1529 .await
1530 {
1531 if emitted_output {
1532 reset_websocket_connection(&mut websocket_session);
1533 yield Ok(StreamDelta::Error {
1534 message: format!("websocket pong failed: {error}"),
1535 kind: StreamErrorKind::ServerError,
1536 });
1537 return;
1538 }
1539 reset_websocket_connection(&mut websocket_session);
1540 if attempt == 1 {
1541 websocket_session.websocket_disabled = true;
1542 mark_websocket_transport_unhealthy();
1543 }
1544 continue 'websocket_attempts;
1545 }
1546 }
1547 WebSocketMessage::Pong(_) | WebSocketMessage::Frame(_) => {}
1548 WebSocketMessage::Close(_) => {
1549 if emitted_output {
1550 reset_websocket_connection(&mut websocket_session);
1551 yield Ok(StreamDelta::Error {
1552 message: "websocket closed before response.completed"
1553 .to_string(),
1554 kind: StreamErrorKind::ServerError,
1555 });
1556 return;
1557 }
1558 reset_websocket_connection(&mut websocket_session);
1559 if attempt == 1 {
1560 websocket_session.websocket_disabled = true;
1561 mark_websocket_transport_unhealthy();
1562 }
1563 continue 'websocket_attempts;
1564 }
1565 }
1566 }
1567 }
1568 }
1569 websocket_session.in_flight = false;
1573 sse_turn_state = websocket_session.turn_state.clone();
1574 drop(websocket_session);
1575 }
1576
1577 let headers = match self.build_headers(
1578 true,
1579 request.session_id.as_deref(),
1580 sse_turn_state.as_deref(),
1581 ) {
1582 Ok(headers) => headers,
1583 Err(error) => {
1584 yield Ok(StreamDelta::Error {
1585 message: error.to_string(),
1586 kind: StreamErrorKind::InvalidRequest,
1587 });
1588 return;
1589 }
1590 };
1591
1592 let Ok(response) = self.client
1593 .post(codex_url(&self.base_url))
1594 .headers(headers)
1595 .json(&api_request)
1596 .send()
1597 .await
1598 else {
1599 yield Err(anyhow::anyhow!("request failed"));
1600 return;
1601 };
1602
1603 let status = response.status();
1604 if !status.is_success() {
1605 let body = response.text().await.unwrap_or_default();
1606 let kind = if status == StatusCode::TOO_MANY_REQUESTS {
1607 StreamErrorKind::RateLimited
1608 } else if status.is_server_error() {
1609 StreamErrorKind::ServerError
1610 } else {
1611 StreamErrorKind::InvalidRequest
1612 };
1613 log::warn!("OpenAI Codex error status={status} body={body}");
1614 yield Ok(StreamDelta::Error { message: body, kind });
1615 return;
1616 }
1617
1618 if let Some(session_id) = request.session_id.as_deref() {
1619 let turn_state = response
1620 .headers()
1621 .get(OPENAI_CODEX_TURN_STATE_HEADER)
1622 .and_then(|value| value.to_str().ok())
1623 .map(ToOwned::to_owned);
1624 if let Some(turn_state) = turn_state {
1625 let session = self.websocket_session(session_id).await;
1626 let mut websocket_session = session.lock().await;
1627 websocket_session.turn_state = Some(turn_state);
1628 }
1629 }
1630
1631 let mut sse = SseLineBuffer::new();
1632 let mut stream = response.bytes_stream();
1633 let mut usage: Option<Usage> = None;
1634 let mut tool_calls: HashMap<String, ToolCallAccumulator> = HashMap::new();
1635 let mut final_status: Option<ApiStatus> = None;
1636 let mut streamed_reasoning_summaries = HashSet::new();
1637 let mut refused = false;
1638 let mut incomplete_reason: Option<String> = None;
1639
1640 while let Some(chunk_result) = stream.next().await {
1641 let Ok(chunk) = chunk_result else {
1642 yield Err(anyhow::anyhow!("stream error"));
1643 return;
1644 };
1645 sse.extend(&chunk);
1646
1647 while let Some(line) = sse.next_line() {
1648 let line = line.trim();
1649 if line.is_empty() {
1650 continue;
1651 }
1652
1653 let Some(data) = line.strip_prefix("data: ") else {
1654 continue;
1655 };
1656
1657 if data == "[DONE]" {
1658 let Some(stop_reason) =
1659 stop_reason_from_stream_state(
1660 &tool_calls,
1661 final_status,
1662 refused,
1663 incomplete_reason.as_deref(),
1664 )
1665 else {
1666 yield Ok(StreamDelta::Error {
1667 message: "OpenAI Codex stream sent [DONE] before a terminal response event"
1668 .to_owned(),
1669 kind: StreamErrorKind::ServerError,
1670 });
1671 return;
1672 };
1673 if stop_reason == StopReason::ToolUse {
1674 for delta in emit_accumulated_tool_calls(&tool_calls) {
1675 yield Ok(delta);
1676 }
1677 }
1678 if let Some(u) = usage.take() {
1679 yield Ok(StreamDelta::Usage(u));
1680 }
1681 yield Ok(StreamDelta::Done {
1682 stop_reason: Some(stop_reason),
1683 });
1684 return;
1685 }
1686
1687 let event = match decode_stream_event(data) {
1688 Ok(event) => event,
1689 Err(error) => {
1690 yield Ok(StreamDelta::Error {
1691 message: format!(
1692 "invalid OpenAI Codex Responses stream event: {error}"
1693 ),
1694 kind: StreamErrorKind::ServerError,
1695 });
1696 return;
1697 }
1698 };
1699 match event.r#type.as_str() {
1700 "response.output_text.delta" => {
1701 if let Some(delta) = event.delta {
1702 yield Ok(StreamDelta::TextDelta {
1703 delta,
1704 block_index: output_block_index(event.output_index),
1705 });
1706 }
1707 }
1708 "response.refusal.delta" => {
1709 refused = true;
1710 if let Some(delta) = event.delta {
1711 yield Ok(StreamDelta::TextDelta {
1712 delta,
1713 block_index: output_block_index(event.output_index),
1714 });
1715 }
1716 }
1717 "response.reasoning_summary_text.delta" => {
1718 if let Some(delta) = event.delta {
1719 let output_index = event.output_index.unwrap_or(0);
1720 streamed_reasoning_summaries.insert(output_index);
1721 yield Ok(StreamDelta::ThinkingDelta {
1722 delta,
1723 block_index: reasoning_summary_block_index(Some(
1724 output_index,
1725 )),
1726 });
1727 }
1728 }
1729 "response.function_call_arguments.delta" => {
1730 let block_index = event
1731 .output_index
1732 .map(|index| index.saturating_mul(2));
1733 if let (Some(call_id), Some(delta)) = (event.call_id, event.delta) {
1734 let order = tool_calls.len();
1735 let acc = tool_calls.entry(call_id.clone()).or_insert_with(|| {
1736 ToolCallAccumulator {
1737 id: call_id,
1738 name: event.name.unwrap_or_default(),
1739 arguments: String::new(),
1740 order,
1741 block_index,
1742 }
1743 });
1744 acc.arguments.push_str(&delta);
1745 }
1746 }
1747 "response.output_item.done" => {
1748 let item = match decode_output_item(event.item) {
1749 Ok(item) => item,
1750 Err(error) => {
1751 yield Ok(StreamDelta::Error {
1752 message: error.to_string(),
1753 kind: StreamErrorKind::ServerError,
1754 });
1755 return;
1756 }
1757 };
1758 let block_index = event.output_index.unwrap_or(0);
1759 let include_summary =
1760 !streamed_reasoning_summaries.contains(&block_index);
1761 for delta in output_item_stream_deltas(
1762 &item,
1763 block_index,
1764 include_summary,
1765 ) {
1766 yield Ok(delta);
1767 }
1768 }
1769 "response.completed" | "response.incomplete" | "response.done" => {
1770 let response_status = event
1771 .response
1772 .as_ref()
1773 .and_then(|response| response.status);
1774 incomplete_reason = event
1775 .response
1776 .as_ref()
1777 .and_then(|response| response.incomplete_details.as_ref())
1778 .and_then(|details| details.reason.clone());
1779 if let Some(resp) = event.response
1780 && let Some(u) = resp.usage
1781 {
1782 usage = Some(usage_from_api_usage(&u));
1783 }
1784 final_status = match event.r#type.as_str() {
1785 "response.incomplete" => Some(ApiStatus::Incomplete),
1786 "response.done" => {
1787 response_status.or(Some(ApiStatus::Completed))
1788 }
1789 _ => Some(ApiStatus::Completed),
1790 };
1791 }
1792 "response.failed" => {
1793 let message = event
1794 .response
1795 .and_then(|resp| resp.error)
1796 .and_then(|error| error.message)
1797 .unwrap_or_else(|| "Codex response failed".to_string());
1798 yield Ok(StreamDelta::Error {
1799 message,
1800 kind: StreamErrorKind::ServerError,
1801 });
1802 return;
1803 }
1804 _ => {}
1805 }
1806 }
1807 }
1808
1809 let Some(stop_reason) = stop_reason_from_stream_state(
1810 &tool_calls,
1811 final_status,
1812 refused,
1813 incomplete_reason.as_deref(),
1814 ) else {
1815 yield Ok(StreamDelta::Error {
1816 message: "OpenAI Codex stream ended before a terminal response event".to_owned(),
1817 kind: StreamErrorKind::ServerError,
1818 });
1819 return;
1820 };
1821 if stop_reason == StopReason::ToolUse {
1822 for delta in emit_accumulated_tool_calls(&tool_calls) {
1823 yield Ok(delta);
1824 }
1825 }
1826 if let Some(u) = usage {
1827 yield Ok(StreamDelta::Usage(u));
1828 }
1829 yield Ok(StreamDelta::Done {
1830 stop_reason: Some(stop_reason),
1831 });
1832 })
1833 }
1834
1835 fn model(&self) -> &str {
1836 &self.model
1837 }
1838
1839 fn provider(&self) -> &'static str {
1840 "openai-codex"
1841 }
1842
1843 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
1844 self.thinking.as_ref()
1845 }
1846}
1847
1848fn build_api_input(request: &ChatRequest) -> Vec<ApiInputItem> {
1853 let mut items = Vec::new();
1854
1855 for msg in &request.messages {
1858 let role = match msg.role {
1859 agent_sdk_foundation::llm::Role::User => ApiRole::User,
1860 agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
1861 };
1862 match &msg.content {
1863 Content::Text(text) => {
1864 items.push(ApiInputItem::Message(ApiMessage {
1865 role,
1866 content: ApiMessageContent::Text(text.clone()),
1867 phase: api_message_phase(role, false),
1868 }));
1869 }
1870 Content::Blocks(blocks) => append_block_input(&mut items, role, blocks),
1871 }
1872 }
1873
1874 items
1875}
1876
1877fn append_block_input(items: &mut Vec<ApiInputItem>, role: ApiRole, blocks: &[ContentBlock]) {
1878 let mut content_parts = Vec::new();
1879 let mut phase = api_message_phase(
1880 role,
1881 blocks
1882 .iter()
1883 .any(|block| matches!(block, ContentBlock::ToolUse { .. })),
1884 );
1885
1886 for block in blocks {
1887 match block {
1888 ContentBlock::Text { text } => {
1889 let part = if matches!(role, ApiRole::Assistant) {
1890 ApiInputContent::OutputText { text: text.clone() }
1891 } else {
1892 ApiInputContent::InputText { text: text.clone() }
1893 };
1894 content_parts.push(part);
1895 }
1896 ContentBlock::OpaqueReasoning { provider, data }
1897 if matches!(role, ApiRole::Assistant)
1898 && is_message_state_marker(provider, data) =>
1899 {
1900 flush_message_parts(items, role, phase.clone(), &mut content_parts);
1901 phase = data
1902 .get("phase")
1903 .and_then(serde_json::Value::as_str)
1904 .map(ToOwned::to_owned);
1905 }
1906 ContentBlock::OpaqueReasoning { provider, data }
1907 if provider == OPENAI_RESPONSES_REASONING_PROVIDER
1908 && data.get("type").and_then(serde_json::Value::as_str)
1909 == Some("reasoning") =>
1910 {
1911 flush_message_parts(items, role, phase.clone(), &mut content_parts);
1912 items.push(ApiInputItem::OpaqueReasoning(data.clone()));
1913 }
1914 ContentBlock::Thinking { .. }
1915 | ContentBlock::RedactedThinking { .. }
1916 | ContentBlock::OpaqueReasoning { .. } => {}
1917 ContentBlock::Image { source } => content_parts.push(ApiInputContent::Image {
1918 image_url: format!("data:{};base64,{}", source.media_type, source.data),
1919 }),
1920 ContentBlock::Document { source } => content_parts.push(ApiInputContent::File {
1921 filename: suggested_filename(&source.media_type),
1922 file_data: format!("data:{};base64,{}", source.media_type, source.data),
1923 }),
1924 ContentBlock::ToolUse {
1925 id, name, input, ..
1926 } => {
1927 flush_message_parts(items, role, phase.clone(), &mut content_parts);
1928 items.push(ApiInputItem::FunctionCall(ApiFunctionCall::new(
1929 id.clone(),
1930 name.clone(),
1931 serde_json::to_string(input).unwrap_or_default(),
1932 )));
1933 }
1934 ContentBlock::ToolResult {
1935 tool_use_id,
1936 content,
1937 ..
1938 } => {
1939 flush_message_parts(items, role, phase.clone(), &mut content_parts);
1940 items.push(ApiInputItem::FunctionCallOutput(
1941 ApiFunctionCallOutput::new(tool_use_id.clone(), content.clone()),
1942 ));
1943 }
1944 _ => log::warn!("Skipping unrecognized OpenAI Responses content block"),
1945 }
1946 }
1947
1948 flush_message_parts(items, role, phase, &mut content_parts);
1949}
1950
1951fn api_message_phase(role: ApiRole, has_tool_use: bool) -> Option<String> {
1952 match (role, has_tool_use) {
1953 (ApiRole::Assistant, true) => Some("commentary".to_owned()),
1954 (ApiRole::Assistant, false) => Some("final_answer".to_owned()),
1955 (ApiRole::User, _) => None,
1956 }
1957}
1958
1959fn message_state_marker(role: &str, phase: Option<&str>) -> serde_json::Value {
1960 let mut marker = serde_json::Map::new();
1961 marker.insert(
1962 "type".to_owned(),
1963 serde_json::Value::String(OPENAI_MESSAGE_ITEM_TYPE.to_owned()),
1964 );
1965 marker.insert(
1966 "role".to_owned(),
1967 serde_json::Value::String(role.to_owned()),
1968 );
1969 if let Some(phase) = phase {
1970 marker.insert(
1971 "phase".to_owned(),
1972 serde_json::Value::String(phase.to_owned()),
1973 );
1974 }
1975 serde_json::Value::Object(marker)
1976}
1977
1978fn is_message_state_marker(provider: &str, data: &serde_json::Value) -> bool {
1979 provider == OPENAI_RESPONSES_REASONING_PROVIDER
1980 && data.get("type").and_then(serde_json::Value::as_str) == Some(OPENAI_MESSAGE_ITEM_TYPE)
1981 && data.get("content").is_none()
1982}
1983
1984fn flush_message_parts(
1985 items: &mut Vec<ApiInputItem>,
1986 role: ApiRole,
1987 phase: Option<String>,
1988 content_parts: &mut Vec<ApiInputContent>,
1989) {
1990 if content_parts.is_empty() {
1991 return;
1992 }
1993 items.push(ApiInputItem::Message(ApiMessage {
1994 role,
1995 content: ApiMessageContent::Parts(std::mem::take(content_parts)),
1996 phase,
1997 }));
1998}
1999
2000fn fix_schema_for_strict_mode(schema: &mut serde_json::Value) {
2008 let Some(obj) = schema.as_object_mut() else {
2009 return;
2010 };
2011
2012 let is_object_type = obj
2014 .get("type")
2015 .is_some_and(|t| t.as_str() == Some("object"));
2016
2017 if is_object_type {
2018 obj.insert(
2020 "additionalProperties".to_owned(),
2021 serde_json::Value::Bool(false),
2022 );
2023
2024 obj.entry("properties".to_owned())
2026 .or_insert_with(|| serde_json::json!({}));
2027 obj.entry("required".to_owned())
2028 .or_insert_with(|| serde_json::json!([]));
2029
2030 let originally_required: std::collections::HashSet<String> = obj
2032 .get("required")
2033 .and_then(|v| v.as_array())
2034 .map(|arr| {
2035 arr.iter()
2036 .filter_map(|v| v.as_str().map(String::from))
2037 .collect()
2038 })
2039 .unwrap_or_default();
2040
2041 if let Some(serde_json::Value::Object(props)) = obj.get_mut("properties") {
2043 for (key, prop_schema) in props.iter_mut() {
2044 if !originally_required.contains(key) {
2045 make_nullable(prop_schema);
2046 }
2047 }
2048 }
2049
2050 if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
2052 let all_keys: Vec<serde_json::Value> = props
2053 .keys()
2054 .map(|k| serde_json::Value::String(k.clone()))
2055 .collect();
2056 obj.insert("required".to_owned(), serde_json::Value::Array(all_keys));
2057 }
2058 }
2059
2060 if let Some(props) = obj.get_mut("properties")
2062 && let Some(props_obj) = props.as_object_mut()
2063 {
2064 for prop_schema in props_obj.values_mut() {
2065 fix_schema_for_strict_mode(prop_schema);
2066 }
2067 }
2068
2069 if let Some(items) = obj.get_mut("items") {
2071 fix_schema_for_strict_mode(items);
2072 }
2073
2074 for key in ["anyOf", "oneOf", "allOf"] {
2076 if let Some(arr) = obj.get_mut(key)
2077 && let Some(arr_items) = arr.as_array_mut()
2078 {
2079 for item in arr_items {
2080 fix_schema_for_strict_mode(item);
2081 }
2082 }
2083 }
2084}
2085
2086fn make_nullable(schema: &mut serde_json::Value) {
2090 if let Some(any_of) = schema
2091 .as_object_mut()
2092 .and_then(|o| o.get_mut("anyOf"))
2093 .and_then(|v| v.as_array_mut())
2094 {
2095 let has_null = any_of
2096 .iter()
2097 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
2098 if !has_null {
2099 any_of.push(serde_json::json!({"type": "null"}));
2100 }
2101 return;
2102 }
2103
2104 let original = schema.clone();
2105 *schema = serde_json::json!({
2106 "anyOf": [original, {"type": "null"}]
2107 });
2108}
2109
2110fn has_freeform_object(schema: &serde_json::Value) -> bool {
2114 let Some(obj) = schema.as_object() else {
2115 return false;
2116 };
2117
2118 let is_object = obj
2119 .get("type")
2120 .is_some_and(|t| t.as_str() == Some("object"));
2121
2122 if is_object && !obj.contains_key("properties") {
2123 return true;
2124 }
2125
2126 if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
2127 for prop in props.values() {
2128 if has_freeform_object(prop) {
2129 return true;
2130 }
2131 }
2132 }
2133
2134 if let Some(items) = obj.get("items")
2135 && has_freeform_object(items)
2136 {
2137 return true;
2138 }
2139
2140 for key in ["anyOf", "oneOf", "allOf"] {
2141 if let Some(arr) = obj.get(key).and_then(|v| v.as_array()) {
2142 for item in arr {
2143 if has_freeform_object(item) {
2144 return true;
2145 }
2146 }
2147 }
2148 }
2149
2150 false
2151}
2152
2153fn convert_tool(tool: agent_sdk_foundation::llm::Tool) -> ApiTool {
2154 let mut schema = tool.input_schema;
2159 let use_strict = if has_freeform_object(&schema) {
2160 log::debug!(
2161 "Tool '{}' has free-form object schema — disabling strict mode",
2162 tool.name
2163 );
2164 None
2165 } else {
2166 fix_schema_for_strict_mode(&mut schema);
2167 Some(true)
2168 };
2169
2170 ApiTool {
2171 r#type: "function".to_owned(),
2172 name: tool.name,
2173 description: Some(tool.description),
2174 parameters: Some(schema),
2175 strict: use_strict,
2176 }
2177}
2178
2179fn suggested_filename(media_type: &str) -> String {
2180 match media_type {
2181 "application/pdf" => "attachment.pdf".to_string(),
2182 "image/png" => "image.png".to_string(),
2183 "image/jpeg" => "image.jpg".to_string(),
2184 "image/gif" => "image.gif".to_string(),
2185 "image/webp" => "image.webp".to_string(),
2186 _ => "attachment.bin".to_string(),
2187 }
2188}
2189
2190fn reasoning_output_item(fields: &serde_json::Map<String, serde_json::Value>) -> serde_json::Value {
2191 let mut item = fields.clone();
2192 item.insert(
2193 "type".to_owned(),
2194 serde_json::Value::String("reasoning".to_owned()),
2195 );
2196 serde_json::Value::Object(item)
2197}
2198
2199fn reasoning_summary_texts(fields: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
2200 fields
2201 .get("summary")
2202 .and_then(serde_json::Value::as_array)
2203 .into_iter()
2204 .flatten()
2205 .filter(|summary| {
2206 summary.get("type").and_then(serde_json::Value::as_str) == Some("summary_text")
2207 })
2208 .filter_map(|summary| {
2209 summary
2210 .get("text")
2211 .and_then(serde_json::Value::as_str)
2212 .filter(|text| !text.is_empty())
2213 .map(ToOwned::to_owned)
2214 })
2215 .collect()
2216}
2217
2218fn build_content_blocks(output: &[ApiOutputItem]) -> Vec<ContentBlock> {
2219 let mut blocks = Vec::new();
2220
2221 for item in output {
2222 match item {
2223 ApiOutputItem::Message {
2224 role,
2225 phase,
2226 content,
2227 } => {
2228 blocks.push(ContentBlock::OpaqueReasoning {
2229 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2230 data: message_state_marker(role, phase.as_deref()),
2231 });
2232 for c in content {
2233 match c {
2234 ApiOutputContent::Text { text }
2235 | ApiOutputContent::Refusal { refusal: text }
2236 if !text.is_empty() =>
2237 {
2238 blocks.push(ContentBlock::Text { text: text.clone() });
2239 }
2240 ApiOutputContent::Text { .. }
2241 | ApiOutputContent::Refusal { .. }
2242 | ApiOutputContent::Unknown => {}
2243 }
2244 }
2245 }
2246 ApiOutputItem::FunctionCall {
2247 call_id,
2248 name,
2249 arguments,
2250 ..
2251 } => {
2252 let input =
2253 serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
2254 blocks.push(ContentBlock::ToolUse {
2255 id: call_id.clone(),
2256 name: name.clone(),
2257 input,
2258 thought_signature: None,
2259 });
2260 }
2261 ApiOutputItem::Reasoning { fields } => {
2262 blocks.push(ContentBlock::OpaqueReasoning {
2263 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2264 data: reasoning_output_item(fields),
2265 });
2266 blocks.extend(reasoning_summary_texts(fields).into_iter().map(|thinking| {
2267 ContentBlock::Thinking {
2268 thinking,
2269 signature: None,
2270 }
2271 }));
2272 }
2273 ApiOutputItem::Unknown => {
2274 }
2276 }
2277 }
2278
2279 blocks
2280}
2281
2282fn output_contains_refusal(output: &[ApiOutputItem]) -> bool {
2283 output.iter().any(|item| {
2284 matches!(
2285 item,
2286 ApiOutputItem::Message { content, .. }
2287 if content
2288 .iter()
2289 .any(|content| matches!(content, ApiOutputContent::Refusal { .. }))
2290 )
2291 })
2292}
2293
2294fn build_api_reasoning(thinking: Option<&ThinkingConfig>) -> Option<ApiReasoning> {
2295 thinking
2296 .and_then(resolve_reasoning_effort)
2297 .map(|effort| ApiReasoning { effort })
2298}
2299
2300const fn resolve_reasoning_effort(config: &ThinkingConfig) -> Option<ReasoningEffort> {
2301 if let Some(effort) = config.effort {
2302 return Some(map_effort(effort));
2303 }
2304
2305 match &config.mode {
2306 ThinkingMode::Adaptive => None,
2307 ThinkingMode::Enabled { budget_tokens } => Some(map_budget_to_reasoning(*budget_tokens)),
2308 }
2309}
2310
2311const fn map_effort(effort: Effort) -> ReasoningEffort {
2312 match effort {
2313 Effort::Low => ReasoningEffort::Low,
2314 Effort::Medium => ReasoningEffort::Medium,
2315 Effort::High => ReasoningEffort::High,
2316 Effort::Max => ReasoningEffort::XHigh,
2317 }
2318}
2319
2320const fn map_reasoning_effort(effort: ReasoningEffort) -> Effort {
2321 match effort {
2322 ReasoningEffort::Low => Effort::Low,
2323 ReasoningEffort::Medium => Effort::Medium,
2324 ReasoningEffort::High => Effort::High,
2325 ReasoningEffort::XHigh => Effort::Max,
2326 }
2327}
2328
2329const fn map_budget_to_reasoning(budget_tokens: u32) -> ReasoningEffort {
2330 if budget_tokens <= 4_096 {
2331 ReasoningEffort::Low
2332 } else if budget_tokens <= 16_384 {
2333 ReasoningEffort::Medium
2334 } else if budget_tokens <= 32_768 {
2335 ReasoningEffort::High
2336 } else {
2337 ReasoningEffort::XHigh
2338 }
2339}
2340
2341fn codex_url(base_url: &str) -> String {
2342 let normalized = base_url.trim_end_matches('/');
2343 if normalized.ends_with("/codex/responses") {
2344 normalized.to_string()
2345 } else if normalized.ends_with("/codex") {
2346 format!("{normalized}/responses")
2347 } else {
2348 format!("{normalized}/codex/responses")
2349 }
2350}
2351
2352fn codex_websocket_url(base_url: &str) -> Result<url::Url> {
2353 let mut url = url::Url::parse(&codex_url(base_url))
2354 .context("failed to parse OpenAI Codex websocket URL")?;
2355
2356 let scheme = match url.scheme() {
2357 "http" => Some("ws"),
2358 "https" => Some("wss"),
2359 _ => None,
2360 };
2361
2362 if let Some(scheme) = scheme {
2363 let _ = url.set_scheme(scheme);
2364 }
2365
2366 Ok(url)
2367}
2368
2369fn extract_account_id(token: &str) -> Result<String> {
2370 let payload = token
2371 .split('.')
2372 .nth(1)
2373 .ok_or_else(|| anyhow::anyhow!("invalid OpenAI Codex OAuth token"))?;
2374 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
2375 .decode(payload)
2376 .context("failed to decode OpenAI Codex token payload")?;
2377 let payload: serde_json::Value =
2378 serde_json::from_slice(&decoded).context("failed to parse OpenAI Codex token payload")?;
2379 payload
2380 .get(OPENAI_CODEX_JWT_CLAIM_PATH)
2381 .and_then(|value| value.get("chatgpt_account_id"))
2382 .and_then(serde_json::Value::as_str)
2383 .map(ToOwned::to_owned)
2384 .ok_or_else(|| anyhow::anyhow!("chatgpt_account_id missing from OpenAI Codex token"))
2385}
2386
2387fn is_empty(value: &str) -> bool {
2388 value.trim().is_empty()
2389}
2390
2391struct ToolCallAccumulator {
2396 id: String,
2397 name: String,
2398 arguments: String,
2399 order: usize,
2402 block_index: Option<usize>,
2404}
2405
2406fn usage_from_api_usage(usage: &ApiUsage) -> Usage {
2407 Usage {
2408 input_tokens: usage.input_tokens,
2409 output_tokens: usage.output_tokens,
2410 cached_input_tokens: usage
2411 .input_tokens_details
2412 .as_ref()
2413 .map_or(0, |details| details.cached_tokens),
2414 cache_creation_input_tokens: usage
2415 .input_tokens_details
2416 .as_ref()
2417 .map_or(0, |details| details.cache_write_tokens),
2418 }
2419}
2420
2421fn output_block_index(output_index: Option<usize>) -> usize {
2422 output_index.unwrap_or(0).saturating_mul(2)
2423}
2424
2425fn reasoning_summary_block_index(output_index: Option<usize>) -> usize {
2426 output_block_index(output_index).saturating_add(1)
2427}
2428
2429fn decode_stream_event(data: &str) -> Result<ApiStreamEvent> {
2430 serde_json::from_str(data).with_context(|| {
2431 let mut snippet: String = data.chars().take(512).collect();
2435 if data.chars().count() > 512 {
2436 snippet.push('…');
2437 }
2438 format!("invalid OpenAI Codex Responses stream event: {snippet}")
2439 })
2440}
2441
2442fn decode_output_item(item: Option<serde_json::Value>) -> Result<ApiOutputItem> {
2443 let item = item.context("OpenAI Codex output_item.done omitted item")?;
2444 serde_json::from_value(item).context("invalid OpenAI Codex output item")
2445}
2446
2447fn output_item_stream_deltas(
2448 item: &ApiOutputItem,
2449 output_index: usize,
2450 include_summary: bool,
2451) -> Vec<StreamDelta> {
2452 let block_index = output_index.saturating_mul(2);
2453 match item {
2454 ApiOutputItem::Message { role, phase, .. } => {
2455 vec![StreamDelta::OpaqueReasoning {
2456 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2457 data: message_state_marker(role, phase.as_deref()),
2458 block_index,
2459 }]
2460 }
2461 ApiOutputItem::Reasoning { fields } => {
2462 let mut deltas = vec![StreamDelta::OpaqueReasoning {
2463 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
2464 data: reasoning_output_item(fields),
2465 block_index,
2466 }];
2467 if include_summary {
2468 let summary_block_index = block_index.saturating_add(1);
2469 deltas.extend(reasoning_summary_texts(fields).into_iter().map(|delta| {
2470 StreamDelta::ThinkingDelta {
2471 delta,
2472 block_index: summary_block_index,
2473 }
2474 }));
2475 }
2476 deltas
2477 }
2478 ApiOutputItem::FunctionCall { .. } | ApiOutputItem::Unknown => Vec::new(),
2479 }
2480}
2481
2482fn emit_accumulated_tool_calls(
2483 tool_calls: &HashMap<String, ToolCallAccumulator>,
2484) -> Vec<StreamDelta> {
2485 let mut accs: Vec<&ToolCallAccumulator> = tool_calls.values().collect();
2490 accs.sort_by_key(|acc| (acc.block_index.unwrap_or(usize::MAX), acc.order));
2491
2492 let mut deltas = Vec::with_capacity(accs.len() * 2);
2493 for (idx, acc) in accs.iter().enumerate() {
2494 let block_index = acc
2495 .block_index
2496 .unwrap_or_else(|| idx.saturating_add(1).saturating_mul(2));
2497 deltas.push(StreamDelta::ToolUseStart {
2498 id: acc.id.clone(),
2499 name: acc.name.clone(),
2500 block_index,
2501 thought_signature: None,
2502 });
2503 deltas.push(StreamDelta::ToolInputDelta {
2504 id: acc.id.clone(),
2505 delta: acc.arguments.clone(),
2506 block_index,
2507 });
2508 }
2509 deltas
2510}
2511
2512fn stop_reason_from_stream_state(
2513 tool_calls: &HashMap<String, ToolCallAccumulator>,
2514 status: Option<ApiStatus>,
2515 refused: bool,
2516 incomplete_reason: Option<&str>,
2517) -> Option<StopReason> {
2518 let status = status?;
2519 Some(match status {
2520 ApiStatus::Incomplete => {
2521 incomplete_reason.map_or(StopReason::Unknown, incomplete_stop_reason)
2522 }
2523 ApiStatus::Completed if refused => StopReason::Refusal,
2524 ApiStatus::Completed if !tool_calls.is_empty() => StopReason::ToolUse,
2525 ApiStatus::Completed => StopReason::EndTurn,
2526 ApiStatus::Failed
2531 | ApiStatus::InProgress
2532 | ApiStatus::Queued
2533 | ApiStatus::Cancelled
2534 | ApiStatus::Other => StopReason::Unknown,
2535 })
2536}
2537
2538fn incomplete_stop_reason(reason: &str) -> StopReason {
2539 match reason {
2540 "max_output_tokens" => StopReason::MaxTokens,
2541 "content_filter" => StopReason::Refusal,
2542 "model_context_window_exceeded" => StopReason::ModelContextWindowExceeded,
2543 _ => StopReason::Unknown,
2544 }
2545}
2546
2547fn reset_websocket_connection(session: &mut WebsocketSessionState) {
2548 session.connection = None;
2549 if session.prewarmed {
2550 session.last_request = None;
2551 session.last_response_id = None;
2552 session.last_response_items.clear();
2553 }
2554 session.prewarmed = false;
2555}
2556
2557fn evict_idle_sessions(sessions: &mut HashMap<String, Arc<Mutex<WebsocketSessionState>>>) {
2564 let mut candidates: Vec<(String, Option<Instant>)> = Vec::new();
2565 for (key, state) in sessions.iter() {
2566 if let Ok(guard) = state.try_lock()
2567 && !guard.in_flight
2568 {
2569 candidates.push((key.clone(), guard.last_used));
2570 }
2571 }
2572 candidates.sort_by_key(|a| a.1);
2574 let evict_count = candidates.len().min(sessions.len() / 2 + 1);
2575 for (key, _) in candidates.into_iter().take(evict_count) {
2576 sessions.remove(&key);
2577 }
2578}
2579
2580fn parse_wrapped_websocket_error_event(payload: &str) -> Option<(StatusCode, String)> {
2581 let event: ApiWrappedWebsocketErrorEvent = serde_json::from_str(payload).ok()?;
2582 if event.kind != "error" {
2583 return None;
2584 }
2585
2586 if event.error.as_ref().and_then(|error| error.code.as_deref())
2587 == Some(OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE)
2588 {
2589 let message = event
2590 .error
2591 .and_then(|error| error.message)
2592 .unwrap_or_else(|| "Responses websocket connection limit reached".to_string());
2593 return Some((StatusCode::TOO_MANY_REQUESTS, message));
2594 }
2595
2596 let status = StatusCode::from_u16(event.status?).ok()?;
2597 let message = event
2598 .error
2599 .and_then(|error| error.message)
2600 .unwrap_or_else(|| payload.to_string());
2601 if status.is_success() {
2602 None
2603 } else {
2604 Some((status, message))
2605 }
2606}
2607
2608fn output_item_to_input_item(item: ApiOutputItem) -> Option<ApiInputItem> {
2609 match item {
2610 ApiOutputItem::Message {
2611 role,
2612 phase,
2613 content,
2614 } => {
2615 let role = if role == "user" {
2616 ApiRole::User
2617 } else {
2618 ApiRole::Assistant
2619 };
2620 let parts: Vec<ApiInputContent> = content
2621 .into_iter()
2622 .filter_map(|content| match content {
2623 ApiOutputContent::Text { text }
2624 | ApiOutputContent::Refusal { refusal: text }
2625 if !text.is_empty() =>
2626 {
2627 Some(ApiInputContent::OutputText { text })
2628 }
2629 ApiOutputContent::Unknown
2630 | ApiOutputContent::Text { .. }
2631 | ApiOutputContent::Refusal { .. } => None,
2632 })
2633 .collect();
2634 if parts.is_empty() {
2635 None
2636 } else {
2637 Some(ApiInputItem::Message(ApiMessage {
2638 role,
2639 content: ApiMessageContent::Parts(parts),
2640 phase,
2641 }))
2642 }
2643 }
2644 ApiOutputItem::FunctionCall {
2645 call_id,
2646 name,
2647 arguments,
2648 } => Some(ApiInputItem::FunctionCall(ApiFunctionCall::new(
2649 call_id, name, arguments,
2650 ))),
2651 ApiOutputItem::Reasoning { fields } => Some(ApiInputItem::OpaqueReasoning(
2652 reasoning_output_item(&fields),
2653 )),
2654 ApiOutputItem::Unknown => None,
2655 }
2656}
2657
2658fn prepare_websocket_request(
2659 request: &ApiStreamingRequest,
2660 session: &WebsocketSessionState,
2661 allow_empty_delta: bool,
2662) -> ApiWebsocketRequest {
2663 let mut websocket_request = ApiWebsocketRequest::from(request);
2664
2665 let Some(last_request) = session.last_request.as_ref() else {
2666 return websocket_request;
2667 };
2668 let Some(last_response_id) = session.last_response_id.as_ref() else {
2669 return websocket_request;
2670 };
2671
2672 let mut previous_without_input = last_request.clone();
2673 previous_without_input.input.clear();
2674 let mut current_without_input = request.clone();
2675 current_without_input.input.clear();
2676 if previous_without_input != current_without_input {
2677 return websocket_request;
2678 }
2679
2680 let mut baseline = last_request.input.clone();
2681 baseline.extend(session.last_response_items.clone());
2682 if request.input.starts_with(&baseline)
2683 && (allow_empty_delta || baseline.len() < request.input.len())
2684 {
2685 websocket_request.previous_response_id = Some(last_response_id.clone());
2686 websocket_request.input = request.input[baseline.len()..].to_vec();
2687 }
2688
2689 websocket_request
2690}
2691
2692#[derive(Serialize)]
2697struct ApiResponsesRequest<'a> {
2698 model: &'a str,
2699 #[serde(skip_serializing_if = "is_empty")]
2700 instructions: &'a str,
2701 input: &'a [ApiInputItem],
2702 #[serde(skip_serializing_if = "Option::is_none")]
2703 tools: Option<&'a [ApiTool]>,
2704 #[serde(skip_serializing_if = "Option::is_none")]
2705 max_output_tokens: Option<u32>,
2706 #[serde(skip_serializing_if = "Option::is_none")]
2707 reasoning: Option<ApiReasoning>,
2708 #[serde(skip_serializing_if = "Option::is_none")]
2709 tool_choice: Option<ApiToolChoice>,
2710 #[serde(skip_serializing_if = "Option::is_none")]
2711 parallel_tool_calls: Option<bool>,
2712 store: bool,
2713 #[serde(skip_serializing_if = "Option::is_none")]
2714 text: Option<ApiTextSettings>,
2715 #[serde(skip_serializing_if = "Option::is_none")]
2716 include: Option<&'a [&'static str]>,
2717 #[serde(skip_serializing_if = "Option::is_none")]
2718 prompt_cache_key: Option<&'a str>,
2719}
2720
2721#[derive(Clone, PartialEq, Serialize)]
2722struct ApiStreamingRequest {
2723 model: String,
2724 #[serde(skip_serializing_if = "String::is_empty")]
2725 instructions: String,
2726 input: Vec<ApiInputItem>,
2727 #[serde(skip_serializing_if = "Option::is_none")]
2728 tools: Option<Vec<ApiTool>>,
2729 #[serde(skip_serializing_if = "Option::is_none")]
2730 max_output_tokens: Option<u32>,
2731 #[serde(skip_serializing_if = "Option::is_none")]
2732 reasoning: Option<ApiReasoning>,
2733 #[serde(skip_serializing_if = "Option::is_none")]
2734 tool_choice: Option<ApiToolChoice>,
2735 #[serde(skip_serializing_if = "Option::is_none")]
2736 parallel_tool_calls: Option<bool>,
2737 store: bool,
2738 #[serde(skip_serializing_if = "Option::is_none")]
2739 text: Option<ApiTextSettings>,
2740 #[serde(skip_serializing_if = "Option::is_none")]
2741 include: Option<Vec<String>>,
2742 #[serde(skip_serializing_if = "Option::is_none")]
2743 prompt_cache_key: Option<String>,
2744 stream: bool,
2745}
2746
2747#[derive(Clone, Serialize)]
2748struct ApiWebsocketRequest {
2749 #[serde(rename = "type")]
2750 kind: &'static str,
2751 model: String,
2752 #[serde(skip_serializing_if = "String::is_empty")]
2753 instructions: String,
2754 #[serde(skip_serializing_if = "Option::is_none")]
2755 previous_response_id: Option<String>,
2756 input: Vec<ApiInputItem>,
2757 #[serde(skip_serializing_if = "Option::is_none")]
2758 tools: Option<Vec<ApiTool>>,
2759 #[serde(skip_serializing_if = "Option::is_none")]
2760 max_output_tokens: Option<u32>,
2761 #[serde(skip_serializing_if = "Option::is_none")]
2762 reasoning: Option<ApiReasoning>,
2763 #[serde(skip_serializing_if = "Option::is_none")]
2764 tool_choice: Option<ApiToolChoice>,
2765 #[serde(skip_serializing_if = "Option::is_none")]
2766 parallel_tool_calls: Option<bool>,
2767 store: bool,
2768 #[serde(skip_serializing_if = "Option::is_none")]
2769 text: Option<ApiTextSettings>,
2770 #[serde(skip_serializing_if = "Option::is_none")]
2771 include: Option<Vec<String>>,
2772 #[serde(skip_serializing_if = "Option::is_none")]
2773 prompt_cache_key: Option<String>,
2774 stream: bool,
2775 #[serde(skip_serializing_if = "Option::is_none")]
2776 generate: Option<bool>,
2777}
2778
2779impl From<&ApiStreamingRequest> for ApiWebsocketRequest {
2780 fn from(request: &ApiStreamingRequest) -> Self {
2781 Self {
2782 kind: "response.create",
2783 model: request.model.clone(),
2784 instructions: request.instructions.clone(),
2785 previous_response_id: None,
2786 input: request.input.clone(),
2787 tools: request.tools.clone(),
2788 max_output_tokens: request.max_output_tokens,
2789 reasoning: request.reasoning.clone(),
2790 tool_choice: request.tool_choice.clone(),
2791 parallel_tool_calls: request.parallel_tool_calls,
2792 store: request.store,
2793 text: request.text.clone(),
2794 include: request.include.clone(),
2795 prompt_cache_key: request.prompt_cache_key.clone(),
2796 stream: request.stream,
2797 generate: None,
2798 }
2799 }
2800}
2801
2802#[derive(Clone, PartialEq, Serialize)]
2803struct ApiTextSettings {
2804 verbosity: &'static str,
2805 #[serde(skip_serializing_if = "Option::is_none")]
2808 format: Option<ApiResponseTextFormat>,
2809}
2810
2811#[derive(Clone, PartialEq, Serialize)]
2812struct ApiResponseTextFormat {
2813 #[serde(rename = "type")]
2814 format_type: &'static str,
2815 name: String,
2816 schema: serde_json::Value,
2817 strict: bool,
2818}
2819
2820impl From<&ResponseFormat> for ApiResponseTextFormat {
2821 fn from(rf: &ResponseFormat) -> Self {
2822 Self {
2823 format_type: "json_schema",
2824 name: rf.name.clone(),
2825 schema: rf.schema.clone(),
2826 strict: rf.strict,
2827 }
2828 }
2829}
2830
2831#[derive(Clone, PartialEq, Serialize)]
2836#[serde(untagged)]
2837enum ApiToolChoice {
2838 Mode(&'static str),
2839 Function {
2840 #[serde(rename = "type")]
2841 choice_type: &'static str,
2842 name: String,
2843 },
2844}
2845
2846fn codex_tool_choice(tool_choice: Option<&ToolChoice>) -> ApiToolChoice {
2849 match tool_choice {
2850 Some(ToolChoice::Tool(name)) => ApiToolChoice::Function {
2851 choice_type: "function",
2852 name: name.clone(),
2853 },
2854 _ => ApiToolChoice::Mode("auto"),
2855 }
2856}
2857
2858#[derive(Clone, PartialEq, Serialize)]
2859struct ApiReasoning {
2860 effort: ReasoningEffort,
2861}
2862
2863#[derive(Clone, PartialEq, Serialize)]
2864#[serde(untagged)]
2865enum ApiInputItem {
2866 Message(ApiMessage),
2867 FunctionCall(ApiFunctionCall),
2868 FunctionCallOutput(ApiFunctionCallOutput),
2869 OpaqueReasoning(serde_json::Value),
2870}
2871
2872#[derive(Clone, PartialEq, Serialize)]
2873struct ApiMessage {
2874 role: ApiRole,
2875 content: ApiMessageContent,
2876 #[serde(skip_serializing_if = "Option::is_none")]
2877 phase: Option<String>,
2878}
2879
2880#[derive(Clone, Copy, PartialEq, Serialize)]
2881#[serde(rename_all = "lowercase")]
2882enum ApiRole {
2883 User,
2884 Assistant,
2885}
2886
2887#[derive(Clone, PartialEq, Serialize)]
2888#[serde(untagged)]
2889enum ApiMessageContent {
2890 Text(String),
2891 Parts(Vec<ApiInputContent>),
2892}
2893
2894#[derive(Clone, PartialEq, Serialize)]
2895#[serde(tag = "type")]
2896enum ApiInputContent {
2897 #[serde(rename = "input_text")]
2898 InputText { text: String },
2899 #[serde(rename = "output_text")]
2900 OutputText { text: String },
2901 #[serde(rename = "input_image")]
2902 Image { image_url: String },
2903 #[serde(rename = "input_file")]
2904 File { filename: String, file_data: String },
2905}
2906
2907#[derive(Clone, PartialEq, Serialize)]
2908struct ApiFunctionCall {
2909 r#type: &'static str,
2910 call_id: String,
2911 name: String,
2912 arguments: String,
2913}
2914
2915impl ApiFunctionCall {
2916 const fn new(call_id: String, name: String, arguments: String) -> Self {
2917 Self {
2918 r#type: "function_call",
2919 call_id,
2920 name,
2921 arguments,
2922 }
2923 }
2924}
2925
2926#[derive(Clone, PartialEq, Serialize)]
2927struct ApiFunctionCallOutput {
2928 r#type: &'static str,
2929 call_id: String,
2930 output: String,
2931}
2932
2933impl ApiFunctionCallOutput {
2934 const fn new(call_id: String, output: String) -> Self {
2935 Self {
2936 r#type: "function_call_output",
2937 call_id,
2938 output,
2939 }
2940 }
2941}
2942
2943#[derive(Clone, PartialEq, Serialize)]
2944struct ApiTool {
2945 r#type: String,
2946 name: String,
2947 #[serde(skip_serializing_if = "Option::is_none")]
2948 description: Option<String>,
2949 #[serde(skip_serializing_if = "Option::is_none")]
2950 parameters: Option<serde_json::Value>,
2951 #[serde(skip_serializing_if = "Option::is_none")]
2952 strict: Option<bool>,
2953}
2954
2955#[derive(Deserialize)]
2960struct ApiResponse {
2961 id: String,
2962 model: String,
2963 output: Vec<ApiOutputItem>,
2964 #[serde(default)]
2965 status: Option<ApiStatus>,
2966 #[serde(default)]
2967 usage: Option<ApiUsage>,
2968 #[serde(default)]
2969 error: Option<ApiErrorBody>,
2970 #[serde(default)]
2971 incomplete_details: Option<ApiIncompleteDetails>,
2972}
2973
2974#[derive(Deserialize)]
2975struct ApiIncompleteDetails {
2976 #[serde(default)]
2977 reason: Option<String>,
2978}
2979
2980#[derive(Clone, Copy, Deserialize)]
2981#[serde(rename_all = "snake_case")]
2982enum ApiStatus {
2983 Completed,
2984 Incomplete,
2985 Failed,
2986 InProgress,
2991 Queued,
2992 Cancelled,
2993 #[serde(other)]
2996 Other,
2997}
2998
2999#[derive(Deserialize)]
3000struct ApiUsage {
3001 input_tokens: u32,
3002 output_tokens: u32,
3003 #[serde(default)]
3004 input_tokens_details: Option<ApiInputTokensDetails>,
3005}
3006
3007#[derive(Deserialize)]
3008struct ApiInputTokensDetails {
3009 #[serde(default)]
3010 cached_tokens: u32,
3011 #[serde(default)]
3012 cache_write_tokens: u32,
3013}
3014
3015#[derive(Deserialize)]
3016#[serde(tag = "type")]
3017enum ApiOutputItem {
3018 #[serde(rename = "message")]
3019 Message {
3020 role: String,
3021 #[serde(default)]
3022 phase: Option<String>,
3023 content: Vec<ApiOutputContent>,
3024 },
3025 #[serde(rename = "function_call")]
3026 FunctionCall {
3027 call_id: String,
3028 name: String,
3029 arguments: String,
3030 },
3031 #[serde(rename = "reasoning")]
3032 Reasoning {
3033 #[serde(flatten)]
3034 fields: serde_json::Map<String, serde_json::Value>,
3035 },
3036 #[serde(other)]
3037 Unknown,
3038}
3039
3040#[derive(Deserialize)]
3041#[serde(tag = "type")]
3042enum ApiOutputContent {
3043 #[serde(rename = "output_text")]
3044 Text { text: String },
3045 #[serde(rename = "refusal")]
3046 Refusal { refusal: String },
3047 #[serde(other)]
3048 Unknown,
3049}
3050
3051#[derive(Deserialize)]
3056struct ApiStreamEvent {
3057 r#type: String,
3058 #[serde(default)]
3059 output_index: Option<usize>,
3060 #[serde(default)]
3061 delta: Option<String>,
3062 #[serde(default)]
3063 call_id: Option<String>,
3064 #[serde(default)]
3065 name: Option<String>,
3066 #[serde(default)]
3067 item: Option<serde_json::Value>,
3068 #[serde(default)]
3069 response: Option<ApiStreamResponse>,
3070}
3071
3072#[derive(Deserialize)]
3073struct ApiStreamResponse {
3074 #[serde(default)]
3075 id: Option<String>,
3076 #[serde(default)]
3077 usage: Option<ApiUsage>,
3078 #[serde(default)]
3079 error: Option<ApiErrorBody>,
3080 #[serde(default)]
3081 status: Option<ApiStatus>,
3082 #[serde(default)]
3083 incomplete_details: Option<ApiIncompleteDetails>,
3084}
3085
3086#[derive(Deserialize)]
3087struct ApiErrorBody {
3088 #[serde(default)]
3089 message: Option<String>,
3090}
3091
3092#[derive(Deserialize)]
3093struct ApiWrappedWebsocketErrorBody {
3094 #[serde(default)]
3095 code: Option<String>,
3096 #[serde(default)]
3097 message: Option<String>,
3098}
3099
3100#[derive(Deserialize)]
3101struct ApiWrappedWebsocketErrorEvent {
3102 #[serde(rename = "type")]
3103 kind: String,
3104 #[serde(alias = "status_code")]
3105 status: Option<u16>,
3106 #[serde(default)]
3107 error: Option<ApiWrappedWebsocketErrorBody>,
3108}
3109
3110#[cfg(test)]
3115mod tests {
3116 use super::*;
3117
3118 #[test]
3119 fn test_model_constant() {
3120 assert_eq!(MODEL_GPT54, "gpt-5.4");
3121 assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
3122 assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
3123 }
3124
3125 #[test]
3126 fn test_codex_factory() {
3127 let provider = OpenAICodexResponsesProvider::codex("test-key".to_string());
3128 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
3129 assert_eq!(provider.provider(), "openai-codex");
3130 }
3131
3132 #[test]
3133 fn test_gpt54_factory() {
3134 let provider = OpenAICodexResponsesProvider::gpt54("test-key".to_string());
3135 assert_eq!(provider.model(), MODEL_GPT54);
3136 assert_eq!(provider.provider(), "openai-codex");
3137 }
3138
3139 #[test]
3140 fn test_gpt53_codex_factory() {
3141 let provider = OpenAICodexResponsesProvider::gpt53_codex("test-key".to_string());
3142 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
3143 assert_eq!(provider.provider(), "openai-codex");
3144 }
3145
3146 #[test]
3147 fn test_reasoning_effort_serialization() {
3148 let low = serde_json::to_string(&ReasoningEffort::Low).unwrap();
3149 assert_eq!(low, "\"low\"");
3150
3151 let xhigh = serde_json::to_string(&ReasoningEffort::XHigh).unwrap();
3152 assert_eq!(xhigh, "\"xhigh\"");
3153 }
3154
3155 #[test]
3156 fn test_with_reasoning_effort() {
3157 let provider = OpenAICodexResponsesProvider::codex("test-key".to_string())
3158 .with_reasoning_effort(ReasoningEffort::High);
3159 let thinking = provider.thinking.as_ref().unwrap();
3160 assert!(matches!(thinking.effort, Some(Effort::High)));
3161 }
3162
3163 #[test]
3164 fn test_build_api_reasoning_uses_explicit_effort() {
3165 let reasoning =
3166 build_api_reasoning(Some(&ThinkingConfig::adaptive_with_effort(Effort::Low))).unwrap();
3167 assert!(matches!(reasoning.effort, ReasoningEffort::Low));
3168 }
3169
3170 #[test]
3171 fn test_build_api_reasoning_omits_adaptive_without_effort() {
3172 assert!(build_api_reasoning(Some(&ThinkingConfig::adaptive())).is_none());
3173 }
3174
3175 #[test]
3176 fn test_openai_responses_accepts_adaptive_thinking() {
3177 let provider = OpenAICodexResponsesProvider::codex("test-key".to_string());
3178 assert!(
3179 provider
3180 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
3181 .is_ok()
3182 );
3183 }
3184
3185 #[test]
3186 fn test_api_tool_serialization() {
3187 let tool = ApiTool {
3188 r#type: "function".to_owned(),
3189 name: "get_weather".to_owned(),
3190 description: Some("Get weather".to_owned()),
3191 parameters: Some(serde_json::json!({"type": "object"})),
3192 strict: Some(true),
3193 };
3194
3195 let json = serde_json::to_string(&tool).unwrap();
3196 assert!(json.contains("\"type\":\"function\""));
3197 assert!(json.contains("\"name\":\"get_weather\""));
3198 assert!(json.contains("\"strict\":true"));
3199 }
3200
3201 fn test_token() -> String {
3202 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
3203 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(
3204 r#"{{"{OPENAI_CODEX_JWT_CLAIM_PATH}":{{"chatgpt_account_id":"acct_123"}}}}"#
3205 ));
3206 format!("{header}.{payload}.sig")
3207 }
3208
3209 #[test]
3210 fn test_build_headers_match_codex_style_defaults() -> anyhow::Result<()> {
3211 let provider = OpenAICodexResponsesProvider::codex(test_token());
3212
3213 let headers = provider.build_headers(true, Some("session-123"), None)?;
3214 assert_eq!(headers.get("originator").unwrap(), OPENAI_CODEX_ORIGINATOR);
3215 assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123");
3216 assert_eq!(headers.get("session_id").unwrap(), "session-123");
3217 assert_eq!(headers.get("x-client-request-id").unwrap(), "session-123");
3218 assert_eq!(
3219 headers.get("OpenAI-Beta").unwrap(),
3220 OPENAI_CODEX_RESPONSES_BETA_HEADER
3221 );
3222
3223 Ok(())
3224 }
3225
3226 #[test]
3227 fn test_build_websocket_headers_match_codex_style_defaults() -> anyhow::Result<()> {
3228 let provider = OpenAICodexResponsesProvider::codex(test_token());
3229
3230 let headers = provider.build_websocket_headers(Some("session-123"), Some("turn-1"))?;
3231 assert_eq!(headers.get("originator").unwrap(), OPENAI_CODEX_ORIGINATOR);
3232 assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123");
3233 assert_eq!(headers.get("session_id").unwrap(), "session-123");
3234 assert_eq!(headers.get("x-client-request-id").unwrap(), "session-123");
3235 assert_eq!(
3236 headers.get(OPENAI_CODEX_TURN_STATE_HEADER).unwrap(),
3237 "turn-1"
3238 );
3239 assert_eq!(
3240 headers.get("OpenAI-Beta").unwrap(),
3241 OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER,
3242 );
3243
3244 Ok(())
3245 }
3246
3247 #[test]
3248 fn test_build_headers_uses_configured_account_id_without_jwt_decode() -> anyhow::Result<()> {
3249 let provider = OpenAICodexResponsesProvider::codex("not-a-jwt".to_string())
3250 .with_account_id("acct_stored");
3251
3252 let headers = provider.build_headers(true, Some("session-123"), Some("turn-1"))?;
3253 assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_stored");
3254 assert_eq!(
3255 headers.get(OPENAI_CODEX_TURN_STATE_HEADER).unwrap(),
3256 "turn-1"
3257 );
3258
3259 Ok(())
3260 }
3261
3262 #[test]
3263 fn test_request_serialization_includes_store_false() {
3264 let request = ApiStreamingRequest {
3265 model: MODEL_GPT53_CODEX.to_string(),
3266 instructions: "system".to_string(),
3267 input: Vec::new(),
3268 tools: None,
3269 max_output_tokens: None,
3270 reasoning: None,
3271 tool_choice: Some(ApiToolChoice::Mode("auto")),
3272 parallel_tool_calls: Some(true),
3273 store: false,
3274 text: Some(ApiTextSettings {
3275 verbosity: "medium",
3276 format: None,
3277 }),
3278 include: Some(vec!["reasoning.encrypted_content".to_string()]),
3279 prompt_cache_key: Some("session-123".to_string()),
3280 stream: true,
3281 };
3282
3283 let json = serde_json::to_string(&request).unwrap();
3284 assert!(json.contains("\"store\":false"));
3285 assert!(json.contains("\"stream\":true"));
3286 }
3287
3288 #[test]
3289 fn test_prepare_websocket_request_uses_previous_response_id_for_incremental_input() {
3290 let request = ApiStreamingRequest {
3291 model: MODEL_GPT53_CODEX.to_string(),
3292 instructions: "system".to_string(),
3293 input: vec![
3294 ApiInputItem::Message(ApiMessage {
3295 role: ApiRole::User,
3296 content: ApiMessageContent::Text("first".to_string()),
3297 phase: None,
3298 }),
3299 ApiInputItem::Message(ApiMessage {
3300 role: ApiRole::Assistant,
3301 content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
3302 text: "answer".to_string(),
3303 }]),
3304 phase: Some("final_answer".to_owned()),
3305 }),
3306 ApiInputItem::Message(ApiMessage {
3307 role: ApiRole::User,
3308 content: ApiMessageContent::Text("follow up".to_string()),
3309 phase: None,
3310 }),
3311 ],
3312 tools: None,
3313 max_output_tokens: None,
3314 reasoning: None,
3315 tool_choice: Some(ApiToolChoice::Mode("auto")),
3316 parallel_tool_calls: None,
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("thread-1".to_string()),
3324 stream: true,
3325 };
3326 let previous_request = ApiStreamingRequest {
3327 input: vec![ApiInputItem::Message(ApiMessage {
3328 role: ApiRole::User,
3329 content: ApiMessageContent::Text("first".to_string()),
3330 phase: None,
3331 })],
3332 ..request.clone()
3333 };
3334 let session = WebsocketSessionState {
3335 connection: None,
3336 last_request: Some(previous_request),
3337 last_response_id: Some("resp_prev".to_string()),
3338 last_response_items: vec![ApiInputItem::Message(ApiMessage {
3339 role: ApiRole::Assistant,
3340 content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
3341 text: "answer".to_string(),
3342 }]),
3343 phase: Some("final_answer".to_owned()),
3344 })],
3345 turn_state: None,
3346 prewarmed: false,
3347 websocket_disabled: false,
3348 in_flight: false,
3349 last_used: None,
3350 };
3351
3352 let websocket_request = prepare_websocket_request(&request, &session, false);
3353 assert_eq!(
3354 websocket_request.previous_response_id.as_deref(),
3355 Some("resp_prev")
3356 );
3357 assert_eq!(websocket_request.input.len(), 1);
3358 match &websocket_request.input[0] {
3359 ApiInputItem::Message(ApiMessage {
3360 role: ApiRole::User,
3361 content: ApiMessageContent::Text(text),
3362 ..
3363 }) => assert_eq!(text, "follow up"),
3364 _ => panic!("expected incremental follow-up user message"),
3365 }
3366 }
3367
3368 #[test]
3369 fn test_parse_wrapped_websocket_error_event_maps_http_status() {
3370 let payload = r#"{"type":"error","status":401,"error":{"message":"unauthorized"}}"#;
3371 let parsed = parse_wrapped_websocket_error_event(payload);
3372 assert_eq!(
3373 parsed,
3374 Some((StatusCode::UNAUTHORIZED, "unauthorized".to_string())),
3375 );
3376 }
3377
3378 #[test]
3379 fn test_parse_wrapped_websocket_error_event_maps_connection_limit() {
3380 let payload = format!(
3381 r#"{{"type":"error","status":429,"error":{{"code":"{OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE}","message":"limit"}}}}"#,
3382 );
3383 let parsed = parse_wrapped_websocket_error_event(&payload);
3384 assert_eq!(
3385 parsed,
3386 Some((StatusCode::TOO_MANY_REQUESTS, "limit".to_string())),
3387 );
3388 }
3389
3390 #[test]
3391 fn test_prepare_websocket_request_allows_empty_delta_after_prewarm() {
3392 let request = ApiStreamingRequest {
3393 model: MODEL_GPT53_CODEX.to_string(),
3394 instructions: "system".to_string(),
3395 input: vec![ApiInputItem::Message(ApiMessage {
3396 role: ApiRole::User,
3397 content: ApiMessageContent::Text("first".to_string()),
3398 phase: None,
3399 })],
3400 tools: None,
3401 max_output_tokens: None,
3402 reasoning: None,
3403 tool_choice: Some(ApiToolChoice::Mode("auto")),
3404 parallel_tool_calls: None,
3405 store: false,
3406 text: Some(ApiTextSettings {
3407 verbosity: "medium",
3408 format: None,
3409 }),
3410 include: Some(vec!["reasoning.encrypted_content".to_string()]),
3411 prompt_cache_key: Some("thread-1".to_string()),
3412 stream: true,
3413 };
3414 let session = WebsocketSessionState {
3415 connection: None,
3416 last_request: Some(request.clone()),
3417 last_response_id: Some("resp_prewarm".to_string()),
3418 last_response_items: Vec::new(),
3419 turn_state: None,
3420 prewarmed: true,
3421 websocket_disabled: false,
3422 in_flight: false,
3423 last_used: None,
3424 };
3425
3426 let websocket_request = prepare_websocket_request(&request, &session, true);
3427 assert_eq!(
3428 websocket_request.previous_response_id.as_deref(),
3429 Some("resp_prewarm")
3430 );
3431 assert!(websocket_request.input.is_empty());
3432 }
3433
3434 #[test]
3435 fn test_api_response_deserialization() {
3436 let json = r#"{
3437 "id": "resp_123",
3438 "model": "gpt-5.2-codex",
3439 "output": [
3440 {
3441 "type": "message",
3442 "role": "assistant",
3443 "content": [
3444 {"type": "output_text", "text": "Hello!"}
3445 ]
3446 }
3447 ],
3448 "status": "completed",
3449 "usage": {
3450 "input_tokens": 100,
3451 "output_tokens": 50
3452 }
3453 }"#;
3454
3455 let response: ApiResponse = serde_json::from_str(json).unwrap();
3456 assert_eq!(response.id, "resp_123");
3457 assert_eq!(response.model, "gpt-5.2-codex");
3458 assert_eq!(response.output.len(), 1);
3459 }
3460
3461 #[test]
3462 fn test_api_response_with_function_call() {
3463 let json = r#"{
3464 "id": "resp_456",
3465 "model": "gpt-5.2-codex",
3466 "output": [
3467 {
3468 "type": "function_call",
3469 "call_id": "call_abc",
3470 "name": "read_file",
3471 "arguments": "{\"path\": \"test.txt\"}"
3472 }
3473 ],
3474 "status": "completed"
3475 }"#;
3476
3477 let response: ApiResponse = serde_json::from_str(json).unwrap();
3478 assert_eq!(response.output.len(), 1);
3479
3480 match &response.output[0] {
3481 ApiOutputItem::FunctionCall {
3482 call_id,
3483 name,
3484 arguments,
3485 } => {
3486 assert_eq!(call_id, "call_abc");
3487 assert_eq!(name, "read_file");
3488 assert!(arguments.contains("test.txt"));
3489 }
3490 _ => panic!("Expected FunctionCall"),
3491 }
3492 }
3493
3494 #[test]
3495 fn test_build_api_input_uses_responses_text_types_by_role() {
3496 let request = ChatRequest {
3497 system: "system".to_string(),
3498 messages: vec![
3499 agent_sdk_foundation::llm::Message::user_with_content(vec![ContentBlock::Text {
3500 text: "question".to_string(),
3501 }]),
3502 agent_sdk_foundation::llm::Message {
3503 role: agent_sdk_foundation::llm::Role::Assistant,
3504 content: Content::Blocks(vec![ContentBlock::Text {
3505 text: "answer".to_string(),
3506 }]),
3507 },
3508 ],
3509 tools: None,
3510 max_tokens: 512,
3511 max_tokens_explicit: false,
3512 session_id: None,
3513 cached_content: None,
3514 thinking: None,
3515 tool_choice: None,
3516 response_format: None,
3517 cache: None,
3518 };
3519
3520 let input = build_api_input(&request);
3521 assert_eq!(input.len(), 2);
3522
3523 match &input[0] {
3524 ApiInputItem::Message(ApiMessage {
3525 role: ApiRole::User,
3526 content: ApiMessageContent::Parts(parts),
3527 ..
3528 }) => assert!(matches!(
3529 parts.as_slice(),
3530 [ApiInputContent::InputText { text }] if text == "question"
3531 )),
3532 _ => panic!("expected user message with input_text content"),
3533 }
3534
3535 match &input[1] {
3536 ApiInputItem::Message(ApiMessage {
3537 role: ApiRole::Assistant,
3538 content: ApiMessageContent::Parts(parts),
3539 ..
3540 }) => assert!(matches!(
3541 parts.as_slice(),
3542 [ApiInputContent::OutputText { text }] if text == "answer"
3543 )),
3544 _ => panic!("expected assistant message with output_text content"),
3545 }
3546 }
3547
3548 #[test]
3549 fn test_api_input_content_serialization_uses_current_responses_tags() {
3550 let json = serde_json::to_string(&ApiMessageContent::Parts(vec![
3551 ApiInputContent::InputText {
3552 text: "prompt".to_string(),
3553 },
3554 ApiInputContent::OutputText {
3555 text: "reply".to_string(),
3556 },
3557 ApiInputContent::Image {
3558 image_url: "data:image/png;base64,abc".to_string(),
3559 },
3560 ApiInputContent::File {
3561 filename: "notes.txt".to_string(),
3562 file_data: "data:text/plain;base64,abc".to_string(),
3563 },
3564 ]))
3565 .unwrap();
3566
3567 assert!(json.contains("\"type\":\"input_text\""));
3568 assert!(json.contains("\"type\":\"output_text\""));
3569 assert!(json.contains("\"type\":\"input_image\""));
3570 assert!(json.contains("\"type\":\"input_file\""));
3571 }
3572
3573 #[test]
3574 fn test_build_content_blocks_text() {
3575 let output = vec![ApiOutputItem::Message {
3576 role: "assistant".to_owned(),
3577 phase: Some("final_answer".to_owned()),
3578 content: vec![ApiOutputContent::Text {
3579 text: "Hello!".to_owned(),
3580 }],
3581 }];
3582
3583 let blocks = build_content_blocks(&output);
3584 assert_eq!(blocks.len(), 2);
3585 assert!(matches!(
3586 &blocks[0],
3587 ContentBlock::OpaqueReasoning { data, .. }
3588 if data["phase"] == "final_answer"
3589 ));
3590 assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Hello!"));
3591 }
3592
3593 #[test]
3594 fn assistant_message_phases_round_trip_without_duplicate_text() -> anyhow::Result<()> {
3595 let output: Vec<ApiOutputItem> = serde_json::from_value(serde_json::json!([
3596 {
3597 "type": "message",
3598 "role": "assistant",
3599 "phase": "commentary",
3600 "content": [{"type": "output_text", "text": "Working."}]
3601 },
3602 {
3603 "type": "message",
3604 "role": "assistant",
3605 "phase": "final_answer",
3606 "content": [{"type": "output_text", "text": "Done."}]
3607 }
3608 ]))?;
3609 let blocks = build_content_blocks(&output);
3610 let request =
3611 ChatRequest::new(String::new(), vec![Message::assistant_with_content(blocks)]);
3612 let value = serde_json::to_value(build_api_input(&request))?;
3613 let items = value
3614 .as_array()
3615 .context("Codex input must serialize as an array")?;
3616
3617 assert_eq!(items.len(), 2);
3618 assert_eq!(items[0]["phase"], "commentary");
3619 assert_eq!(items[0]["content"][0]["text"], "Working.");
3620 assert_eq!(items[1]["phase"], "final_answer");
3621 assert_eq!(items[1]["content"][0]["text"], "Done.");
3622 assert_eq!(value.to_string().matches("Working.").count(), 1);
3623 assert_eq!(value.to_string().matches("Done.").count(), 1);
3624
3625 let direct: ApiOutputItem = serde_json::from_value(serde_json::json!({
3626 "type": "message",
3627 "role": "assistant",
3628 "phase": "commentary",
3629 "content": [{"type": "output_text", "text": "Working."}]
3630 }))?;
3631 let direct = output_item_to_input_item(direct)
3632 .context("message output should become a continuation input")?;
3633 assert_eq!(serde_json::to_value(direct)?["phase"], "commentary");
3634 Ok(())
3635 }
3636
3637 #[test]
3638 fn streamed_phase_and_reasoning_summary_preserve_block_order() -> anyhow::Result<()> {
3639 let message: ApiOutputItem = serde_json::from_value(serde_json::json!({
3640 "type": "message",
3641 "role": "assistant",
3642 "phase": "commentary",
3643 "content": [{"type": "output_text", "text": "Working."}]
3644 }))?;
3645 let mut accumulator = crate::streaming::StreamAccumulator::new();
3646 accumulator.apply(&StreamDelta::TextDelta {
3647 delta: "Working.".to_owned(),
3648 block_index: output_block_index(Some(0)),
3649 });
3650 for delta in output_item_stream_deltas(&message, 0, true) {
3651 accumulator.apply(&delta);
3652 }
3653 let message_blocks = accumulator.into_content_blocks();
3654 assert!(matches!(
3655 message_blocks.as_slice(),
3656 [ContentBlock::OpaqueReasoning { data, .. }, ContentBlock::Text { text }]
3657 if data["phase"] == "commentary" && text == "Working."
3658 ));
3659
3660 let reasoning: ApiOutputItem = serde_json::from_value(serde_json::json!({
3661 "type": "reasoning",
3662 "id": "rs_1",
3663 "encrypted_content": "ciphertext",
3664 "summary": [{"type": "summary_text", "text": "Checked."}]
3665 }))?;
3666 let mut accumulator = crate::streaming::StreamAccumulator::new();
3667 for delta in output_item_stream_deltas(&reasoning, 1, true) {
3668 accumulator.apply(&delta);
3669 }
3670 let reasoning_blocks = accumulator.into_content_blocks();
3671 assert!(matches!(
3672 reasoning_blocks.as_slice(),
3673 [ContentBlock::OpaqueReasoning { data, .. }, ContentBlock::Thinking { thinking, .. }]
3674 if data["encrypted_content"] == "ciphertext" && thinking == "Checked."
3675 ));
3676 Ok(())
3677 }
3678
3679 #[test]
3680 fn test_build_content_blocks_function_call() {
3681 let output = vec![ApiOutputItem::FunctionCall {
3682 call_id: "call_123".to_owned(),
3683 name: "test_tool".to_owned(),
3684 arguments: r#"{"key": "value"}"#.to_owned(),
3685 }];
3686
3687 let blocks = build_content_blocks(&output);
3688 assert_eq!(blocks.len(), 1);
3689 assert!(
3690 matches!(&blocks[0], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "test_tool")
3691 );
3692 }
3693
3694 #[test]
3695 fn incomplete_and_refusal_responses_suppress_partial_tools() -> anyhow::Result<()> {
3696 let incomplete: ApiResponse = serde_json::from_value(serde_json::json!({
3697 "id": "resp_incomplete",
3698 "model": "gpt-5.3-codex",
3699 "status": "incomplete",
3700 "incomplete_details": {"reason": "model_context_window_exceeded"},
3701 "output": [{
3702 "type": "function_call",
3703 "call_id": "call_partial",
3704 "name": "lookup",
3705 "arguments": "{"
3706 }]
3707 }))?;
3708 let incomplete = OpenAICodexResponsesProvider::map_response(incomplete);
3709 assert_eq!(
3710 incomplete.stop_reason,
3711 Some(StopReason::ModelContextWindowExceeded)
3712 );
3713 assert!(
3714 !incomplete
3715 .content
3716 .iter()
3717 .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
3718 );
3719
3720 let refusal: ApiResponse = serde_json::from_value(serde_json::json!({
3721 "id": "resp_refusal",
3722 "model": "gpt-5.3-codex",
3723 "status": "completed",
3724 "output": [
3725 {
3726 "type": "function_call",
3727 "call_id": "call_partial",
3728 "name": "lookup",
3729 "arguments": "{}"
3730 },
3731 {
3732 "type": "message",
3733 "role": "assistant",
3734 "phase": "final_answer",
3735 "content": [{"type": "refusal", "refusal": "Cannot comply."}]
3736 }
3737 ]
3738 }))?;
3739 let refusal = OpenAICodexResponsesProvider::map_response(refusal);
3740 assert_eq!(refusal.stop_reason, Some(StopReason::Refusal));
3741 assert!(
3742 !refusal
3743 .content
3744 .iter()
3745 .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
3746 );
3747 assert!(matches!(
3748 refusal.content.last(),
3749 Some(ContentBlock::Text { text }) if text == "Cannot comply."
3750 ));
3751 Ok(())
3752 }
3753
3754 #[test]
3755 fn reasoning_output_item_is_preserved_and_summary_is_visible() -> anyhow::Result<()> {
3756 let raw = serde_json::json!({
3757 "type": "reasoning",
3758 "id": "rs_123",
3759 "status": "completed",
3760 "encrypted_content": "ciphertext",
3761 "summary": [
3762 {"type": "summary_text", "text": "Checked the relevant constraints."}
3763 ]
3764 });
3765 let item: ApiOutputItem = serde_json::from_value(raw.clone())?;
3766 let replay_item = output_item_to_input_item(item);
3767 let Some(replay_item) = replay_item else {
3768 anyhow::bail!("reasoning item was not converted to a replay item");
3769 };
3770 assert_eq!(serde_json::to_value(replay_item)?, raw);
3771
3772 let item: ApiOutputItem = serde_json::from_value(raw.clone())?;
3773 let blocks = build_content_blocks(&[item]);
3774 assert_eq!(blocks.len(), 2);
3775 assert!(matches!(
3776 &blocks[0],
3777 ContentBlock::OpaqueReasoning { provider, data, .. }
3778 if provider == OPENAI_RESPONSES_REASONING_PROVIDER && data == &raw
3779 ));
3780 assert!(matches!(
3781 &blocks[1],
3782 ContentBlock::Thinking { thinking, signature, .. }
3783 if thinking == "Checked the relevant constraints." && signature.is_none()
3784 ));
3785 Ok(())
3786 }
3787
3788 #[test]
3789 fn matching_opaque_reasoning_replays_as_a_top_level_item_in_source_order() -> anyhow::Result<()>
3790 {
3791 let raw = serde_json::json!({
3792 "type": "reasoning",
3793 "id": "rs_123",
3794 "encrypted_content": "ciphertext",
3795 "summary": []
3796 });
3797 let request = ChatRequest::new(
3798 "",
3799 vec![agent_sdk_foundation::llm::Message {
3800 role: agent_sdk_foundation::llm::Role::Assistant,
3801 content: Content::Blocks(vec![
3802 ContentBlock::Text {
3803 text: "before".to_owned(),
3804 },
3805 ContentBlock::OpaqueReasoning {
3806 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
3807 data: raw.clone(),
3808 },
3809 ContentBlock::OpaqueReasoning {
3810 provider: "another-provider".to_owned(),
3811 data: serde_json::json!({"type": "reasoning", "id": "ignored"}),
3812 },
3813 ContentBlock::Text {
3814 text: "after".to_owned(),
3815 },
3816 ]),
3817 }],
3818 );
3819
3820 assert_eq!(
3821 serde_json::to_value(build_api_input(&request))?,
3822 serde_json::json!([
3823 {
3824 "role": "assistant",
3825 "phase": "final_answer",
3826 "content": [{"type": "output_text", "text": "before"}]
3827 },
3828 raw,
3829 {
3830 "role": "assistant",
3831 "phase": "final_answer",
3832 "content": [{"type": "output_text", "text": "after"}]
3833 }
3834 ])
3835 );
3836 Ok(())
3837 }
3838
3839 #[test]
3840 fn usage_maps_cache_write_tokens() -> anyhow::Result<()> {
3841 let usage: ApiUsage = serde_json::from_value(serde_json::json!({
3842 "input_tokens": 2048,
3843 "output_tokens": 128,
3844 "input_tokens_details": {
3845 "cached_tokens": 1024,
3846 "cache_write_tokens": 512
3847 }
3848 }))?;
3849
3850 let usage = usage_from_api_usage(&usage);
3851 assert_eq!(usage.cached_input_tokens, 1024);
3852 assert_eq!(usage.cache_creation_input_tokens, 512);
3853 Ok(())
3854 }
3855
3856 #[test]
3857 fn stream_stop_reason_requires_a_semantic_terminal_event() {
3858 let tool_calls = HashMap::new();
3859 assert!(stop_reason_from_stream_state(&tool_calls, None, false, None).is_none());
3860 assert!(matches!(
3861 stop_reason_from_stream_state(&tool_calls, Some(ApiStatus::Completed), false, None,),
3862 Some(StopReason::EndTurn)
3863 ));
3864 assert!(matches!(
3865 stop_reason_from_stream_state(
3866 &tool_calls,
3867 Some(ApiStatus::Incomplete),
3868 false,
3869 Some("max_output_tokens"),
3870 ),
3871 Some(StopReason::MaxTokens)
3872 ));
3873 }
3874
3875 #[test]
3876 fn test_request_serializes_response_format_text_and_forced_tool_choice() {
3877 let request = ApiStreamingRequest {
3878 model: MODEL_GPT53_CODEX.to_string(),
3879 instructions: String::new(),
3880 input: Vec::new(),
3881 tools: None,
3882 max_output_tokens: None,
3883 reasoning: None,
3884 tool_choice: Some(codex_tool_choice(Some(&ToolChoice::Tool(
3885 "respond".to_owned(),
3886 )))),
3887 parallel_tool_calls: None,
3888 store: false,
3889 text: Some(ApiTextSettings {
3890 verbosity: "medium",
3891 format: Some(ApiResponseTextFormat::from(&ResponseFormat::new(
3892 "person",
3893 serde_json::json!({"type": "object"}),
3894 ))),
3895 }),
3896 include: None,
3897 prompt_cache_key: None,
3898 stream: true,
3899 };
3900
3901 let json = serde_json::to_value(&request).unwrap();
3902 assert_eq!(json["text"]["format"]["type"], "json_schema");
3903 assert_eq!(json["text"]["format"]["name"], "person");
3904 assert_eq!(json["text"]["format"]["strict"], true);
3905 assert_eq!(json["tool_choice"]["type"], "function");
3906 assert_eq!(json["tool_choice"]["name"], "respond");
3907 }
3908
3909 #[test]
3910 fn test_codex_tool_choice_defaults_to_auto() {
3911 assert_eq!(
3912 serde_json::to_value(codex_tool_choice(None)).unwrap(),
3913 serde_json::json!("auto")
3914 );
3915 assert_eq!(
3916 serde_json::to_value(codex_tool_choice(Some(&ToolChoice::Auto))).unwrap(),
3917 serde_json::json!("auto")
3918 );
3919 }
3920
3921 #[test]
3922 fn test_convert_tool_makes_optional_params_nullable() {
3923 let tool = agent_sdk_foundation::llm::Tool {
3924 name: "t".to_string(),
3925 description: "d".to_string(),
3926 input_schema: serde_json::json!({
3927 "type": "object",
3928 "properties": {
3929 "req": {"type": "string"},
3930 "opt": {"type": "string"}
3931 },
3932 "required": ["req"]
3933 }),
3934 display_name: "T".to_string(),
3935 tier: agent_sdk_foundation::ToolTier::Observe,
3936 };
3937
3938 let api_tool = convert_tool(tool);
3939 assert_eq!(api_tool.strict, Some(true));
3940 let schema = api_tool.parameters.unwrap();
3941
3942 let required: Vec<&str> = schema["required"]
3943 .as_array()
3944 .unwrap()
3945 .iter()
3946 .filter_map(|v| v.as_str())
3947 .collect();
3948 assert!(required.contains(&"req"));
3949 assert!(required.contains(&"opt"));
3950
3951 let any_of = schema["properties"]["opt"]["anyOf"].as_array().unwrap();
3954 assert!(
3955 any_of
3956 .iter()
3957 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"))
3958 );
3959 }
3960
3961 #[test]
3962 fn test_convert_tool_disables_strict_for_freeform_object() {
3963 let tool = agent_sdk_foundation::llm::Tool {
3964 name: "t".to_string(),
3965 description: "d".to_string(),
3966 input_schema: serde_json::json!({"type": "object"}),
3967 display_name: "T".to_string(),
3968 tier: agent_sdk_foundation::ToolTier::Observe,
3969 };
3970
3971 let api_tool = convert_tool(tool);
3972 assert_eq!(api_tool.strict, None);
3973 }
3974
3975 #[test]
3976 fn test_emit_accumulated_tool_calls_assigns_distinct_ordered_indices() {
3977 let mut tool_calls = HashMap::new();
3978 tool_calls.insert(
3979 "b".to_string(),
3980 ToolCallAccumulator {
3981 id: "b".to_string(),
3982 name: "second".to_string(),
3983 arguments: "{}".to_string(),
3984 order: 1,
3985 block_index: None,
3986 },
3987 );
3988 tool_calls.insert(
3989 "a".to_string(),
3990 ToolCallAccumulator {
3991 id: "a".to_string(),
3992 name: "first".to_string(),
3993 arguments: "{}".to_string(),
3994 order: 0,
3995 block_index: None,
3996 },
3997 );
3998
3999 let deltas = emit_accumulated_tool_calls(&tool_calls);
4000 let starts: Vec<(String, usize)> = deltas
4001 .iter()
4002 .filter_map(|d| match d {
4003 StreamDelta::ToolUseStart {
4004 name, block_index, ..
4005 } => Some((name.clone(), *block_index)),
4006 _ => None,
4007 })
4008 .collect();
4009 assert_eq!(
4010 starts,
4011 vec![("first".to_string(), 2), ("second".to_string(), 4)]
4012 );
4013 }
4014
4015 use crate::provider::LlmProvider;
4020 use agent_sdk_foundation::llm::{ChatRequest, Message};
4021 use std::sync::atomic::AtomicUsize;
4022 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4023 use tokio::net::TcpListener;
4024
4025 fn oauth_token() -> String {
4028 test_token()
4029 }
4030
4031 fn streaming_request(session_id: &str) -> ChatRequest {
4032 ChatRequest::new("You are helpful.", vec![Message::user("hello")])
4033 .with_max_tokens(1024)
4034 .with_session_id(session_id)
4035 }
4036
4037 async fn read_http_head(stream: &mut tokio::net::TcpStream) -> String {
4039 let mut buf = Vec::new();
4040 let mut byte = [0u8; 1];
4041 while stream.read_exact(&mut byte).await.is_ok() {
4042 buf.push(byte[0]);
4043 if buf.ends_with(b"\r\n\r\n") {
4044 break;
4045 }
4046 if buf.len() > 16 * 1024 {
4047 break;
4048 }
4049 }
4050 String::from_utf8_lossy(&buf).into_owned()
4051 }
4052
4053 const HTTP_SSE_BODY: &str = concat!(
4055 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n",
4056 "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\"}}\n\n",
4057 "data: [DONE]\n\n",
4058 );
4059
4060 async fn spawn_http_only_server_with_body(
4064 sse_body: &'static str,
4065 ) -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
4066 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4067 let addr = listener.local_addr().unwrap();
4068 let ws_attempts = Arc::new(AtomicUsize::new(0));
4069 let http_requests = Arc::new(AtomicUsize::new(0));
4070 let ws_attempts_task = ws_attempts.clone();
4071 let http_requests_task = http_requests.clone();
4072
4073 tokio::spawn(async move {
4074 loop {
4075 let Ok((mut stream, _)) = listener.accept().await else {
4076 break;
4077 };
4078 let ws_attempts = ws_attempts_task.clone();
4079 let http_requests = http_requests_task.clone();
4080 tokio::spawn(async move {
4081 let head = read_http_head(&mut stream).await;
4082 if head.to_ascii_lowercase().contains("upgrade: websocket") {
4083 ws_attempts.fetch_add(1, Ordering::Relaxed);
4084 let _ = stream
4088 .write_all(
4089 b"HTTP/1.1 426 Upgrade Required\r\ncontent-length: 0\r\n\r\n",
4090 )
4091 .await;
4092 return;
4093 }
4094 http_requests.fetch_add(1, Ordering::Relaxed);
4095 let response = format!(
4096 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{}",
4097 sse_body.len(),
4098 sse_body,
4099 );
4100 let _ = stream.write_all(response.as_bytes()).await;
4101 });
4102 }
4103 });
4104
4105 (
4106 format!("http://{addr}/backend-api"),
4107 ws_attempts,
4108 http_requests,
4109 )
4110 }
4111
4112 async fn spawn_http_only_server() -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
4113 spawn_http_only_server_with_body(HTTP_SSE_BODY).await
4114 }
4115
4116 async fn drain_ok(provider: &OpenAICodexResponsesProvider, request: ChatRequest) -> bool {
4118 let mut stream = std::pin::pin!(provider.chat_stream(request));
4119 let mut saw_error = false;
4120 while let Some(item) = stream.next().await {
4121 match item {
4122 Ok(StreamDelta::Error { .. }) | Err(_) => saw_error = true,
4123 Ok(_) => {}
4124 }
4125 }
4126 !saw_error
4127 }
4128
4129 #[tokio::test]
4130 async fn websockets_disabled_builder_goes_straight_to_http() {
4131 let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
4132 let provider = OpenAICodexResponsesProvider::with_base_url(
4133 oauth_token(),
4134 MODEL_GPT53_CODEX.to_string(),
4135 base_url,
4136 )
4137 .with_websockets_disabled(true);
4138
4139 assert!(drain_ok(&provider, streaming_request("session-a")).await);
4140
4141 assert_eq!(
4142 ws_attempts.load(Ordering::Relaxed),
4143 0,
4144 "no websocket upgrade may be attempted when websockets are disabled",
4145 );
4146 assert_eq!(http_requests.load(Ordering::Relaxed), 1);
4147 }
4148
4149 #[tokio::test]
4150 async fn premature_http_stream_termination_is_an_error() {
4151 for (case, sse_body) in [
4152 ("done", "data: [DONE]\n\n"),
4153 (
4154 "eof",
4155 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
4156 ),
4157 ] {
4158 let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
4159 let provider = OpenAICodexResponsesProvider::with_base_url(
4160 oauth_token(),
4161 MODEL_GPT53_CODEX.to_string(),
4162 base_url,
4163 )
4164 .with_websockets_disabled(true);
4165 let mut stream = std::pin::pin!(
4166 provider.chat_stream(streaming_request(&format!("premature-{case}")))
4167 );
4168 let mut saw_error = false;
4169 let mut saw_done = false;
4170 while let Some(item) = stream.next().await {
4171 match item {
4172 Ok(StreamDelta::Error { .. }) | Err(_) => saw_error = true,
4173 Ok(StreamDelta::Done { .. }) => saw_done = true,
4174 Ok(_) => {}
4175 }
4176 }
4177 assert!(saw_error, "case={case} must surface a stream error");
4178 assert!(!saw_done, "case={case} must not synthesize success");
4179 }
4180 }
4181
4182 #[tokio::test]
4183 async fn malformed_http_events_and_items_fail_closed() -> anyhow::Result<()> {
4184 for (case, sse_body) in [
4185 ("event", "data: {not-json}\n\n"),
4186 (
4187 "item",
4188 "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\"}}\n\n",
4189 ),
4190 ] {
4191 let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
4192 let provider = OpenAICodexResponsesProvider::with_base_url(
4193 oauth_token(),
4194 MODEL_GPT53_CODEX.to_owned(),
4195 base_url,
4196 )
4197 .with_websockets_disabled(true);
4198 let mut stream = std::pin::pin!(
4199 provider.chat_stream(streaming_request(&format!("malformed-{case}")))
4200 );
4201 let first = stream
4202 .next()
4203 .await
4204 .context("malformed stream must emit an error")??;
4205 assert!(matches!(
4206 first,
4207 StreamDelta::Error {
4208 kind: StreamErrorKind::ServerError,
4209 ..
4210 }
4211 ));
4212 assert!(stream.next().await.is_none());
4213 }
4214 Ok(())
4215 }
4216
4217 #[tokio::test]
4218 async fn non_tool_http_terminals_suppress_partial_tool_calls() -> anyhow::Result<()> {
4219 for (case, sse_body, expected_stop) in [
4220 (
4221 "incomplete",
4222 concat!(
4223 "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"call_id\":\"call_1\",\"name\":\"lookup\",\"delta\":\"{\"}\n\n",
4224 "data: {\"type\":\"response.incomplete\",\"response\":{\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
4225 "data: [DONE]\n\n",
4226 ),
4227 StopReason::MaxTokens,
4228 ),
4229 (
4230 "refusal",
4231 concat!(
4232 "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"call_id\":\"call_1\",\"name\":\"lookup\",\"delta\":\"{}\"}\n\n",
4233 "data: {\"type\":\"response.refusal.delta\",\"output_index\":1,\"delta\":\"Cannot comply.\"}\n\n",
4234 "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n",
4235 "data: [DONE]\n\n",
4236 ),
4237 StopReason::Refusal,
4238 ),
4239 ] {
4240 let (base_url, _, _) = spawn_http_only_server_with_body(sse_body).await;
4241 let provider = OpenAICodexResponsesProvider::with_base_url(
4242 oauth_token(),
4243 MODEL_GPT53_CODEX.to_owned(),
4244 base_url,
4245 )
4246 .with_websockets_disabled(true);
4247 let mut stream = std::pin::pin!(
4248 provider.chat_stream(streaming_request(&format!("terminal-{case}")))
4249 );
4250 let mut deltas = Vec::new();
4251 while let Some(delta) = stream.next().await {
4252 deltas.push(delta?);
4253 }
4254 assert!(matches!(
4255 deltas.last(),
4256 Some(StreamDelta::Done {
4257 stop_reason: Some(stop_reason)
4258 }) if *stop_reason == expected_stop
4259 ));
4260 assert!(!deltas.iter().any(|delta| matches!(
4261 delta,
4262 StreamDelta::ToolUseStart { .. } | StreamDelta::ToolInputDelta { .. }
4263 )));
4264 }
4265 Ok(())
4266 }
4267
4268 #[test]
4269 fn parse_disable_websockets_value_recognizes_truthy_values() {
4270 for value in ["1", "true", "TRUE", " yes ", "on"] {
4271 assert!(
4272 parse_disable_websockets_value(Some(value)),
4273 "value={value:?} should disable websockets",
4274 );
4275 }
4276 for value in ["0", "false", "no", "off", "", "maybe"] {
4277 assert!(
4278 !parse_disable_websockets_value(Some(value)),
4279 "value={value:?} should NOT disable websockets",
4280 );
4281 }
4282 assert!(!parse_disable_websockets_value(None));
4283 }
4284
4285 #[tokio::test]
4286 async fn websockets_disabled_via_env_value_goes_straight_to_http() {
4287 let disabled = parse_disable_websockets_value(Some("1"));
4292 assert!(disabled);
4293
4294 let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
4295 let provider = OpenAICodexResponsesProvider::with_base_url(
4296 oauth_token(),
4297 MODEL_GPT53_CODEX.to_string(),
4298 base_url,
4299 )
4300 .with_websockets_disabled(disabled);
4301
4302 assert!(drain_ok(&provider, streaming_request("session-env")).await);
4303
4304 assert_eq!(
4305 ws_attempts.load(Ordering::Relaxed),
4306 0,
4307 "no websocket upgrade may be attempted when the env var forces HTTP-only",
4308 );
4309 assert_eq!(http_requests.load(Ordering::Relaxed), 1);
4310 }
4311
4312 #[tokio::test]
4313 async fn provider_marked_ws_unhealthy_skips_websocket_on_new_session() {
4314 let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
4315 let provider = OpenAICodexResponsesProvider::with_base_url(
4316 oauth_token(),
4317 MODEL_GPT53_CODEX.to_string(),
4318 base_url,
4319 );
4320
4321 provider.websockets_unhealthy.store(true, Ordering::Relaxed);
4324
4325 assert!(drain_ok(&provider, streaming_request("fresh-session")).await);
4327
4328 assert_eq!(
4329 ws_attempts.load(Ordering::Relaxed),
4330 0,
4331 "a websocket-unhealthy provider must not attempt a new upgrade",
4332 );
4333 assert_eq!(http_requests.load(Ordering::Relaxed), 1);
4334 }
4335
4336 async fn spawn_ws_unauthorized_server() -> (String, Arc<AtomicUsize>) {
4340 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4341 let addr = listener.local_addr().unwrap();
4342 let http_requests = Arc::new(AtomicUsize::new(0));
4343 let http_requests_task = http_requests.clone();
4344
4345 tokio::spawn(async move {
4346 loop {
4347 let Ok((stream, _)) = listener.accept().await else {
4348 break;
4349 };
4350 let http_requests = http_requests_task.clone();
4351 tokio::spawn(async move {
4352 let mut stream = stream;
4353 let mut peek = [0u8; 1024];
4356 let Ok(n) = stream.peek(&mut peek).await else {
4357 return;
4358 };
4359 let head = String::from_utf8_lossy(&peek[..n]).to_ascii_lowercase();
4360
4361 if head.contains("upgrade: websocket") {
4362 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
4365 return;
4366 };
4367 let payload =
4368 r#"{"type":"error","status":401,"error":{"message":"unauthorized"}}"#;
4369 let _ = ws
4370 .send(WebSocketMessage::Text(payload.to_string().into()))
4371 .await;
4372 let _ = ws.send(WebSocketMessage::Close(None)).await;
4373 return;
4374 }
4375
4376 let _ = read_http_head(&mut stream).await;
4378 http_requests.fetch_add(1, Ordering::Relaxed);
4379 let response = format!(
4380 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{}",
4381 HTTP_SSE_BODY.len(),
4382 HTTP_SSE_BODY,
4383 );
4384 let _ = stream.write_all(response.as_bytes()).await;
4385 });
4386 }
4387 });
4388
4389 (format!("http://{addr}/backend-api"), http_requests)
4390 }
4391
4392 #[tokio::test]
4393 async fn ws_unauthorized_disables_session_but_not_provider() {
4394 let (base_url, http_requests) = spawn_ws_unauthorized_server().await;
4395 let provider = OpenAICodexResponsesProvider::with_base_url(
4396 oauth_token(),
4397 MODEL_GPT53_CODEX.to_string(),
4398 base_url,
4399 );
4400
4401 assert!(drain_ok(&provider, streaming_request("auth-session")).await);
4404
4405 assert!(
4409 !provider.websockets_unhealthy.load(Ordering::Relaxed),
4410 "a 401 must not mark the provider websocket-transport-unhealthy",
4411 );
4412 let session = provider.websocket_session("auth-session").await;
4415 assert!(session.lock().await.websocket_disabled);
4416
4417 assert!(http_requests.load(Ordering::Relaxed) >= 1);
4418 }
4419}