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;
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";
89
90pub const MODEL_GPT54: &str = "gpt-5.4";
92
93pub const MODEL_GPT53_CODEX: &str = "gpt-5.3-codex";
95
96pub const MODEL_GPT52_CODEX: &str = "gpt-5.2-codex";
98
99#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
101#[serde(rename_all = "lowercase")]
102pub enum ReasoningEffort {
103 Low,
104 #[default]
105 Medium,
106 High,
107 #[serde(rename = "xhigh")]
109 XHigh,
110}
111
112#[derive(Clone)]
117pub struct OpenAICodexResponsesProvider {
118 client: reqwest::Client,
119 api_key: String,
120 model: String,
121 base_url: String,
122 thinking: Option<ThinkingConfig>,
123 account_id: Option<String>,
124 websocket_sessions: Arc<Mutex<HashMap<String, Arc<Mutex<WebsocketSessionState>>>>>,
125 websockets_disabled: bool,
130 websockets_unhealthy: Arc<AtomicBool>,
142}
143
144type CodexWebSocket = WebSocketStream<MaybeTlsStream<TcpStream>>;
145
146#[derive(Default)]
147struct WebsocketSessionState {
148 connection: Option<CodexWebSocket>,
149 last_request: Option<ApiStreamingRequest>,
150 last_response_id: Option<String>,
151 last_response_items: Vec<ApiInputItem>,
152 turn_state: Option<String>,
153 prewarmed: bool,
154 websocket_disabled: bool,
155 in_flight: bool,
161 last_used: Option<Instant>,
163}
164
165impl OpenAICodexResponsesProvider {
166 #[must_use]
168 pub fn new(api_key: String, model: String) -> Self {
169 Self {
170 client: build_http_client(),
171 api_key,
172 model,
173 base_url: DEFAULT_BASE_URL.to_owned(),
174 thinking: None,
175 account_id: None,
176 websocket_sessions: Arc::new(Mutex::new(HashMap::new())),
177 websockets_disabled: websockets_disabled_from_env(),
178 websockets_unhealthy: Arc::new(AtomicBool::new(false)),
179 }
180 }
181
182 #[must_use]
184 pub fn with_base_url(api_key: String, model: String, base_url: String) -> Self {
185 Self {
186 client: build_http_client(),
187 api_key,
188 model,
189 base_url,
190 thinking: None,
191 account_id: None,
192 websocket_sessions: Arc::new(Mutex::new(HashMap::new())),
193 websockets_disabled: websockets_disabled_from_env(),
194 websockets_unhealthy: Arc::new(AtomicBool::new(false)),
195 }
196 }
197
198 #[must_use]
200 pub fn gpt53_codex(api_key: String) -> Self {
201 Self::new(api_key, MODEL_GPT53_CODEX.to_owned())
202 }
203
204 #[must_use]
206 pub fn codex(api_key: String) -> Self {
207 Self::gpt53_codex(api_key)
208 }
209
210 #[must_use]
212 pub fn gpt54(api_key: String) -> Self {
213 Self::new(api_key, MODEL_GPT54.to_owned())
214 }
215
216 #[must_use]
218 pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
219 self.thinking = Some(thinking);
220 self
221 }
222
223 #[must_use]
225 pub fn with_account_id(mut self, account_id: impl Into<String>) -> Self {
226 self.account_id = Some(account_id.into());
227 self
228 }
229
230 #[must_use]
232 pub fn with_reasoning_effort(self, effort: ReasoningEffort) -> Self {
233 self.with_thinking(ThinkingConfig::default().with_effort(map_reasoning_effort(effort)))
234 }
235
236 #[must_use]
246 pub const fn with_websockets_disabled(mut self, disabled: bool) -> Self {
247 self.websockets_disabled = disabled;
248 self
249 }
250
251 fn skip_websocket(&self) -> bool {
255 self.websockets_disabled || self.websockets_unhealthy.load(Ordering::Relaxed)
256 }
257
258 const fn max_output_tokens(request: &ChatRequest) -> Option<u32> {
259 if request.max_tokens_explicit {
260 Some(request.max_tokens)
261 } else {
262 None
263 }
264 }
265
266 fn build_headers(
267 &self,
268 streaming: bool,
269 session_id: Option<&str>,
270 turn_state: Option<&str>,
271 ) -> Result<reqwest::header::HeaderMap> {
272 self.build_headers_with_beta(
273 streaming,
274 session_id,
275 OPENAI_CODEX_RESPONSES_BETA_HEADER,
276 turn_state,
277 )
278 }
279
280 fn build_websocket_headers(
281 &self,
282 session_id: Option<&str>,
283 turn_state: Option<&str>,
284 ) -> Result<reqwest::header::HeaderMap> {
285 self.build_headers_with_beta(
286 false,
287 session_id,
288 OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER,
289 turn_state,
290 )
291 }
292
293 fn build_headers_with_beta(
294 &self,
295 streaming: bool,
296 session_id: Option<&str>,
297 beta_header: &'static str,
298 turn_state: Option<&str>,
299 ) -> Result<reqwest::header::HeaderMap> {
300 use reqwest::header::{
301 ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT,
302 };
303
304 let account_id = self
305 .account_id
306 .clone()
307 .map_or_else(|| extract_account_id(&self.api_key), Ok)
308 .context("failed to extract chatgpt account id from OpenAI Codex OAuth token")?;
309
310 let mut headers = HeaderMap::new();
311 headers.insert(
312 AUTHORIZATION,
313 HeaderValue::from_str(&format!("Bearer {}", self.api_key))?,
314 );
315 headers.insert("chatgpt-account-id", HeaderValue::from_str(&account_id)?);
316 headers.insert("OpenAI-Beta", HeaderValue::from_static(beta_header));
317 headers.insert(
318 "originator",
319 HeaderValue::from_static(OPENAI_CODEX_ORIGINATOR),
320 );
321 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
322 headers.insert(
323 USER_AGENT,
324 HeaderValue::from_str(&format!(
325 "{OPENAI_CODEX_ORIGINATOR}/{} ({} {})",
326 env!("CARGO_PKG_VERSION"),
327 std::env::consts::OS,
328 std::env::consts::ARCH,
329 ))?,
330 );
331 if streaming {
332 headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
333 }
334 if let Some(session_id) = session_id {
335 let session_id_header = HeaderValue::from_str(session_id)?;
336 headers.insert("session_id", session_id_header.clone());
337 headers.insert("x-client-request-id", session_id_header);
338 }
339 if let Some(turn_state) = turn_state {
340 headers.insert(
341 OPENAI_CODEX_TURN_STATE_HEADER,
342 HeaderValue::from_str(turn_state)?,
343 );
344 }
345
346 Ok(headers)
347 }
348
349 async fn websocket_session(&self, session_id: &str) -> Arc<Mutex<WebsocketSessionState>> {
350 let mut sessions = self.websocket_sessions.lock().await;
351 if !sessions.contains_key(session_id) && sessions.len() >= MAX_WEBSOCKET_SESSIONS {
352 evict_idle_sessions(&mut sessions);
353 }
354 sessions
355 .entry(session_id.to_string())
356 .or_insert_with(|| Arc::new(Mutex::new(WebsocketSessionState::default())))
357 .clone()
358 }
359
360 async fn connect_websocket(
361 &self,
362 session_id: Option<&str>,
363 turn_state: Option<&str>,
364 ) -> Result<(CodexWebSocket, Option<String>)> {
365 let headers = self.build_websocket_headers(session_id, turn_state)?;
366 let url = codex_websocket_url(&self.base_url)
367 .context("failed to build OpenAI Codex websocket URL")?;
368 let mut request = url
369 .as_str()
370 .into_client_request()
371 .context("failed to build OpenAI Codex websocket request")?;
372 request.headers_mut().extend(headers);
373
374 let (stream, response) = timeout(CONNECT_TIMEOUT, connect_async(request))
375 .await
376 .context("OpenAI Codex websocket connect timed out")?
377 .context("failed to connect OpenAI Codex websocket")?;
378 let turn_state = response
379 .headers()
380 .get(OPENAI_CODEX_TURN_STATE_HEADER)
381 .and_then(|value| value.to_str().ok())
382 .map(ToOwned::to_owned);
383 Ok((stream, turn_state))
384 }
385
386 fn map_response(api_response: ApiResponse) -> ChatResponse {
387 let content = build_content_blocks(&api_response.output);
388 let has_tool_calls = content
389 .iter()
390 .any(|block| matches!(block, ContentBlock::ToolUse { .. }));
391 let stop_reason = if has_tool_calls {
392 Some(StopReason::ToolUse)
393 } else {
394 api_response.status.map(|status| match status {
395 ApiStatus::Completed => StopReason::EndTurn,
396 ApiStatus::Incomplete => StopReason::MaxTokens,
397 ApiStatus::Failed => StopReason::StopSequence,
398 })
399 };
400
401 ChatResponse {
402 id: api_response.id,
403 content,
404 model: api_response.model,
405 stop_reason,
406 usage: api_response.usage.map_or(
407 Usage {
408 input_tokens: 0,
409 output_tokens: 0,
410 cached_input_tokens: 0,
411 cache_creation_input_tokens: 0,
412 },
413 |usage| Usage {
414 input_tokens: usage.input_tokens,
415 output_tokens: usage.output_tokens,
416 cached_input_tokens: usage
417 .input_tokens_details
418 .as_ref()
419 .map_or(0, |details| details.cached_tokens),
420 cache_creation_input_tokens: 0,
421 },
422 ),
423 }
424 }
425}
426
427#[async_trait]
428impl LlmProvider for OpenAICodexResponsesProvider {
429 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
430 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
431 Ok(thinking) => thinking,
432 Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
433 };
434 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
435 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
436 }
437 let reasoning = build_api_reasoning(thinking_config.as_ref());
438 let input = build_api_input(&request);
439 let max_output_tokens = Self::max_output_tokens(&request);
440 let prompt_cache_key = request.session_id.as_deref();
441 let tools: Option<Vec<ApiTool>> = request
442 .tools
443 .as_ref()
444 .map(|ts| ts.iter().cloned().map(convert_tool).collect());
445 let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
446 let text_format = request
447 .response_format
448 .as_ref()
449 .map(ApiResponseTextFormat::from);
450 let tool_choice = codex_tool_choice(request.tool_choice.as_ref());
451
452 let api_request = ApiResponsesRequest {
453 model: &self.model,
454 instructions: request.system.as_str(),
455 input: &input,
456 tools: tools.as_deref(),
457 max_output_tokens,
458 reasoning,
459 tool_choice: Some(tool_choice),
460 parallel_tool_calls: parallel_tool_calls.then_some(true),
461 store: false,
462 text: Some(ApiTextSettings {
463 verbosity: "medium",
464 format: text_format,
465 }),
466 include: Some(&["reasoning.encrypted_content"]),
467 prompt_cache_key,
468 };
469
470 log::debug!(
471 "OpenAI Codex request model={} max_tokens={}",
472 self.model,
473 request.max_tokens
474 );
475
476 let response = self
477 .client
478 .post(codex_url(&self.base_url))
479 .headers(self.build_headers(false, request.session_id.as_deref(), None)?)
480 .json(&api_request)
481 .send()
482 .await
483 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
484
485 let status = response.status();
486 let retry_after = if status == StatusCode::TOO_MANY_REQUESTS {
488 crate::http::retry_after_from_headers(response.headers())
489 } else {
490 None
491 };
492 let bytes = response
493 .bytes()
494 .await
495 .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
496
497 log::debug!(
498 "OpenAI Codex response status={} body_len={}",
499 status,
500 bytes.len()
501 );
502
503 if status == StatusCode::TOO_MANY_REQUESTS {
504 return Ok(ChatOutcome::RateLimited(retry_after));
505 }
506
507 if status.is_server_error() {
508 let body = String::from_utf8_lossy(&bytes);
509 log::error!("OpenAI Codex server error status={status} body={body}");
510 return Ok(ChatOutcome::ServerError(body.into_owned()));
511 }
512
513 if status.is_client_error() {
514 let body = String::from_utf8_lossy(&bytes);
515 log::warn!("OpenAI Codex client error status={status} body={body}");
516 return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
517 }
518
519 let api_response: ApiResponse = serde_json::from_slice(&bytes)
520 .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
521
522 if matches!(api_response.status, Some(ApiStatus::Failed)) {
527 let message = api_response
528 .error
529 .and_then(|error| error.message)
530 .unwrap_or_else(|| "OpenAI Codex reported status=failed".to_owned());
531 log::error!("OpenAI Codex generation failed: {message}");
532 return Ok(ChatOutcome::ServerError(message));
533 }
534
535 Ok(ChatOutcome::Success(Self::map_response(api_response)))
536 }
537
538 #[allow(clippy::too_many_lines)]
539 fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
540 Box::pin(async_stream::stream! {
541 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
542 Ok(thinking) => thinking,
543 Err(error) => {
544 yield Ok(StreamDelta::Error {
545 message: error.to_string(),
546 kind: StreamErrorKind::InvalidRequest,
547 });
548 return;
549 }
550 };
551 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
552 yield Ok(StreamDelta::Error {
553 message: error.to_string(),
554 kind: StreamErrorKind::InvalidRequest,
555 });
556 return;
557 }
558
559 let reasoning = build_api_reasoning(thinking_config.as_ref());
560 let input = build_api_input(&request);
561 let max_output_tokens = Self::max_output_tokens(&request);
562 let tools: Option<Vec<ApiTool>> = request
563 .tools
564 .as_ref()
565 .map(|ts| ts.iter().cloned().map(convert_tool).collect());
566 let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
567 let text_format = request.response_format.as_ref().map(ApiResponseTextFormat::from);
568 let tool_choice = codex_tool_choice(request.tool_choice.as_ref());
569 let api_request = ApiStreamingRequest {
570 model: self.model.clone(),
571 instructions: request.system.clone(),
572 input,
573 tools,
574 max_output_tokens,
575 reasoning,
576 tool_choice: Some(tool_choice),
577 parallel_tool_calls: parallel_tool_calls.then_some(true),
578 store: false,
579 text: Some(ApiTextSettings { verbosity: "medium", format: text_format }),
580 include: Some(vec!["reasoning.encrypted_content".to_string()]),
581 prompt_cache_key: request.session_id.clone(),
582 stream: true,
583 };
584
585 log::debug!("OpenAI Codex streaming request model={} max_tokens={}", self.model, request.max_tokens);
586
587 let mut sse_turn_state: Option<String> = None;
588
589 if let Some(session_id) = request.session_id.as_deref().filter(|_| !self.skip_websocket()) {
595 let session = self.websocket_session(session_id).await;
596 let mut websocket_session = session.lock().await;
597
598 let mark_websocket_transport_unhealthy = || {
605 self.websockets_unhealthy.store(true, Ordering::Relaxed);
606 };
607
608 if websocket_session.in_flight {
614 log::warn!(
615 "OpenAI Codex session {session_id} had an abandoned in-flight turn; resetting websocket state"
616 );
617 reset_websocket_connection(&mut websocket_session);
618 websocket_session.last_request = None;
619 websocket_session.last_response_id = None;
620 websocket_session.last_response_items.clear();
621 }
622 websocket_session.in_flight = true;
623 websocket_session.last_used = Some(Instant::now());
624
625 if !websocket_session.websocket_disabled {
626 'websocket_attempts: for attempt in 0..2 {
627 if websocket_session.connection.is_none() {
628 match self
629 .connect_websocket(
630 Some(session_id),
631 websocket_session.turn_state.as_deref(),
632 )
633 .await
634 {
635 Ok((connection, turn_state)) => {
636 websocket_session.connection = Some(connection);
637 if let Some(turn_state) = turn_state {
638 websocket_session.turn_state = Some(turn_state);
639 }
640 websocket_session.prewarmed = false;
641 }
642 Err(error) => {
643 log::warn!(
644 "OpenAI Codex websocket connect failed on attempt {}: {error:#}",
645 attempt + 1,
646 );
647 if attempt == 1 {
648 websocket_session.websocket_disabled = true;
649 mark_websocket_transport_unhealthy();
650 }
651 continue;
652 }
653 }
654 }
655
656 if websocket_session.connection.is_some()
657 && websocket_session.last_request.is_none()
658 && !websocket_session.prewarmed
659 {
660 let mut warmup_request = ApiWebsocketRequest::from(&api_request);
661 warmup_request.generate = Some(false);
662 let warmup_payload = match serde_json::to_string(&warmup_request) {
663 Ok(payload) => payload,
664 Err(error) => {
665 yield Ok(StreamDelta::Error {
666 message: format!(
667 "failed to encode websocket warmup request: {error}"
668 ),
669 kind: StreamErrorKind::InvalidRequest,
670 });
671 return;
672 }
673 };
674
675 let warmup_send_result = if let Some(connection) =
676 websocket_session.connection.as_mut()
677 {
678 timeout(
679 WEBSOCKET_IO_TIMEOUT,
680 connection.send(WebSocketMessage::Text(warmup_payload.into())),
681 )
682 .await
683 .unwrap_or(Err(
684 tokio_tungstenite::tungstenite::Error::ConnectionClosed,
685 ))
686 } else {
687 Err(tokio_tungstenite::tungstenite::Error::ConnectionClosed)
688 };
689
690 if let Err(error) = warmup_send_result {
691 log::warn!(
692 "OpenAI Codex websocket warmup send failed on attempt {}: {error}",
693 attempt + 1,
694 );
695 reset_websocket_connection(&mut websocket_session);
696 if attempt == 1 {
697 websocket_session.websocket_disabled = true;
698 mark_websocket_transport_unhealthy();
699 }
700 continue;
701 }
702
703 let mut warmup_response_id: Option<String> = None;
704 let mut warmup_response_items = Vec::new();
705
706 loop {
707 let message_result = if let Some(connection) =
708 websocket_session.connection.as_mut()
709 {
710 timeout(WEBSOCKET_IO_TIMEOUT, connection.next()).await.unwrap_or_else(|_| {
711 log::warn!("OpenAI Codex websocket warmup read timed out");
712 None
713 })
714 } else {
715 None
716 };
717 let Some(message_result) = message_result else {
718 log::warn!(
719 "OpenAI Codex websocket warmup closed before completion on attempt {}",
720 attempt + 1,
721 );
722 reset_websocket_connection(&mut websocket_session);
723 if attempt == 1 {
724 websocket_session.websocket_disabled = true;
725 mark_websocket_transport_unhealthy();
726 }
727 continue 'websocket_attempts;
728 };
729
730 let message = match message_result {
731 Ok(message) => message,
732 Err(error) => {
733 log::warn!(
734 "OpenAI Codex websocket warmup failed on attempt {}: {error}",
735 attempt + 1,
736 );
737 reset_websocket_connection(&mut websocket_session);
738 if attempt == 1 {
739 websocket_session.websocket_disabled = true;
740 mark_websocket_transport_unhealthy();
741 }
742 continue 'websocket_attempts;
743 }
744 };
745
746 match message {
747 WebSocketMessage::Text(text) => {
748 if let Some((status, message)) =
749 parse_wrapped_websocket_error_event(&text)
750 {
751 log::warn!(
752 "OpenAI Codex websocket warmup wrapped error on attempt {} status={} message={message}",
753 attempt + 1,
754 status,
755 );
756 if status == StatusCode::UNAUTHORIZED
757 || status == StatusCode::UPGRADE_REQUIRED
758 || status.is_client_error()
759 {
760 websocket_session.websocket_disabled = true;
761 }
762 reset_websocket_connection(&mut websocket_session);
763 continue 'websocket_attempts;
764 }
765 if let Ok(event) =
766 serde_json::from_str::<ApiStreamEvent>(&text)
767 {
768 match event.r#type.as_str() {
769 "response.output_item.added" => {
770 if let Some(item) = event.item
771 && let Ok(item) =
772 serde_json::from_value::<ApiOutputItem>(item)
773 && let Some(item) =
774 output_item_to_input_item(item)
775 {
776 warmup_response_items.push(item);
777 }
778 }
779 "response.completed" | "response.done" => {
780 if let Some(resp) = event.response
781 && let Some(id) = resp.id
782 {
783 warmup_response_id = Some(id);
784 }
785 websocket_session.last_request =
786 Some(api_request.clone());
787 websocket_session.last_response_id =
788 warmup_response_id;
789 websocket_session.last_response_items =
790 warmup_response_items;
791 websocket_session.prewarmed = true;
792 break;
793 }
794 "response.incomplete" | "response.failed" => {
795 log::warn!(
796 "OpenAI Codex websocket warmup returned {} on attempt {}",
797 event.r#type,
798 attempt + 1,
799 );
800 reset_websocket_connection(&mut websocket_session);
801 if attempt == 1 {
802 websocket_session.websocket_disabled = true;
803 mark_websocket_transport_unhealthy();
804 }
805 continue 'websocket_attempts;
806 }
807 _ => {}
808 }
809 }
810 }
811 WebSocketMessage::Binary(bytes) => {
812 if let Ok(text) = String::from_utf8(bytes.to_vec()) {
813 if let Some((status, message)) =
814 parse_wrapped_websocket_error_event(&text)
815 {
816 log::warn!(
817 "OpenAI Codex websocket warmup wrapped error on attempt {} status={} message={message}",
818 attempt + 1,
819 status,
820 );
821 if status == StatusCode::UNAUTHORIZED
822 || status == StatusCode::UPGRADE_REQUIRED
823 || status.is_client_error()
824 {
825 websocket_session.websocket_disabled = true;
826 }
827 reset_websocket_connection(&mut websocket_session);
828 continue 'websocket_attempts;
829 }
830
831 if let Ok(event) =
832 serde_json::from_str::<ApiStreamEvent>(&text)
833 {
834 match event.r#type.as_str() {
835 "response.output_item.added" => {
836 if let Some(item) = event.item
837 && let Ok(item) = serde_json::from_value::<
838 ApiOutputItem,
839 >(item)
840 && let Some(item) =
841 output_item_to_input_item(item)
842 {
843 warmup_response_items.push(item);
844 }
845 }
846 "response.completed" | "response.done" => {
847 if let Some(resp) = event.response
848 && let Some(id) = resp.id
849 {
850 warmup_response_id = Some(id);
851 }
852 websocket_session.last_request =
853 Some(api_request.clone());
854 websocket_session.last_response_id =
855 warmup_response_id;
856 websocket_session.last_response_items =
857 warmup_response_items;
858 websocket_session.prewarmed = true;
859 break;
860 }
861 "response.incomplete" | "response.failed" => {
862 log::warn!(
863 "OpenAI Codex websocket warmup returned {} on attempt {}",
864 event.r#type,
865 attempt + 1,
866 );
867 reset_websocket_connection(&mut websocket_session);
868 if attempt == 1 {
869 websocket_session.websocket_disabled = true;
870 mark_websocket_transport_unhealthy();
871 }
872 continue 'websocket_attempts;
873 }
874 _ => {}
875 }
876 }
877 }
878 }
879 WebSocketMessage::Ping(payload) => {
880 if let Some(connection) =
881 websocket_session.connection.as_mut()
882 && let Err(error) = connection
883 .send(WebSocketMessage::Pong(payload))
884 .await
885 {
886 log::warn!(
887 "OpenAI Codex websocket warmup pong failed on attempt {}: {error}",
888 attempt + 1,
889 );
890 reset_websocket_connection(&mut websocket_session);
891 if attempt == 1 {
892 websocket_session.websocket_disabled = true;
893 mark_websocket_transport_unhealthy();
894 }
895 continue 'websocket_attempts;
896 }
897 }
898 WebSocketMessage::Pong(_) | WebSocketMessage::Frame(_) => {}
899 WebSocketMessage::Close(_) => {
900 log::warn!(
901 "OpenAI Codex websocket warmup closed on attempt {}",
902 attempt + 1,
903 );
904 reset_websocket_connection(&mut websocket_session);
905 if attempt == 1 {
906 websocket_session.websocket_disabled = true;
907 mark_websocket_transport_unhealthy();
908 }
909 continue 'websocket_attempts;
910 }
911 }
912 }
913 }
914
915 let websocket_request = prepare_websocket_request(
916 &api_request,
917 &websocket_session,
918 websocket_session.prewarmed,
919 );
920 let request_payload = match serde_json::to_string(&websocket_request) {
921 Ok(payload) => payload,
922 Err(error) => {
923 yield Ok(StreamDelta::Error {
924 message: format!(
925 "failed to encode websocket request: {error}"
926 ),
927 kind: StreamErrorKind::InvalidRequest,
928 });
929 return;
930 }
931 };
932
933 let send_result = if let Some(connection) = websocket_session.connection.as_mut() {
934 timeout(
935 WEBSOCKET_IO_TIMEOUT,
936 connection.send(WebSocketMessage::Text(request_payload.into())),
937 )
938 .await
939 .unwrap_or(Err(
940 tokio_tungstenite::tungstenite::Error::ConnectionClosed,
941 ))
942 } else {
943 Err(tokio_tungstenite::tungstenite::Error::ConnectionClosed)
944 };
945
946 if let Err(error) = send_result {
947 log::warn!(
948 "OpenAI Codex websocket send failed on attempt {}: {error}",
949 attempt + 1,
950 );
951 reset_websocket_connection(&mut websocket_session);
952 if attempt == 1 {
953 websocket_session.websocket_disabled = true;
954 mark_websocket_transport_unhealthy();
955 }
956 continue;
957 }
958
959 let mut usage: Option<Usage> = None;
960 let mut tool_calls: HashMap<String, ToolCallAccumulator> = HashMap::new();
961 let mut response_id: Option<String> = None;
962 let mut response_items = Vec::new();
963 let mut emitted_output = false;
964
965 loop {
966 let message_result = if let Some(connection) =
967 websocket_session.connection.as_mut()
968 {
969 timeout(WEBSOCKET_IO_TIMEOUT, connection.next()).await.unwrap_or_else(|_| {
970 log::warn!("OpenAI Codex websocket read timed out");
971 None
972 })
973 } else {
974 None
975 };
976 let Some(message_result) = message_result else {
977 if emitted_output {
978 reset_websocket_connection(&mut websocket_session);
979 yield Ok(StreamDelta::Error {
980 message: "websocket closed before response.completed"
981 .to_string(),
982 kind: StreamErrorKind::ServerError,
983 });
984 return;
985 }
986 reset_websocket_connection(&mut websocket_session);
987 if attempt == 1 {
988 websocket_session.websocket_disabled = true;
989 mark_websocket_transport_unhealthy();
990 }
991 continue 'websocket_attempts;
992 };
993
994 let message = match message_result {
995 Ok(message) => message,
996 Err(error) => {
997 if emitted_output {
998 reset_websocket_connection(&mut websocket_session);
999 yield Ok(StreamDelta::Error {
1000 message: format!("websocket error: {error}"),
1001 kind: StreamErrorKind::ServerError,
1002 });
1003 return;
1004 }
1005 reset_websocket_connection(&mut websocket_session);
1006 if attempt == 1 {
1007 websocket_session.websocket_disabled = true;
1008 mark_websocket_transport_unhealthy();
1009 }
1010 continue 'websocket_attempts;
1011 }
1012 };
1013
1014 match message {
1015 WebSocketMessage::Text(text) => {
1016 if let Some((status, message)) =
1017 parse_wrapped_websocket_error_event(&text)
1018 {
1019 let kind = if status == StatusCode::TOO_MANY_REQUESTS {
1020 StreamErrorKind::RateLimited
1021 } else if status.is_server_error() {
1022 StreamErrorKind::ServerError
1023 } else {
1024 StreamErrorKind::InvalidRequest
1025 };
1026 if emitted_output {
1027 reset_websocket_connection(&mut websocket_session);
1028 yield Ok(StreamDelta::Error {
1029 message,
1030 kind,
1031 });
1032 return;
1033 }
1034 if status == StatusCode::UNAUTHORIZED
1035 || status == StatusCode::UPGRADE_REQUIRED
1036 || status.is_client_error()
1037 {
1038 websocket_session.websocket_disabled = true;
1039 }
1040 reset_websocket_connection(&mut websocket_session);
1041 continue 'websocket_attempts;
1042 }
1043 if let Ok(event) = serde_json::from_str::<ApiStreamEvent>(&text) {
1044 match event.r#type.as_str() {
1045 "response.output_text.delta" => {
1046 if let Some(delta) = event.delta {
1047 emitted_output = true;
1048 yield Ok(StreamDelta::TextDelta {
1049 delta,
1050 block_index: 0,
1051 });
1052 }
1053 }
1054 "response.function_call_arguments.delta" => {
1055 if let (Some(call_id), Some(delta)) =
1056 (event.call_id, event.delta)
1057 {
1058 let order = tool_calls.len();
1059 let acc = tool_calls
1060 .entry(call_id.clone())
1061 .or_insert_with(|| ToolCallAccumulator {
1062 id: call_id,
1063 name: event.name.unwrap_or_default(),
1064 arguments: String::new(),
1065 order,
1066 });
1067 acc.arguments.push_str(&delta);
1068 }
1069 }
1070 "response.output_item.added" => {
1071 if let Some(item) = event.item
1072 && let Ok(item) =
1073 serde_json::from_value::<ApiOutputItem>(item)
1074 && let Some(item) = output_item_to_input_item(item)
1075 {
1076 response_items.push(item);
1077 }
1078 }
1079 "response.completed"
1080 | "response.incomplete"
1081 | "response.done" => {
1082 if let Some(resp) = event.response {
1083 if let Some(u) = resp.usage {
1084 usage = Some(usage_from_api_usage(&u));
1085 }
1086 if let Some(id) = resp.id {
1087 response_id = Some(id);
1088 }
1089 }
1090 let final_status = Some(match event.r#type.as_str() {
1091 "response.incomplete" => ApiStatus::Incomplete,
1092 _ => ApiStatus::Completed,
1093 });
1094 for delta in emit_accumulated_tool_calls(&tool_calls) {
1095 yield Ok(delta);
1096 }
1097 if let Some(u) = usage.take() {
1098 yield Ok(StreamDelta::Usage(u));
1099 }
1100 websocket_session.last_request = Some(api_request.clone());
1101 websocket_session.last_response_id = response_id;
1102 websocket_session.last_response_items = response_items;
1103 websocket_session.prewarmed = false;
1104 websocket_session.in_flight = false;
1108 yield Ok(StreamDelta::Done {
1109 stop_reason: Some(stop_reason_from_stream_state(
1110 &tool_calls,
1111 final_status,
1112 )),
1113 });
1114 return;
1115 }
1116 "response.failed" => {
1117 websocket_session.last_request = None;
1118 websocket_session.last_response_id = None;
1119 websocket_session.last_response_items.clear();
1120 websocket_session.prewarmed = false;
1121 let message = event
1122 .response
1123 .and_then(|resp| resp.error)
1124 .and_then(|error| error.message)
1125 .unwrap_or_else(|| {
1126 "Codex response failed".to_string()
1127 });
1128 yield Ok(StreamDelta::Error {
1129 message,
1130 kind: StreamErrorKind::ServerError,
1131 });
1132 return;
1133 }
1134 _ => {}
1135 }
1136 }
1137 }
1138 WebSocketMessage::Binary(bytes) => {
1139 if let Ok(text) = String::from_utf8(bytes.to_vec()) {
1140 if let Some((status, message)) =
1141 parse_wrapped_websocket_error_event(&text)
1142 {
1143 let kind = if status == StatusCode::TOO_MANY_REQUESTS {
1144 StreamErrorKind::RateLimited
1145 } else if status.is_server_error() {
1146 StreamErrorKind::ServerError
1147 } else {
1148 StreamErrorKind::InvalidRequest
1149 };
1150 if emitted_output {
1151 reset_websocket_connection(&mut websocket_session);
1152 yield Ok(StreamDelta::Error {
1153 message,
1154 kind,
1155 });
1156 return;
1157 }
1158 if status == StatusCode::UNAUTHORIZED
1159 || status == StatusCode::UPGRADE_REQUIRED
1160 || status.is_client_error()
1161 {
1162 websocket_session.websocket_disabled = true;
1163 }
1164 reset_websocket_connection(&mut websocket_session);
1165 continue 'websocket_attempts;
1166 }
1167
1168 if let Ok(event) =
1169 serde_json::from_str::<ApiStreamEvent>(&text)
1170 {
1171 match event.r#type.as_str() {
1172 "response.output_text.delta" => {
1173 if let Some(delta) = event.delta {
1174 emitted_output = true;
1175 yield Ok(StreamDelta::TextDelta {
1176 delta,
1177 block_index: 0,
1178 });
1179 }
1180 }
1181 "response.function_call_arguments.delta" => {
1182 if let (Some(call_id), Some(delta)) =
1183 (event.call_id, event.delta)
1184 {
1185 let order = tool_calls.len();
1186 let acc = tool_calls
1187 .entry(call_id.clone())
1188 .or_insert_with(|| ToolCallAccumulator {
1189 id: call_id,
1190 name: event.name.unwrap_or_default(),
1191 arguments: String::new(),
1192 order,
1193 });
1194 acc.arguments.push_str(&delta);
1195 }
1196 }
1197 "response.output_item.added" => {
1198 if let Some(item) = event.item
1199 && let Ok(item) = serde_json::from_value::<
1200 ApiOutputItem,
1201 >(item)
1202 && let Some(item) =
1203 output_item_to_input_item(item)
1204 {
1205 response_items.push(item);
1206 }
1207 }
1208 "response.completed"
1209 | "response.incomplete"
1210 | "response.done" => {
1211 if let Some(resp) = event.response {
1212 if let Some(u) = resp.usage {
1213 usage = Some(usage_from_api_usage(&u));
1214 }
1215 if let Some(id) = resp.id {
1216 response_id = Some(id);
1217 }
1218 }
1219 let final_status = Some(
1220 match event.r#type.as_str() {
1221 "response.incomplete" => ApiStatus::Incomplete,
1222 _ => ApiStatus::Completed,
1223 },
1224 );
1225 for delta in
1226 emit_accumulated_tool_calls(&tool_calls)
1227 {
1228 yield Ok(delta);
1229 }
1230 if let Some(u) = usage.take() {
1231 yield Ok(StreamDelta::Usage(u));
1232 }
1233 websocket_session.last_request =
1234 Some(api_request.clone());
1235 websocket_session.last_response_id = response_id;
1236 websocket_session.last_response_items =
1237 response_items;
1238 websocket_session.prewarmed = false;
1239 websocket_session.in_flight = false;
1243 yield Ok(StreamDelta::Done {
1244 stop_reason: Some(
1245 stop_reason_from_stream_state(
1246 &tool_calls,
1247 final_status,
1248 ),
1249 ),
1250 });
1251 return;
1252 }
1253 "response.failed" => {
1254 websocket_session.last_request = None;
1255 websocket_session.last_response_id = None;
1256 websocket_session.last_response_items.clear();
1257 websocket_session.prewarmed = false;
1258 let message = event
1259 .response
1260 .and_then(|resp| resp.error)
1261 .and_then(|error| error.message)
1262 .unwrap_or_else(|| {
1263 "Codex response failed".to_string()
1264 });
1265 yield Ok(StreamDelta::Error {
1266 message,
1267 kind: StreamErrorKind::ServerError,
1268 });
1269 return;
1270 }
1271 _ => {}
1272 }
1273 }
1274 }
1275 }
1276 WebSocketMessage::Ping(payload) => {
1277 if let Some(connection) = websocket_session.connection.as_mut()
1278 && let Err(error) = connection
1279 .send(WebSocketMessage::Pong(payload))
1280 .await
1281 {
1282 if emitted_output {
1283 reset_websocket_connection(&mut websocket_session);
1284 yield Ok(StreamDelta::Error {
1285 message: format!("websocket pong failed: {error}"),
1286 kind: StreamErrorKind::ServerError,
1287 });
1288 return;
1289 }
1290 reset_websocket_connection(&mut websocket_session);
1291 if attempt == 1 {
1292 websocket_session.websocket_disabled = true;
1293 mark_websocket_transport_unhealthy();
1294 }
1295 continue 'websocket_attempts;
1296 }
1297 }
1298 WebSocketMessage::Pong(_) | WebSocketMessage::Frame(_) => {}
1299 WebSocketMessage::Close(_) => {
1300 if emitted_output {
1301 reset_websocket_connection(&mut websocket_session);
1302 yield Ok(StreamDelta::Error {
1303 message: "websocket closed before response.completed"
1304 .to_string(),
1305 kind: StreamErrorKind::ServerError,
1306 });
1307 return;
1308 }
1309 reset_websocket_connection(&mut websocket_session);
1310 if attempt == 1 {
1311 websocket_session.websocket_disabled = true;
1312 mark_websocket_transport_unhealthy();
1313 }
1314 continue 'websocket_attempts;
1315 }
1316 }
1317 }
1318 }
1319 }
1320 websocket_session.in_flight = false;
1324 sse_turn_state = websocket_session.turn_state.clone();
1325 drop(websocket_session);
1326 }
1327
1328 let headers = match self.build_headers(
1329 true,
1330 request.session_id.as_deref(),
1331 sse_turn_state.as_deref(),
1332 ) {
1333 Ok(headers) => headers,
1334 Err(error) => {
1335 yield Ok(StreamDelta::Error {
1336 message: error.to_string(),
1337 kind: StreamErrorKind::InvalidRequest,
1338 });
1339 return;
1340 }
1341 };
1342
1343 let Ok(response) = self.client
1344 .post(codex_url(&self.base_url))
1345 .headers(headers)
1346 .json(&api_request)
1347 .send()
1348 .await
1349 else {
1350 yield Err(anyhow::anyhow!("request failed"));
1351 return;
1352 };
1353
1354 let status = response.status();
1355 if !status.is_success() {
1356 let body = response.text().await.unwrap_or_default();
1357 let kind = if status == StatusCode::TOO_MANY_REQUESTS {
1358 StreamErrorKind::RateLimited
1359 } else if status.is_server_error() {
1360 StreamErrorKind::ServerError
1361 } else {
1362 StreamErrorKind::InvalidRequest
1363 };
1364 log::warn!("OpenAI Codex error status={status} body={body}");
1365 yield Ok(StreamDelta::Error { message: body, kind });
1366 return;
1367 }
1368
1369 if let Some(session_id) = request.session_id.as_deref() {
1370 let turn_state = response
1371 .headers()
1372 .get(OPENAI_CODEX_TURN_STATE_HEADER)
1373 .and_then(|value| value.to_str().ok())
1374 .map(ToOwned::to_owned);
1375 if let Some(turn_state) = turn_state {
1376 let session = self.websocket_session(session_id).await;
1377 let mut websocket_session = session.lock().await;
1378 websocket_session.turn_state = Some(turn_state);
1379 }
1380 }
1381
1382 let mut sse = SseLineBuffer::new();
1383 let mut stream = response.bytes_stream();
1384 let mut usage: Option<Usage> = None;
1385 let mut tool_calls: HashMap<String, ToolCallAccumulator> = HashMap::new();
1386 let mut final_status: Option<ApiStatus> = None;
1387
1388 while let Some(chunk_result) = stream.next().await {
1389 let Ok(chunk) = chunk_result else {
1390 yield Err(anyhow::anyhow!("stream error"));
1391 return;
1392 };
1393 sse.extend(&chunk);
1394
1395 while let Some(line) = sse.next_line() {
1396 let line = line.trim();
1397 if line.is_empty() {
1398 continue;
1399 }
1400
1401 let Some(data) = line.strip_prefix("data: ") else {
1402 continue;
1403 };
1404
1405 if data == "[DONE]" {
1406 for delta in emit_accumulated_tool_calls(&tool_calls) {
1407 yield Ok(delta);
1408 }
1409 if let Some(u) = usage.take() {
1410 yield Ok(StreamDelta::Usage(u));
1411 }
1412 yield Ok(StreamDelta::Done {
1413 stop_reason: Some(stop_reason_from_stream_state(&tool_calls, final_status)),
1414 });
1415 return;
1416 }
1417
1418 if let Ok(event) = serde_json::from_str::<ApiStreamEvent>(data) {
1419 match event.r#type.as_str() {
1420 "response.output_text.delta" => {
1421 if let Some(delta) = event.delta {
1422 yield Ok(StreamDelta::TextDelta { delta, block_index: 0 });
1423 }
1424 }
1425 "response.function_call_arguments.delta" => {
1426 if let (Some(call_id), Some(delta)) = (event.call_id, event.delta) {
1427 let order = tool_calls.len();
1428 let acc = tool_calls.entry(call_id.clone()).or_insert_with(|| {
1429 ToolCallAccumulator {
1430 id: call_id,
1431 name: event.name.unwrap_or_default(),
1432 arguments: String::new(),
1433 order,
1434 }
1435 });
1436 acc.arguments.push_str(&delta);
1437 }
1438 }
1439 "response.completed" | "response.incomplete" | "response.done" => {
1440 if let Some(resp) = event.response
1441 && let Some(u) = resp.usage
1442 {
1443 usage = Some(usage_from_api_usage(&u));
1444 }
1445 final_status = Some(match event.r#type.as_str() {
1446 "response.incomplete" => ApiStatus::Incomplete,
1447 _ => ApiStatus::Completed,
1448 });
1449 }
1450 "response.failed" => {
1451 let message = event
1452 .response
1453 .and_then(|resp| resp.error)
1454 .and_then(|error| error.message)
1455 .unwrap_or_else(|| "Codex response failed".to_string());
1456 yield Ok(StreamDelta::Error {
1457 message,
1458 kind: StreamErrorKind::ServerError,
1459 });
1460 return;
1461 }
1462 _ => {}
1463 }
1464 }
1465 }
1466 }
1467
1468 if let Some(u) = usage {
1469 yield Ok(StreamDelta::Usage(u));
1470 }
1471 yield Ok(StreamDelta::Done {
1472 stop_reason: Some(stop_reason_from_stream_state(&tool_calls, final_status)),
1473 });
1474 })
1475 }
1476
1477 fn model(&self) -> &str {
1478 &self.model
1479 }
1480
1481 fn provider(&self) -> &'static str {
1482 "openai-codex"
1483 }
1484
1485 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
1486 self.thinking.as_ref()
1487 }
1488}
1489
1490fn build_api_input(request: &ChatRequest) -> Vec<ApiInputItem> {
1495 let mut items = Vec::new();
1496
1497 for msg in &request.messages {
1500 match &msg.content {
1501 Content::Text(text) => {
1502 items.push(ApiInputItem::Message(ApiMessage {
1503 role: match msg.role {
1504 agent_sdk_foundation::llm::Role::User => ApiRole::User,
1505 agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
1506 },
1507 content: ApiMessageContent::Text(text.clone()),
1508 }));
1509 }
1510 Content::Blocks(blocks) => {
1511 let mut content_parts = Vec::new();
1512
1513 for block in blocks {
1514 match block {
1515 ContentBlock::Text { text } => {
1516 let part = match msg.role {
1517 agent_sdk_foundation::llm::Role::Assistant => {
1518 ApiInputContent::OutputText { text: text.clone() }
1519 }
1520 agent_sdk_foundation::llm::Role::User => {
1521 ApiInputContent::InputText { text: text.clone() }
1522 }
1523 };
1524 content_parts.push(part);
1525 }
1526 ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {}
1527 ContentBlock::Image { source } => {
1528 content_parts.push(ApiInputContent::Image {
1529 image_url: format!(
1530 "data:{};base64,{}",
1531 source.media_type, source.data
1532 ),
1533 });
1534 }
1535 ContentBlock::Document { source } => {
1536 content_parts.push(ApiInputContent::File {
1537 filename: suggested_filename(&source.media_type),
1538 file_data: format!(
1539 "data:{};base64,{}",
1540 source.media_type, source.data
1541 ),
1542 });
1543 }
1544 ContentBlock::ToolUse {
1545 id, name, input, ..
1546 } => {
1547 items.push(ApiInputItem::FunctionCall(ApiFunctionCall::new(
1548 id.clone(),
1549 name.clone(),
1550 serde_json::to_string(input).unwrap_or_default(),
1551 )));
1552 }
1553 ContentBlock::ToolResult {
1554 tool_use_id,
1555 content,
1556 ..
1557 } => {
1558 items.push(ApiInputItem::FunctionCallOutput(
1559 ApiFunctionCallOutput::new(tool_use_id.clone(), content.clone()),
1560 ));
1561 }
1562 _ => {
1565 log::warn!("Skipping unrecognized OpenAI Responses content block");
1566 }
1567 }
1568 }
1569
1570 if !content_parts.is_empty() {
1571 items.push(ApiInputItem::Message(ApiMessage {
1572 role: match msg.role {
1573 agent_sdk_foundation::llm::Role::User => ApiRole::User,
1574 agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
1575 },
1576 content: ApiMessageContent::Parts(content_parts),
1577 }));
1578 }
1579 }
1580 }
1581 }
1582
1583 items
1584}
1585
1586fn fix_schema_for_strict_mode(schema: &mut serde_json::Value) {
1594 let Some(obj) = schema.as_object_mut() else {
1595 return;
1596 };
1597
1598 let is_object_type = obj
1600 .get("type")
1601 .is_some_and(|t| t.as_str() == Some("object"));
1602
1603 if is_object_type {
1604 obj.insert(
1606 "additionalProperties".to_owned(),
1607 serde_json::Value::Bool(false),
1608 );
1609
1610 obj.entry("properties".to_owned())
1612 .or_insert_with(|| serde_json::json!({}));
1613 obj.entry("required".to_owned())
1614 .or_insert_with(|| serde_json::json!([]));
1615
1616 let originally_required: std::collections::HashSet<String> = obj
1618 .get("required")
1619 .and_then(|v| v.as_array())
1620 .map(|arr| {
1621 arr.iter()
1622 .filter_map(|v| v.as_str().map(String::from))
1623 .collect()
1624 })
1625 .unwrap_or_default();
1626
1627 if let Some(serde_json::Value::Object(props)) = obj.get_mut("properties") {
1629 for (key, prop_schema) in props.iter_mut() {
1630 if !originally_required.contains(key) {
1631 make_nullable(prop_schema);
1632 }
1633 }
1634 }
1635
1636 if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
1638 let all_keys: Vec<serde_json::Value> = props
1639 .keys()
1640 .map(|k| serde_json::Value::String(k.clone()))
1641 .collect();
1642 obj.insert("required".to_owned(), serde_json::Value::Array(all_keys));
1643 }
1644 }
1645
1646 if let Some(props) = obj.get_mut("properties")
1648 && let Some(props_obj) = props.as_object_mut()
1649 {
1650 for prop_schema in props_obj.values_mut() {
1651 fix_schema_for_strict_mode(prop_schema);
1652 }
1653 }
1654
1655 if let Some(items) = obj.get_mut("items") {
1657 fix_schema_for_strict_mode(items);
1658 }
1659
1660 for key in ["anyOf", "oneOf", "allOf"] {
1662 if let Some(arr) = obj.get_mut(key)
1663 && let Some(arr_items) = arr.as_array_mut()
1664 {
1665 for item in arr_items {
1666 fix_schema_for_strict_mode(item);
1667 }
1668 }
1669 }
1670}
1671
1672fn make_nullable(schema: &mut serde_json::Value) {
1676 if let Some(any_of) = schema
1677 .as_object_mut()
1678 .and_then(|o| o.get_mut("anyOf"))
1679 .and_then(|v| v.as_array_mut())
1680 {
1681 let has_null = any_of
1682 .iter()
1683 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
1684 if !has_null {
1685 any_of.push(serde_json::json!({"type": "null"}));
1686 }
1687 return;
1688 }
1689
1690 let original = schema.clone();
1691 *schema = serde_json::json!({
1692 "anyOf": [original, {"type": "null"}]
1693 });
1694}
1695
1696fn has_freeform_object(schema: &serde_json::Value) -> bool {
1700 let Some(obj) = schema.as_object() else {
1701 return false;
1702 };
1703
1704 let is_object = obj
1705 .get("type")
1706 .is_some_and(|t| t.as_str() == Some("object"));
1707
1708 if is_object && !obj.contains_key("properties") {
1709 return true;
1710 }
1711
1712 if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
1713 for prop in props.values() {
1714 if has_freeform_object(prop) {
1715 return true;
1716 }
1717 }
1718 }
1719
1720 if let Some(items) = obj.get("items")
1721 && has_freeform_object(items)
1722 {
1723 return true;
1724 }
1725
1726 for key in ["anyOf", "oneOf", "allOf"] {
1727 if let Some(arr) = obj.get(key).and_then(|v| v.as_array()) {
1728 for item in arr {
1729 if has_freeform_object(item) {
1730 return true;
1731 }
1732 }
1733 }
1734 }
1735
1736 false
1737}
1738
1739fn convert_tool(tool: agent_sdk_foundation::llm::Tool) -> ApiTool {
1740 let mut schema = tool.input_schema;
1745 let use_strict = if has_freeform_object(&schema) {
1746 log::debug!(
1747 "Tool '{}' has free-form object schema — disabling strict mode",
1748 tool.name
1749 );
1750 None
1751 } else {
1752 fix_schema_for_strict_mode(&mut schema);
1753 Some(true)
1754 };
1755
1756 ApiTool {
1757 r#type: "function".to_owned(),
1758 name: tool.name,
1759 description: Some(tool.description),
1760 parameters: Some(schema),
1761 strict: use_strict,
1762 }
1763}
1764
1765fn suggested_filename(media_type: &str) -> String {
1766 match media_type {
1767 "application/pdf" => "attachment.pdf".to_string(),
1768 "image/png" => "image.png".to_string(),
1769 "image/jpeg" => "image.jpg".to_string(),
1770 "image/gif" => "image.gif".to_string(),
1771 "image/webp" => "image.webp".to_string(),
1772 _ => "attachment.bin".to_string(),
1773 }
1774}
1775
1776fn build_content_blocks(output: &[ApiOutputItem]) -> Vec<ContentBlock> {
1777 let mut blocks = Vec::new();
1778
1779 for item in output {
1780 match item {
1781 ApiOutputItem::Message { content, .. } => {
1782 for c in content {
1783 if let ApiOutputContent::Text { text } = c
1784 && !text.is_empty()
1785 {
1786 blocks.push(ContentBlock::Text { text: text.clone() });
1787 }
1788 }
1789 }
1790 ApiOutputItem::FunctionCall {
1791 call_id,
1792 name,
1793 arguments,
1794 ..
1795 } => {
1796 let input =
1797 serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
1798 blocks.push(ContentBlock::ToolUse {
1799 id: call_id.clone(),
1800 name: name.clone(),
1801 input,
1802 thought_signature: None,
1803 });
1804 }
1805 ApiOutputItem::Unknown => {
1806 }
1808 }
1809 }
1810
1811 blocks
1812}
1813
1814fn build_api_reasoning(thinking: Option<&ThinkingConfig>) -> Option<ApiReasoning> {
1815 thinking
1816 .and_then(resolve_reasoning_effort)
1817 .map(|effort| ApiReasoning { effort })
1818}
1819
1820const fn resolve_reasoning_effort(config: &ThinkingConfig) -> Option<ReasoningEffort> {
1821 if let Some(effort) = config.effort {
1822 return Some(map_effort(effort));
1823 }
1824
1825 match &config.mode {
1826 ThinkingMode::Adaptive => None,
1827 ThinkingMode::Enabled { budget_tokens } => Some(map_budget_to_reasoning(*budget_tokens)),
1828 }
1829}
1830
1831const fn map_effort(effort: Effort) -> ReasoningEffort {
1832 match effort {
1833 Effort::Low => ReasoningEffort::Low,
1834 Effort::Medium => ReasoningEffort::Medium,
1835 Effort::High => ReasoningEffort::High,
1836 Effort::Max => ReasoningEffort::XHigh,
1837 }
1838}
1839
1840const fn map_reasoning_effort(effort: ReasoningEffort) -> Effort {
1841 match effort {
1842 ReasoningEffort::Low => Effort::Low,
1843 ReasoningEffort::Medium => Effort::Medium,
1844 ReasoningEffort::High => Effort::High,
1845 ReasoningEffort::XHigh => Effort::Max,
1846 }
1847}
1848
1849const fn map_budget_to_reasoning(budget_tokens: u32) -> ReasoningEffort {
1850 if budget_tokens <= 4_096 {
1851 ReasoningEffort::Low
1852 } else if budget_tokens <= 16_384 {
1853 ReasoningEffort::Medium
1854 } else if budget_tokens <= 32_768 {
1855 ReasoningEffort::High
1856 } else {
1857 ReasoningEffort::XHigh
1858 }
1859}
1860
1861fn codex_url(base_url: &str) -> String {
1862 let normalized = base_url.trim_end_matches('/');
1863 if normalized.ends_with("/codex/responses") {
1864 normalized.to_string()
1865 } else if normalized.ends_with("/codex") {
1866 format!("{normalized}/responses")
1867 } else {
1868 format!("{normalized}/codex/responses")
1869 }
1870}
1871
1872fn codex_websocket_url(base_url: &str) -> Result<url::Url> {
1873 let mut url = url::Url::parse(&codex_url(base_url))
1874 .context("failed to parse OpenAI Codex websocket URL")?;
1875
1876 let scheme = match url.scheme() {
1877 "http" => Some("ws"),
1878 "https" => Some("wss"),
1879 _ => None,
1880 };
1881
1882 if let Some(scheme) = scheme {
1883 let _ = url.set_scheme(scheme);
1884 }
1885
1886 Ok(url)
1887}
1888
1889fn extract_account_id(token: &str) -> Result<String> {
1890 let payload = token
1891 .split('.')
1892 .nth(1)
1893 .ok_or_else(|| anyhow::anyhow!("invalid OpenAI Codex OAuth token"))?;
1894 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
1895 .decode(payload)
1896 .context("failed to decode OpenAI Codex token payload")?;
1897 let payload: serde_json::Value =
1898 serde_json::from_slice(&decoded).context("failed to parse OpenAI Codex token payload")?;
1899 payload
1900 .get(OPENAI_CODEX_JWT_CLAIM_PATH)
1901 .and_then(|value| value.get("chatgpt_account_id"))
1902 .and_then(serde_json::Value::as_str)
1903 .map(ToOwned::to_owned)
1904 .ok_or_else(|| anyhow::anyhow!("chatgpt_account_id missing from OpenAI Codex token"))
1905}
1906
1907fn is_empty(value: &str) -> bool {
1908 value.trim().is_empty()
1909}
1910
1911struct ToolCallAccumulator {
1916 id: String,
1917 name: String,
1918 arguments: String,
1919 order: usize,
1922}
1923
1924fn usage_from_api_usage(usage: &ApiUsage) -> Usage {
1925 Usage {
1926 input_tokens: usage.input_tokens,
1927 output_tokens: usage.output_tokens,
1928 cached_input_tokens: usage
1929 .input_tokens_details
1930 .as_ref()
1931 .map_or(0, |details| details.cached_tokens),
1932 cache_creation_input_tokens: 0,
1933 }
1934}
1935
1936fn emit_accumulated_tool_calls(
1937 tool_calls: &HashMap<String, ToolCallAccumulator>,
1938) -> Vec<StreamDelta> {
1939 let mut accs: Vec<&ToolCallAccumulator> = tool_calls.values().collect();
1944 accs.sort_by_key(|acc| acc.order);
1945
1946 let mut deltas = Vec::with_capacity(accs.len() * 2);
1947 for (idx, acc) in accs.iter().enumerate() {
1948 let block_index = idx + 1;
1949 deltas.push(StreamDelta::ToolUseStart {
1950 id: acc.id.clone(),
1951 name: acc.name.clone(),
1952 block_index,
1953 thought_signature: None,
1954 });
1955 deltas.push(StreamDelta::ToolInputDelta {
1956 id: acc.id.clone(),
1957 delta: acc.arguments.clone(),
1958 block_index,
1959 });
1960 }
1961 deltas
1962}
1963
1964fn stop_reason_from_stream_state(
1965 tool_calls: &HashMap<String, ToolCallAccumulator>,
1966 status: Option<ApiStatus>,
1967) -> StopReason {
1968 if tool_calls.is_empty() {
1969 match status.unwrap_or(ApiStatus::Completed) {
1970 ApiStatus::Completed => StopReason::EndTurn,
1971 ApiStatus::Incomplete => StopReason::MaxTokens,
1972 ApiStatus::Failed => StopReason::StopSequence,
1973 }
1974 } else {
1975 StopReason::ToolUse
1976 }
1977}
1978
1979fn reset_websocket_connection(session: &mut WebsocketSessionState) {
1980 session.connection = None;
1981 if session.prewarmed {
1982 session.last_request = None;
1983 session.last_response_id = None;
1984 session.last_response_items.clear();
1985 }
1986 session.prewarmed = false;
1987}
1988
1989fn evict_idle_sessions(sessions: &mut HashMap<String, Arc<Mutex<WebsocketSessionState>>>) {
1996 let mut candidates: Vec<(String, Option<Instant>)> = Vec::new();
1997 for (key, state) in sessions.iter() {
1998 if let Ok(guard) = state.try_lock()
1999 && !guard.in_flight
2000 {
2001 candidates.push((key.clone(), guard.last_used));
2002 }
2003 }
2004 candidates.sort_by_key(|a| a.1);
2006 let evict_count = candidates.len().min(sessions.len() / 2 + 1);
2007 for (key, _) in candidates.into_iter().take(evict_count) {
2008 sessions.remove(&key);
2009 }
2010}
2011
2012fn parse_wrapped_websocket_error_event(payload: &str) -> Option<(StatusCode, String)> {
2013 let event: ApiWrappedWebsocketErrorEvent = serde_json::from_str(payload).ok()?;
2014 if event.kind != "error" {
2015 return None;
2016 }
2017
2018 if event.error.as_ref().and_then(|error| error.code.as_deref())
2019 == Some(OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE)
2020 {
2021 let message = event
2022 .error
2023 .and_then(|error| error.message)
2024 .unwrap_or_else(|| "Responses websocket connection limit reached".to_string());
2025 return Some((StatusCode::TOO_MANY_REQUESTS, message));
2026 }
2027
2028 let status = StatusCode::from_u16(event.status?).ok()?;
2029 let message = event
2030 .error
2031 .and_then(|error| error.message)
2032 .unwrap_or_else(|| payload.to_string());
2033 if status.is_success() {
2034 None
2035 } else {
2036 Some((status, message))
2037 }
2038}
2039
2040fn output_item_to_input_item(item: ApiOutputItem) -> Option<ApiInputItem> {
2041 match item {
2042 ApiOutputItem::Message { content, .. } => {
2043 let parts: Vec<ApiInputContent> = content
2044 .into_iter()
2045 .filter_map(|content| match content {
2046 ApiOutputContent::Text { text } if !text.is_empty() => {
2047 Some(ApiInputContent::OutputText { text })
2048 }
2049 ApiOutputContent::Unknown | ApiOutputContent::Text { .. } => None,
2050 })
2051 .collect();
2052 if parts.is_empty() {
2053 None
2054 } else {
2055 Some(ApiInputItem::Message(ApiMessage {
2056 role: ApiRole::Assistant,
2057 content: ApiMessageContent::Parts(parts),
2058 }))
2059 }
2060 }
2061 ApiOutputItem::FunctionCall {
2062 call_id,
2063 name,
2064 arguments,
2065 } => Some(ApiInputItem::FunctionCall(ApiFunctionCall::new(
2066 call_id, name, arguments,
2067 ))),
2068 ApiOutputItem::Unknown => None,
2069 }
2070}
2071
2072fn prepare_websocket_request(
2073 request: &ApiStreamingRequest,
2074 session: &WebsocketSessionState,
2075 allow_empty_delta: bool,
2076) -> ApiWebsocketRequest {
2077 let mut websocket_request = ApiWebsocketRequest::from(request);
2078
2079 let Some(last_request) = session.last_request.as_ref() else {
2080 return websocket_request;
2081 };
2082 let Some(last_response_id) = session.last_response_id.as_ref() else {
2083 return websocket_request;
2084 };
2085
2086 let mut previous_without_input = last_request.clone();
2087 previous_without_input.input.clear();
2088 let mut current_without_input = request.clone();
2089 current_without_input.input.clear();
2090 if previous_without_input != current_without_input {
2091 return websocket_request;
2092 }
2093
2094 let mut baseline = last_request.input.clone();
2095 baseline.extend(session.last_response_items.clone());
2096 if request.input.starts_with(&baseline)
2097 && (allow_empty_delta || baseline.len() < request.input.len())
2098 {
2099 websocket_request.previous_response_id = Some(last_response_id.clone());
2100 websocket_request.input = request.input[baseline.len()..].to_vec();
2101 }
2102
2103 websocket_request
2104}
2105
2106#[derive(Serialize)]
2111struct ApiResponsesRequest<'a> {
2112 model: &'a str,
2113 #[serde(skip_serializing_if = "is_empty")]
2114 instructions: &'a str,
2115 input: &'a [ApiInputItem],
2116 #[serde(skip_serializing_if = "Option::is_none")]
2117 tools: Option<&'a [ApiTool]>,
2118 #[serde(skip_serializing_if = "Option::is_none")]
2119 max_output_tokens: Option<u32>,
2120 #[serde(skip_serializing_if = "Option::is_none")]
2121 reasoning: Option<ApiReasoning>,
2122 #[serde(skip_serializing_if = "Option::is_none")]
2123 tool_choice: Option<ApiToolChoice>,
2124 #[serde(skip_serializing_if = "Option::is_none")]
2125 parallel_tool_calls: Option<bool>,
2126 store: bool,
2127 #[serde(skip_serializing_if = "Option::is_none")]
2128 text: Option<ApiTextSettings>,
2129 #[serde(skip_serializing_if = "Option::is_none")]
2130 include: Option<&'a [&'static str]>,
2131 #[serde(skip_serializing_if = "Option::is_none")]
2132 prompt_cache_key: Option<&'a str>,
2133}
2134
2135#[derive(Clone, PartialEq, Serialize)]
2136struct ApiStreamingRequest {
2137 model: String,
2138 #[serde(skip_serializing_if = "String::is_empty")]
2139 instructions: String,
2140 input: Vec<ApiInputItem>,
2141 #[serde(skip_serializing_if = "Option::is_none")]
2142 tools: Option<Vec<ApiTool>>,
2143 #[serde(skip_serializing_if = "Option::is_none")]
2144 max_output_tokens: Option<u32>,
2145 #[serde(skip_serializing_if = "Option::is_none")]
2146 reasoning: Option<ApiReasoning>,
2147 #[serde(skip_serializing_if = "Option::is_none")]
2148 tool_choice: Option<ApiToolChoice>,
2149 #[serde(skip_serializing_if = "Option::is_none")]
2150 parallel_tool_calls: Option<bool>,
2151 store: bool,
2152 #[serde(skip_serializing_if = "Option::is_none")]
2153 text: Option<ApiTextSettings>,
2154 #[serde(skip_serializing_if = "Option::is_none")]
2155 include: Option<Vec<String>>,
2156 #[serde(skip_serializing_if = "Option::is_none")]
2157 prompt_cache_key: Option<String>,
2158 stream: bool,
2159}
2160
2161#[derive(Clone, Serialize)]
2162struct ApiWebsocketRequest {
2163 #[serde(rename = "type")]
2164 kind: &'static str,
2165 model: String,
2166 #[serde(skip_serializing_if = "String::is_empty")]
2167 instructions: String,
2168 #[serde(skip_serializing_if = "Option::is_none")]
2169 previous_response_id: Option<String>,
2170 input: Vec<ApiInputItem>,
2171 #[serde(skip_serializing_if = "Option::is_none")]
2172 tools: Option<Vec<ApiTool>>,
2173 #[serde(skip_serializing_if = "Option::is_none")]
2174 max_output_tokens: Option<u32>,
2175 #[serde(skip_serializing_if = "Option::is_none")]
2176 reasoning: Option<ApiReasoning>,
2177 #[serde(skip_serializing_if = "Option::is_none")]
2178 tool_choice: Option<ApiToolChoice>,
2179 #[serde(skip_serializing_if = "Option::is_none")]
2180 parallel_tool_calls: Option<bool>,
2181 store: bool,
2182 #[serde(skip_serializing_if = "Option::is_none")]
2183 text: Option<ApiTextSettings>,
2184 #[serde(skip_serializing_if = "Option::is_none")]
2185 include: Option<Vec<String>>,
2186 #[serde(skip_serializing_if = "Option::is_none")]
2187 prompt_cache_key: Option<String>,
2188 stream: bool,
2189 #[serde(skip_serializing_if = "Option::is_none")]
2190 generate: Option<bool>,
2191}
2192
2193impl From<&ApiStreamingRequest> for ApiWebsocketRequest {
2194 fn from(request: &ApiStreamingRequest) -> Self {
2195 Self {
2196 kind: "response.create",
2197 model: request.model.clone(),
2198 instructions: request.instructions.clone(),
2199 previous_response_id: None,
2200 input: request.input.clone(),
2201 tools: request.tools.clone(),
2202 max_output_tokens: request.max_output_tokens,
2203 reasoning: request.reasoning.clone(),
2204 tool_choice: request.tool_choice.clone(),
2205 parallel_tool_calls: request.parallel_tool_calls,
2206 store: request.store,
2207 text: request.text.clone(),
2208 include: request.include.clone(),
2209 prompt_cache_key: request.prompt_cache_key.clone(),
2210 stream: request.stream,
2211 generate: None,
2212 }
2213 }
2214}
2215
2216#[derive(Clone, PartialEq, Serialize)]
2217struct ApiTextSettings {
2218 verbosity: &'static str,
2219 #[serde(skip_serializing_if = "Option::is_none")]
2222 format: Option<ApiResponseTextFormat>,
2223}
2224
2225#[derive(Clone, PartialEq, Serialize)]
2226struct ApiResponseTextFormat {
2227 #[serde(rename = "type")]
2228 format_type: &'static str,
2229 name: String,
2230 schema: serde_json::Value,
2231 strict: bool,
2232}
2233
2234impl From<&ResponseFormat> for ApiResponseTextFormat {
2235 fn from(rf: &ResponseFormat) -> Self {
2236 Self {
2237 format_type: "json_schema",
2238 name: rf.name.clone(),
2239 schema: rf.schema.clone(),
2240 strict: rf.strict,
2241 }
2242 }
2243}
2244
2245#[derive(Clone, PartialEq, Serialize)]
2250#[serde(untagged)]
2251enum ApiToolChoice {
2252 Mode(&'static str),
2253 Function {
2254 #[serde(rename = "type")]
2255 choice_type: &'static str,
2256 name: String,
2257 },
2258}
2259
2260fn codex_tool_choice(tool_choice: Option<&ToolChoice>) -> ApiToolChoice {
2263 match tool_choice {
2264 Some(ToolChoice::Tool(name)) => ApiToolChoice::Function {
2265 choice_type: "function",
2266 name: name.clone(),
2267 },
2268 _ => ApiToolChoice::Mode("auto"),
2269 }
2270}
2271
2272#[derive(Clone, PartialEq, Serialize)]
2273struct ApiReasoning {
2274 effort: ReasoningEffort,
2275}
2276
2277#[derive(Clone, PartialEq, Serialize)]
2278#[serde(untagged)]
2279enum ApiInputItem {
2280 Message(ApiMessage),
2281 FunctionCall(ApiFunctionCall),
2282 FunctionCallOutput(ApiFunctionCallOutput),
2283}
2284
2285#[derive(Clone, PartialEq, Serialize)]
2286struct ApiMessage {
2287 role: ApiRole,
2288 content: ApiMessageContent,
2289}
2290
2291#[derive(Clone, Copy, PartialEq, Serialize)]
2292#[serde(rename_all = "lowercase")]
2293enum ApiRole {
2294 User,
2295 Assistant,
2296}
2297
2298#[derive(Clone, PartialEq, Serialize)]
2299#[serde(untagged)]
2300enum ApiMessageContent {
2301 Text(String),
2302 Parts(Vec<ApiInputContent>),
2303}
2304
2305#[derive(Clone, PartialEq, Serialize)]
2306#[serde(tag = "type")]
2307enum ApiInputContent {
2308 #[serde(rename = "input_text")]
2309 InputText { text: String },
2310 #[serde(rename = "output_text")]
2311 OutputText { text: String },
2312 #[serde(rename = "input_image")]
2313 Image { image_url: String },
2314 #[serde(rename = "input_file")]
2315 File { filename: String, file_data: String },
2316}
2317
2318#[derive(Clone, PartialEq, Serialize)]
2319struct ApiFunctionCall {
2320 r#type: &'static str,
2321 call_id: String,
2322 name: String,
2323 arguments: String,
2324}
2325
2326impl ApiFunctionCall {
2327 const fn new(call_id: String, name: String, arguments: String) -> Self {
2328 Self {
2329 r#type: "function_call",
2330 call_id,
2331 name,
2332 arguments,
2333 }
2334 }
2335}
2336
2337#[derive(Clone, PartialEq, Serialize)]
2338struct ApiFunctionCallOutput {
2339 r#type: &'static str,
2340 call_id: String,
2341 output: String,
2342}
2343
2344impl ApiFunctionCallOutput {
2345 const fn new(call_id: String, output: String) -> Self {
2346 Self {
2347 r#type: "function_call_output",
2348 call_id,
2349 output,
2350 }
2351 }
2352}
2353
2354#[derive(Clone, PartialEq, Serialize)]
2355struct ApiTool {
2356 r#type: String,
2357 name: String,
2358 #[serde(skip_serializing_if = "Option::is_none")]
2359 description: Option<String>,
2360 #[serde(skip_serializing_if = "Option::is_none")]
2361 parameters: Option<serde_json::Value>,
2362 #[serde(skip_serializing_if = "Option::is_none")]
2363 strict: Option<bool>,
2364}
2365
2366#[derive(Deserialize)]
2371struct ApiResponse {
2372 id: String,
2373 model: String,
2374 output: Vec<ApiOutputItem>,
2375 #[serde(default)]
2376 status: Option<ApiStatus>,
2377 #[serde(default)]
2378 usage: Option<ApiUsage>,
2379 #[serde(default)]
2380 error: Option<ApiErrorBody>,
2381}
2382
2383#[derive(Clone, Copy, Deserialize)]
2384#[serde(rename_all = "snake_case")]
2385enum ApiStatus {
2386 Completed,
2387 Incomplete,
2388 Failed,
2389}
2390
2391#[derive(Deserialize)]
2392struct ApiUsage {
2393 input_tokens: u32,
2394 output_tokens: u32,
2395 #[serde(default)]
2396 input_tokens_details: Option<ApiInputTokensDetails>,
2397}
2398
2399#[derive(Deserialize)]
2400struct ApiInputTokensDetails {
2401 #[serde(default)]
2402 cached_tokens: u32,
2403}
2404
2405#[derive(Deserialize)]
2406#[serde(tag = "type")]
2407enum ApiOutputItem {
2408 #[serde(rename = "message")]
2409 Message {
2410 #[serde(rename = "role")]
2411 _role: String,
2412 content: Vec<ApiOutputContent>,
2413 },
2414 #[serde(rename = "function_call")]
2415 FunctionCall {
2416 call_id: String,
2417 name: String,
2418 arguments: String,
2419 },
2420 #[serde(other)]
2421 Unknown,
2422}
2423
2424#[derive(Deserialize)]
2425#[serde(tag = "type")]
2426enum ApiOutputContent {
2427 #[serde(rename = "output_text")]
2428 Text { text: String },
2429 #[serde(other)]
2430 Unknown,
2431}
2432
2433#[derive(Deserialize)]
2438struct ApiStreamEvent {
2439 r#type: String,
2440 #[serde(default)]
2441 delta: Option<String>,
2442 #[serde(default)]
2443 call_id: Option<String>,
2444 #[serde(default)]
2445 name: Option<String>,
2446 #[serde(default)]
2447 item: Option<serde_json::Value>,
2448 #[serde(default)]
2449 response: Option<ApiStreamResponse>,
2450}
2451
2452#[derive(Deserialize)]
2453struct ApiStreamResponse {
2454 #[serde(default)]
2455 id: Option<String>,
2456 #[serde(default)]
2457 usage: Option<ApiUsage>,
2458 #[serde(default)]
2459 error: Option<ApiErrorBody>,
2460}
2461
2462#[derive(Deserialize)]
2463struct ApiErrorBody {
2464 #[serde(default)]
2465 message: Option<String>,
2466}
2467
2468#[derive(Deserialize)]
2469struct ApiWrappedWebsocketErrorBody {
2470 #[serde(default)]
2471 code: Option<String>,
2472 #[serde(default)]
2473 message: Option<String>,
2474}
2475
2476#[derive(Deserialize)]
2477struct ApiWrappedWebsocketErrorEvent {
2478 #[serde(rename = "type")]
2479 kind: String,
2480 #[serde(alias = "status_code")]
2481 status: Option<u16>,
2482 #[serde(default)]
2483 error: Option<ApiWrappedWebsocketErrorBody>,
2484}
2485
2486#[cfg(test)]
2491mod tests {
2492 use super::*;
2493
2494 #[test]
2495 fn test_model_constant() {
2496 assert_eq!(MODEL_GPT54, "gpt-5.4");
2497 assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
2498 assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
2499 }
2500
2501 #[test]
2502 fn test_codex_factory() {
2503 let provider = OpenAICodexResponsesProvider::codex("test-key".to_string());
2504 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
2505 assert_eq!(provider.provider(), "openai-codex");
2506 }
2507
2508 #[test]
2509 fn test_gpt54_factory() {
2510 let provider = OpenAICodexResponsesProvider::gpt54("test-key".to_string());
2511 assert_eq!(provider.model(), MODEL_GPT54);
2512 assert_eq!(provider.provider(), "openai-codex");
2513 }
2514
2515 #[test]
2516 fn test_gpt53_codex_factory() {
2517 let provider = OpenAICodexResponsesProvider::gpt53_codex("test-key".to_string());
2518 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
2519 assert_eq!(provider.provider(), "openai-codex");
2520 }
2521
2522 #[test]
2523 fn test_reasoning_effort_serialization() {
2524 let low = serde_json::to_string(&ReasoningEffort::Low).unwrap();
2525 assert_eq!(low, "\"low\"");
2526
2527 let xhigh = serde_json::to_string(&ReasoningEffort::XHigh).unwrap();
2528 assert_eq!(xhigh, "\"xhigh\"");
2529 }
2530
2531 #[test]
2532 fn test_with_reasoning_effort() {
2533 let provider = OpenAICodexResponsesProvider::codex("test-key".to_string())
2534 .with_reasoning_effort(ReasoningEffort::High);
2535 let thinking = provider.thinking.as_ref().unwrap();
2536 assert!(matches!(thinking.effort, Some(Effort::High)));
2537 }
2538
2539 #[test]
2540 fn test_build_api_reasoning_uses_explicit_effort() {
2541 let reasoning =
2542 build_api_reasoning(Some(&ThinkingConfig::adaptive_with_effort(Effort::Low))).unwrap();
2543 assert!(matches!(reasoning.effort, ReasoningEffort::Low));
2544 }
2545
2546 #[test]
2547 fn test_build_api_reasoning_omits_adaptive_without_effort() {
2548 assert!(build_api_reasoning(Some(&ThinkingConfig::adaptive())).is_none());
2549 }
2550
2551 #[test]
2552 fn test_openai_responses_rejects_adaptive_thinking() {
2553 let provider = OpenAICodexResponsesProvider::codex("test-key".to_string());
2554 let error = provider
2555 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
2556 .unwrap_err();
2557 assert!(
2558 error
2559 .to_string()
2560 .contains("adaptive thinking is not supported")
2561 );
2562 }
2563
2564 #[test]
2565 fn test_api_tool_serialization() {
2566 let tool = ApiTool {
2567 r#type: "function".to_owned(),
2568 name: "get_weather".to_owned(),
2569 description: Some("Get weather".to_owned()),
2570 parameters: Some(serde_json::json!({"type": "object"})),
2571 strict: Some(true),
2572 };
2573
2574 let json = serde_json::to_string(&tool).unwrap();
2575 assert!(json.contains("\"type\":\"function\""));
2576 assert!(json.contains("\"name\":\"get_weather\""));
2577 assert!(json.contains("\"strict\":true"));
2578 }
2579
2580 fn test_token() -> String {
2581 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
2582 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(
2583 r#"{{"{OPENAI_CODEX_JWT_CLAIM_PATH}":{{"chatgpt_account_id":"acct_123"}}}}"#
2584 ));
2585 format!("{header}.{payload}.sig")
2586 }
2587
2588 #[test]
2589 fn test_build_headers_match_codex_style_defaults() -> anyhow::Result<()> {
2590 let provider = OpenAICodexResponsesProvider::codex(test_token());
2591
2592 let headers = provider.build_headers(true, Some("session-123"), None)?;
2593 assert_eq!(headers.get("originator").unwrap(), OPENAI_CODEX_ORIGINATOR);
2594 assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123");
2595 assert_eq!(headers.get("session_id").unwrap(), "session-123");
2596 assert_eq!(headers.get("x-client-request-id").unwrap(), "session-123");
2597 assert_eq!(
2598 headers.get("OpenAI-Beta").unwrap(),
2599 OPENAI_CODEX_RESPONSES_BETA_HEADER
2600 );
2601
2602 Ok(())
2603 }
2604
2605 #[test]
2606 fn test_build_websocket_headers_match_codex_style_defaults() -> anyhow::Result<()> {
2607 let provider = OpenAICodexResponsesProvider::codex(test_token());
2608
2609 let headers = provider.build_websocket_headers(Some("session-123"), Some("turn-1"))?;
2610 assert_eq!(headers.get("originator").unwrap(), OPENAI_CODEX_ORIGINATOR);
2611 assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_123");
2612 assert_eq!(headers.get("session_id").unwrap(), "session-123");
2613 assert_eq!(headers.get("x-client-request-id").unwrap(), "session-123");
2614 assert_eq!(
2615 headers.get(OPENAI_CODEX_TURN_STATE_HEADER).unwrap(),
2616 "turn-1"
2617 );
2618 assert_eq!(
2619 headers.get("OpenAI-Beta").unwrap(),
2620 OPENAI_CODEX_RESPONSES_WEBSOCKETS_BETA_HEADER,
2621 );
2622
2623 Ok(())
2624 }
2625
2626 #[test]
2627 fn test_build_headers_uses_configured_account_id_without_jwt_decode() -> anyhow::Result<()> {
2628 let provider = OpenAICodexResponsesProvider::codex("not-a-jwt".to_string())
2629 .with_account_id("acct_stored");
2630
2631 let headers = provider.build_headers(true, Some("session-123"), Some("turn-1"))?;
2632 assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct_stored");
2633 assert_eq!(
2634 headers.get(OPENAI_CODEX_TURN_STATE_HEADER).unwrap(),
2635 "turn-1"
2636 );
2637
2638 Ok(())
2639 }
2640
2641 #[test]
2642 fn test_request_serialization_includes_store_false() {
2643 let request = ApiStreamingRequest {
2644 model: MODEL_GPT53_CODEX.to_string(),
2645 instructions: "system".to_string(),
2646 input: Vec::new(),
2647 tools: None,
2648 max_output_tokens: None,
2649 reasoning: None,
2650 tool_choice: Some(ApiToolChoice::Mode("auto")),
2651 parallel_tool_calls: Some(true),
2652 store: false,
2653 text: Some(ApiTextSettings {
2654 verbosity: "medium",
2655 format: None,
2656 }),
2657 include: Some(vec!["reasoning.encrypted_content".to_string()]),
2658 prompt_cache_key: Some("session-123".to_string()),
2659 stream: true,
2660 };
2661
2662 let json = serde_json::to_string(&request).unwrap();
2663 assert!(json.contains("\"store\":false"));
2664 assert!(json.contains("\"stream\":true"));
2665 }
2666
2667 #[test]
2668 fn test_prepare_websocket_request_uses_previous_response_id_for_incremental_input() {
2669 let request = ApiStreamingRequest {
2670 model: MODEL_GPT53_CODEX.to_string(),
2671 instructions: "system".to_string(),
2672 input: vec![
2673 ApiInputItem::Message(ApiMessage {
2674 role: ApiRole::User,
2675 content: ApiMessageContent::Text("first".to_string()),
2676 }),
2677 ApiInputItem::Message(ApiMessage {
2678 role: ApiRole::Assistant,
2679 content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
2680 text: "answer".to_string(),
2681 }]),
2682 }),
2683 ApiInputItem::Message(ApiMessage {
2684 role: ApiRole::User,
2685 content: ApiMessageContent::Text("follow up".to_string()),
2686 }),
2687 ],
2688 tools: None,
2689 max_output_tokens: None,
2690 reasoning: None,
2691 tool_choice: Some(ApiToolChoice::Mode("auto")),
2692 parallel_tool_calls: None,
2693 store: false,
2694 text: Some(ApiTextSettings {
2695 verbosity: "medium",
2696 format: None,
2697 }),
2698 include: Some(vec!["reasoning.encrypted_content".to_string()]),
2699 prompt_cache_key: Some("thread-1".to_string()),
2700 stream: true,
2701 };
2702 let previous_request = ApiStreamingRequest {
2703 input: vec![ApiInputItem::Message(ApiMessage {
2704 role: ApiRole::User,
2705 content: ApiMessageContent::Text("first".to_string()),
2706 })],
2707 ..request.clone()
2708 };
2709 let session = WebsocketSessionState {
2710 connection: None,
2711 last_request: Some(previous_request),
2712 last_response_id: Some("resp_prev".to_string()),
2713 last_response_items: vec![ApiInputItem::Message(ApiMessage {
2714 role: ApiRole::Assistant,
2715 content: ApiMessageContent::Parts(vec![ApiInputContent::OutputText {
2716 text: "answer".to_string(),
2717 }]),
2718 })],
2719 turn_state: None,
2720 prewarmed: false,
2721 websocket_disabled: false,
2722 in_flight: false,
2723 last_used: None,
2724 };
2725
2726 let websocket_request = prepare_websocket_request(&request, &session, false);
2727 assert_eq!(
2728 websocket_request.previous_response_id.as_deref(),
2729 Some("resp_prev")
2730 );
2731 assert_eq!(websocket_request.input.len(), 1);
2732 match &websocket_request.input[0] {
2733 ApiInputItem::Message(ApiMessage {
2734 role: ApiRole::User,
2735 content: ApiMessageContent::Text(text),
2736 }) => assert_eq!(text, "follow up"),
2737 _ => panic!("expected incremental follow-up user message"),
2738 }
2739 }
2740
2741 #[test]
2742 fn test_parse_wrapped_websocket_error_event_maps_http_status() {
2743 let payload = r#"{"type":"error","status":401,"error":{"message":"unauthorized"}}"#;
2744 let parsed = parse_wrapped_websocket_error_event(payload);
2745 assert_eq!(
2746 parsed,
2747 Some((StatusCode::UNAUTHORIZED, "unauthorized".to_string())),
2748 );
2749 }
2750
2751 #[test]
2752 fn test_parse_wrapped_websocket_error_event_maps_connection_limit() {
2753 let payload = format!(
2754 r#"{{"type":"error","status":429,"error":{{"code":"{OPENAI_CODEX_WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE}","message":"limit"}}}}"#,
2755 );
2756 let parsed = parse_wrapped_websocket_error_event(&payload);
2757 assert_eq!(
2758 parsed,
2759 Some((StatusCode::TOO_MANY_REQUESTS, "limit".to_string())),
2760 );
2761 }
2762
2763 #[test]
2764 fn test_prepare_websocket_request_allows_empty_delta_after_prewarm() {
2765 let request = ApiStreamingRequest {
2766 model: MODEL_GPT53_CODEX.to_string(),
2767 instructions: "system".to_string(),
2768 input: vec![ApiInputItem::Message(ApiMessage {
2769 role: ApiRole::User,
2770 content: ApiMessageContent::Text("first".to_string()),
2771 })],
2772 tools: None,
2773 max_output_tokens: None,
2774 reasoning: None,
2775 tool_choice: Some(ApiToolChoice::Mode("auto")),
2776 parallel_tool_calls: None,
2777 store: false,
2778 text: Some(ApiTextSettings {
2779 verbosity: "medium",
2780 format: None,
2781 }),
2782 include: Some(vec!["reasoning.encrypted_content".to_string()]),
2783 prompt_cache_key: Some("thread-1".to_string()),
2784 stream: true,
2785 };
2786 let session = WebsocketSessionState {
2787 connection: None,
2788 last_request: Some(request.clone()),
2789 last_response_id: Some("resp_prewarm".to_string()),
2790 last_response_items: Vec::new(),
2791 turn_state: None,
2792 prewarmed: true,
2793 websocket_disabled: false,
2794 in_flight: false,
2795 last_used: None,
2796 };
2797
2798 let websocket_request = prepare_websocket_request(&request, &session, true);
2799 assert_eq!(
2800 websocket_request.previous_response_id.as_deref(),
2801 Some("resp_prewarm")
2802 );
2803 assert!(websocket_request.input.is_empty());
2804 }
2805
2806 #[test]
2807 fn test_api_response_deserialization() {
2808 let json = r#"{
2809 "id": "resp_123",
2810 "model": "gpt-5.2-codex",
2811 "output": [
2812 {
2813 "type": "message",
2814 "role": "assistant",
2815 "content": [
2816 {"type": "output_text", "text": "Hello!"}
2817 ]
2818 }
2819 ],
2820 "status": "completed",
2821 "usage": {
2822 "input_tokens": 100,
2823 "output_tokens": 50
2824 }
2825 }"#;
2826
2827 let response: ApiResponse = serde_json::from_str(json).unwrap();
2828 assert_eq!(response.id, "resp_123");
2829 assert_eq!(response.model, "gpt-5.2-codex");
2830 assert_eq!(response.output.len(), 1);
2831 }
2832
2833 #[test]
2834 fn test_api_response_with_function_call() {
2835 let json = r#"{
2836 "id": "resp_456",
2837 "model": "gpt-5.2-codex",
2838 "output": [
2839 {
2840 "type": "function_call",
2841 "call_id": "call_abc",
2842 "name": "read_file",
2843 "arguments": "{\"path\": \"test.txt\"}"
2844 }
2845 ],
2846 "status": "completed"
2847 }"#;
2848
2849 let response: ApiResponse = serde_json::from_str(json).unwrap();
2850 assert_eq!(response.output.len(), 1);
2851
2852 match &response.output[0] {
2853 ApiOutputItem::FunctionCall {
2854 call_id,
2855 name,
2856 arguments,
2857 } => {
2858 assert_eq!(call_id, "call_abc");
2859 assert_eq!(name, "read_file");
2860 assert!(arguments.contains("test.txt"));
2861 }
2862 _ => panic!("Expected FunctionCall"),
2863 }
2864 }
2865
2866 #[test]
2867 fn test_build_api_input_uses_responses_text_types_by_role() {
2868 let request = ChatRequest {
2869 system: "system".to_string(),
2870 messages: vec![
2871 agent_sdk_foundation::llm::Message::user_with_content(vec![ContentBlock::Text {
2872 text: "question".to_string(),
2873 }]),
2874 agent_sdk_foundation::llm::Message {
2875 role: agent_sdk_foundation::llm::Role::Assistant,
2876 content: Content::Blocks(vec![ContentBlock::Text {
2877 text: "answer".to_string(),
2878 }]),
2879 },
2880 ],
2881 tools: None,
2882 max_tokens: 512,
2883 max_tokens_explicit: false,
2884 session_id: None,
2885 cached_content: None,
2886 thinking: None,
2887 tool_choice: None,
2888 response_format: None,
2889 cache: None,
2890 };
2891
2892 let input = build_api_input(&request);
2893 assert_eq!(input.len(), 2);
2894
2895 match &input[0] {
2896 ApiInputItem::Message(ApiMessage {
2897 role: ApiRole::User,
2898 content: ApiMessageContent::Parts(parts),
2899 }) => assert!(matches!(
2900 parts.as_slice(),
2901 [ApiInputContent::InputText { text }] if text == "question"
2902 )),
2903 _ => panic!("expected user message with input_text content"),
2904 }
2905
2906 match &input[1] {
2907 ApiInputItem::Message(ApiMessage {
2908 role: ApiRole::Assistant,
2909 content: ApiMessageContent::Parts(parts),
2910 }) => assert!(matches!(
2911 parts.as_slice(),
2912 [ApiInputContent::OutputText { text }] if text == "answer"
2913 )),
2914 _ => panic!("expected assistant message with output_text content"),
2915 }
2916 }
2917
2918 #[test]
2919 fn test_api_input_content_serialization_uses_current_responses_tags() {
2920 let json = serde_json::to_string(&ApiMessageContent::Parts(vec![
2921 ApiInputContent::InputText {
2922 text: "prompt".to_string(),
2923 },
2924 ApiInputContent::OutputText {
2925 text: "reply".to_string(),
2926 },
2927 ApiInputContent::Image {
2928 image_url: "data:image/png;base64,abc".to_string(),
2929 },
2930 ApiInputContent::File {
2931 filename: "notes.txt".to_string(),
2932 file_data: "data:text/plain;base64,abc".to_string(),
2933 },
2934 ]))
2935 .unwrap();
2936
2937 assert!(json.contains("\"type\":\"input_text\""));
2938 assert!(json.contains("\"type\":\"output_text\""));
2939 assert!(json.contains("\"type\":\"input_image\""));
2940 assert!(json.contains("\"type\":\"input_file\""));
2941 }
2942
2943 #[test]
2944 fn test_build_content_blocks_text() {
2945 let output = vec![ApiOutputItem::Message {
2946 _role: "assistant".to_owned(),
2947 content: vec![ApiOutputContent::Text {
2948 text: "Hello!".to_owned(),
2949 }],
2950 }];
2951
2952 let blocks = build_content_blocks(&output);
2953 assert_eq!(blocks.len(), 1);
2954 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello!"));
2955 }
2956
2957 #[test]
2958 fn test_build_content_blocks_function_call() {
2959 let output = vec![ApiOutputItem::FunctionCall {
2960 call_id: "call_123".to_owned(),
2961 name: "test_tool".to_owned(),
2962 arguments: r#"{"key": "value"}"#.to_owned(),
2963 }];
2964
2965 let blocks = build_content_blocks(&output);
2966 assert_eq!(blocks.len(), 1);
2967 assert!(
2968 matches!(&blocks[0], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "test_tool")
2969 );
2970 }
2971
2972 #[test]
2973 fn test_request_serializes_response_format_text_and_forced_tool_choice() {
2974 let request = ApiStreamingRequest {
2975 model: MODEL_GPT53_CODEX.to_string(),
2976 instructions: String::new(),
2977 input: Vec::new(),
2978 tools: None,
2979 max_output_tokens: None,
2980 reasoning: None,
2981 tool_choice: Some(codex_tool_choice(Some(&ToolChoice::Tool(
2982 "respond".to_owned(),
2983 )))),
2984 parallel_tool_calls: None,
2985 store: false,
2986 text: Some(ApiTextSettings {
2987 verbosity: "medium",
2988 format: Some(ApiResponseTextFormat::from(&ResponseFormat::new(
2989 "person",
2990 serde_json::json!({"type": "object"}),
2991 ))),
2992 }),
2993 include: None,
2994 prompt_cache_key: None,
2995 stream: true,
2996 };
2997
2998 let json = serde_json::to_value(&request).unwrap();
2999 assert_eq!(json["text"]["format"]["type"], "json_schema");
3000 assert_eq!(json["text"]["format"]["name"], "person");
3001 assert_eq!(json["text"]["format"]["strict"], true);
3002 assert_eq!(json["tool_choice"]["type"], "function");
3003 assert_eq!(json["tool_choice"]["name"], "respond");
3004 }
3005
3006 #[test]
3007 fn test_codex_tool_choice_defaults_to_auto() {
3008 assert_eq!(
3009 serde_json::to_value(codex_tool_choice(None)).unwrap(),
3010 serde_json::json!("auto")
3011 );
3012 assert_eq!(
3013 serde_json::to_value(codex_tool_choice(Some(&ToolChoice::Auto))).unwrap(),
3014 serde_json::json!("auto")
3015 );
3016 }
3017
3018 #[test]
3019 fn test_convert_tool_makes_optional_params_nullable() {
3020 let tool = agent_sdk_foundation::llm::Tool {
3021 name: "t".to_string(),
3022 description: "d".to_string(),
3023 input_schema: serde_json::json!({
3024 "type": "object",
3025 "properties": {
3026 "req": {"type": "string"},
3027 "opt": {"type": "string"}
3028 },
3029 "required": ["req"]
3030 }),
3031 display_name: "T".to_string(),
3032 tier: agent_sdk_foundation::ToolTier::Observe,
3033 };
3034
3035 let api_tool = convert_tool(tool);
3036 assert_eq!(api_tool.strict, Some(true));
3037 let schema = api_tool.parameters.unwrap();
3038
3039 let required: Vec<&str> = schema["required"]
3040 .as_array()
3041 .unwrap()
3042 .iter()
3043 .filter_map(|v| v.as_str())
3044 .collect();
3045 assert!(required.contains(&"req"));
3046 assert!(required.contains(&"opt"));
3047
3048 let any_of = schema["properties"]["opt"]["anyOf"].as_array().unwrap();
3051 assert!(
3052 any_of
3053 .iter()
3054 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"))
3055 );
3056 }
3057
3058 #[test]
3059 fn test_convert_tool_disables_strict_for_freeform_object() {
3060 let tool = agent_sdk_foundation::llm::Tool {
3061 name: "t".to_string(),
3062 description: "d".to_string(),
3063 input_schema: serde_json::json!({"type": "object"}),
3064 display_name: "T".to_string(),
3065 tier: agent_sdk_foundation::ToolTier::Observe,
3066 };
3067
3068 let api_tool = convert_tool(tool);
3069 assert_eq!(api_tool.strict, None);
3070 }
3071
3072 #[test]
3073 fn test_emit_accumulated_tool_calls_assigns_distinct_ordered_indices() {
3074 let mut tool_calls = HashMap::new();
3075 tool_calls.insert(
3076 "b".to_string(),
3077 ToolCallAccumulator {
3078 id: "b".to_string(),
3079 name: "second".to_string(),
3080 arguments: "{}".to_string(),
3081 order: 1,
3082 },
3083 );
3084 tool_calls.insert(
3085 "a".to_string(),
3086 ToolCallAccumulator {
3087 id: "a".to_string(),
3088 name: "first".to_string(),
3089 arguments: "{}".to_string(),
3090 order: 0,
3091 },
3092 );
3093
3094 let deltas = emit_accumulated_tool_calls(&tool_calls);
3095 let starts: Vec<(String, usize)> = deltas
3096 .iter()
3097 .filter_map(|d| match d {
3098 StreamDelta::ToolUseStart {
3099 name, block_index, ..
3100 } => Some((name.clone(), *block_index)),
3101 _ => None,
3102 })
3103 .collect();
3104 assert_eq!(
3105 starts,
3106 vec![("first".to_string(), 1), ("second".to_string(), 2)]
3107 );
3108 }
3109
3110 use crate::provider::LlmProvider;
3115 use agent_sdk_foundation::llm::{ChatRequest, Message};
3116 use std::sync::atomic::AtomicUsize;
3117 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3118 use tokio::net::TcpListener;
3119
3120 fn oauth_token() -> String {
3123 test_token()
3124 }
3125
3126 fn streaming_request(session_id: &str) -> ChatRequest {
3127 ChatRequest::new("You are helpful.", vec![Message::user("hello")])
3128 .with_max_tokens(1024)
3129 .with_session_id(session_id)
3130 }
3131
3132 async fn read_http_head(stream: &mut tokio::net::TcpStream) -> String {
3134 let mut buf = Vec::new();
3135 let mut byte = [0u8; 1];
3136 while stream.read_exact(&mut byte).await.is_ok() {
3137 buf.push(byte[0]);
3138 if buf.ends_with(b"\r\n\r\n") {
3139 break;
3140 }
3141 if buf.len() > 16 * 1024 {
3142 break;
3143 }
3144 }
3145 String::from_utf8_lossy(&buf).into_owned()
3146 }
3147
3148 const HTTP_SSE_BODY: &str = concat!(
3150 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n",
3151 "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\"}}\n\n",
3152 "data: [DONE]\n\n",
3153 );
3154
3155 async fn spawn_http_only_server() -> (String, Arc<AtomicUsize>, Arc<AtomicUsize>) {
3159 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3160 let addr = listener.local_addr().unwrap();
3161 let ws_attempts = Arc::new(AtomicUsize::new(0));
3162 let http_requests = Arc::new(AtomicUsize::new(0));
3163 let ws_attempts_task = ws_attempts.clone();
3164 let http_requests_task = http_requests.clone();
3165
3166 tokio::spawn(async move {
3167 loop {
3168 let Ok((mut stream, _)) = listener.accept().await else {
3169 break;
3170 };
3171 let ws_attempts = ws_attempts_task.clone();
3172 let http_requests = http_requests_task.clone();
3173 tokio::spawn(async move {
3174 let head = read_http_head(&mut stream).await;
3175 if head.to_ascii_lowercase().contains("upgrade: websocket") {
3176 ws_attempts.fetch_add(1, Ordering::Relaxed);
3177 let _ = stream
3181 .write_all(
3182 b"HTTP/1.1 426 Upgrade Required\r\ncontent-length: 0\r\n\r\n",
3183 )
3184 .await;
3185 return;
3186 }
3187 http_requests.fetch_add(1, Ordering::Relaxed);
3188 let response = format!(
3189 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{}",
3190 HTTP_SSE_BODY.len(),
3191 HTTP_SSE_BODY,
3192 );
3193 let _ = stream.write_all(response.as_bytes()).await;
3194 });
3195 }
3196 });
3197
3198 (
3199 format!("http://{addr}/backend-api"),
3200 ws_attempts,
3201 http_requests,
3202 )
3203 }
3204
3205 async fn drain_ok(provider: &OpenAICodexResponsesProvider, request: ChatRequest) -> bool {
3207 let mut stream = std::pin::pin!(provider.chat_stream(request));
3208 let mut saw_error = false;
3209 while let Some(item) = stream.next().await {
3210 match item {
3211 Ok(StreamDelta::Error { .. }) | Err(_) => saw_error = true,
3212 Ok(_) => {}
3213 }
3214 }
3215 !saw_error
3216 }
3217
3218 #[tokio::test]
3219 async fn websockets_disabled_builder_goes_straight_to_http() {
3220 let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
3221 let provider = OpenAICodexResponsesProvider::with_base_url(
3222 oauth_token(),
3223 MODEL_GPT53_CODEX.to_string(),
3224 base_url,
3225 )
3226 .with_websockets_disabled(true);
3227
3228 assert!(drain_ok(&provider, streaming_request("session-a")).await);
3229
3230 assert_eq!(
3231 ws_attempts.load(Ordering::Relaxed),
3232 0,
3233 "no websocket upgrade may be attempted when websockets are disabled",
3234 );
3235 assert_eq!(http_requests.load(Ordering::Relaxed), 1);
3236 }
3237
3238 #[test]
3239 fn parse_disable_websockets_value_recognizes_truthy_values() {
3240 for value in ["1", "true", "TRUE", " yes ", "on"] {
3241 assert!(
3242 parse_disable_websockets_value(Some(value)),
3243 "value={value:?} should disable websockets",
3244 );
3245 }
3246 for value in ["0", "false", "no", "off", "", "maybe"] {
3247 assert!(
3248 !parse_disable_websockets_value(Some(value)),
3249 "value={value:?} should NOT disable websockets",
3250 );
3251 }
3252 assert!(!parse_disable_websockets_value(None));
3253 }
3254
3255 #[tokio::test]
3256 async fn websockets_disabled_via_env_value_goes_straight_to_http() {
3257 let disabled = parse_disable_websockets_value(Some("1"));
3262 assert!(disabled);
3263
3264 let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
3265 let provider = OpenAICodexResponsesProvider::with_base_url(
3266 oauth_token(),
3267 MODEL_GPT53_CODEX.to_string(),
3268 base_url,
3269 )
3270 .with_websockets_disabled(disabled);
3271
3272 assert!(drain_ok(&provider, streaming_request("session-env")).await);
3273
3274 assert_eq!(
3275 ws_attempts.load(Ordering::Relaxed),
3276 0,
3277 "no websocket upgrade may be attempted when the env var forces HTTP-only",
3278 );
3279 assert_eq!(http_requests.load(Ordering::Relaxed), 1);
3280 }
3281
3282 #[tokio::test]
3283 async fn provider_marked_ws_unhealthy_skips_websocket_on_new_session() {
3284 let (base_url, ws_attempts, http_requests) = spawn_http_only_server().await;
3285 let provider = OpenAICodexResponsesProvider::with_base_url(
3286 oauth_token(),
3287 MODEL_GPT53_CODEX.to_string(),
3288 base_url,
3289 );
3290
3291 provider.websockets_unhealthy.store(true, Ordering::Relaxed);
3294
3295 assert!(drain_ok(&provider, streaming_request("fresh-session")).await);
3297
3298 assert_eq!(
3299 ws_attempts.load(Ordering::Relaxed),
3300 0,
3301 "a websocket-unhealthy provider must not attempt a new upgrade",
3302 );
3303 assert_eq!(http_requests.load(Ordering::Relaxed), 1);
3304 }
3305
3306 async fn spawn_ws_unauthorized_server() -> (String, Arc<AtomicUsize>) {
3310 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3311 let addr = listener.local_addr().unwrap();
3312 let http_requests = Arc::new(AtomicUsize::new(0));
3313 let http_requests_task = http_requests.clone();
3314
3315 tokio::spawn(async move {
3316 loop {
3317 let Ok((stream, _)) = listener.accept().await else {
3318 break;
3319 };
3320 let http_requests = http_requests_task.clone();
3321 tokio::spawn(async move {
3322 let mut stream = stream;
3323 let mut peek = [0u8; 1024];
3326 let Ok(n) = stream.peek(&mut peek).await else {
3327 return;
3328 };
3329 let head = String::from_utf8_lossy(&peek[..n]).to_ascii_lowercase();
3330
3331 if head.contains("upgrade: websocket") {
3332 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
3335 return;
3336 };
3337 let payload =
3338 r#"{"type":"error","status":401,"error":{"message":"unauthorized"}}"#;
3339 let _ = ws
3340 .send(WebSocketMessage::Text(payload.to_string().into()))
3341 .await;
3342 let _ = ws.send(WebSocketMessage::Close(None)).await;
3343 return;
3344 }
3345
3346 let _ = read_http_head(&mut stream).await;
3348 http_requests.fetch_add(1, Ordering::Relaxed);
3349 let response = format!(
3350 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{}",
3351 HTTP_SSE_BODY.len(),
3352 HTTP_SSE_BODY,
3353 );
3354 let _ = stream.write_all(response.as_bytes()).await;
3355 });
3356 }
3357 });
3358
3359 (format!("http://{addr}/backend-api"), http_requests)
3360 }
3361
3362 #[tokio::test]
3363 async fn ws_unauthorized_disables_session_but_not_provider() {
3364 let (base_url, http_requests) = spawn_ws_unauthorized_server().await;
3365 let provider = OpenAICodexResponsesProvider::with_base_url(
3366 oauth_token(),
3367 MODEL_GPT53_CODEX.to_string(),
3368 base_url,
3369 );
3370
3371 assert!(drain_ok(&provider, streaming_request("auth-session")).await);
3374
3375 assert!(
3379 !provider.websockets_unhealthy.load(Ordering::Relaxed),
3380 "a 401 must not mark the provider websocket-transport-unhealthy",
3381 );
3382 let session = provider.websocket_session("auth-session").await;
3385 assert!(session.lock().await.websocket_disabled);
3386
3387 assert!(http_requests.load(Ordering::Relaxed) >= 1);
3388 }
3389}