From 5fc4f8e7262b25ea834c465b99184746afdde0c9 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 17 Mar 2026 13:34:05 -0700 Subject: [PATCH 1/6] Gate realtime audio interruption logic to v2 --- .../schema/json/ServerNotification.json | 6 +- .../codex_app_server_protocol.schemas.json | 6 +- .../codex_app_server_protocol.v2.schemas.json | 6 +- .../v2/ThreadRealtimeStartedNotification.json | 6 +- .../v2/ThreadRealtimeStartedNotification.ts | 2 +- .../src/protocol/common.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 1 + .../app-server/src/bespoke_event_handling.rs | 5 ++ .../tests/suite/v2/realtime_conversation.rs | 1 + codex-rs/core/src/realtime_conversation.rs | 8 +- .../core/tests/suite/realtime_conversation.rs | 2 + codex-rs/protocol/src/protocol.rs | 8 ++ codex-rs/tui/src/chatwidget/realtime.rs | 51 ++++++++++- codex-rs/tui/src/lib.rs | 10 ++- codex-rs/tui/src/voice.rs | 87 ++++++++++++++++--- .../src/app/app_server_adapter.rs | 5 ++ 16 files changed, 182 insertions(+), 23 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 14908dbb1f70..d1ff7a279b95 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2857,10 +2857,14 @@ }, "threadId": { "type": "string" + }, + "version": { + "type": "string" } }, "required": [ - "threadId" + "threadId", + "version" ], "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index e370546dc2fe..32a787a597c4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12925,10 +12925,14 @@ }, "threadId": { "type": "string" + }, + "version": { + "type": "string" } }, "required": [ - "threadId" + "threadId", + "version" ], "title": "ThreadRealtimeStartedNotification", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b069d3e5e7e6..3d4746950747 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -10685,10 +10685,14 @@ }, "threadId": { "type": "string" + }, + "version": { + "type": "string" } }, "required": [ - "threadId" + "threadId", + "version" ], "title": "ThreadRealtimeStartedNotification", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRealtimeStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRealtimeStartedNotification.json index 1584112640e9..ce3cb9dbb3fc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRealtimeStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRealtimeStartedNotification.json @@ -10,10 +10,14 @@ }, "threadId": { "type": "string" + }, + "version": { + "type": "string" } }, "required": [ - "threadId" + "threadId", + "version" ], "title": "ThreadRealtimeStartedNotification", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartedNotification.ts index 736ecde1fe17..a34aed8904d4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartedNotification.ts @@ -5,4 +5,4 @@ /** * EXPERIMENTAL - emitted when thread realtime startup is accepted. */ -export type ThreadRealtimeStartedNotification = { threadId: string, sessionId: string | null, }; +export type ThreadRealtimeStartedNotification = { threadId: string, sessionId: string | null, version: string, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 73139a2e09bd..350314fca226 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1628,6 +1628,7 @@ mod tests { ServerNotification::ThreadRealtimeStarted(v2::ThreadRealtimeStartedNotification { thread_id: "thr_123".to_string(), session_id: Some("sess_456".to_string()), + version: "v1".to_string(), }); let reason = crate::experimental_api::ExperimentalApi::experimental_reason(¬ification); assert_eq!(reason, Some("thread/realtime/started")); diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e2316d8e788d..62871a68ee3e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3775,6 +3775,7 @@ pub struct ThreadRealtimeStopResponse {} pub struct ThreadRealtimeStartedNotification { pub thread_id: String, pub session_id: Option, + pub version: String, } /// EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4f4f995e2c74..d65b56d8b157 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -121,6 +121,7 @@ use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; @@ -338,6 +339,10 @@ pub(crate) async fn apply_bespoke_event_handling( let notification = ThreadRealtimeStartedNotification { thread_id: conversation_id.to_string(), session_id: event.session_id, + version: match event.version { + RealtimeConversationVersion::V1 => "v1".to_string(), + RealtimeConversationVersion::V2 => "v2".to_string(), + }, }; outgoing .send_server_notification(ServerNotification::ThreadRealtimeStarted( diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 71b6d6dcf338..a08e73120f5c 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -115,6 +115,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { .await?; assert_eq!(started.thread_id, thread_start.thread.id); assert!(started.session_id.is_some()); + assert_eq!(started.version, "v1"); let startup_context_request = realtime_server.wait_for_request(0, 0).await; assert_eq!( diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 938f922f8774..b4d6b6494fd9 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -32,6 +32,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RealtimeConversationClosedEvent; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeConversationStartedEvent; +use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeHandoffRequested; use http::HeaderMap; use http::HeaderValue; @@ -371,7 +372,8 @@ pub(crate) async fn handle_start( format!("{prompt}\n\n{startup_context}") }; let model = config.experimental_realtime_ws_model.clone(); - let event_parser = match config.realtime.version { + let version = config.realtime.version; + let event_parser = match version { RealtimeWsVersion::V1 => RealtimeEventParser::V1, RealtimeWsVersion::V2 => RealtimeEventParser::RealtimeV2, }; @@ -411,6 +413,10 @@ pub(crate) async fn handle_start( id: sub_id.clone(), msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent { session_id: requested_session_id, + version: match version { + RealtimeWsVersion::V1 => RealtimeConversationVersion::V1, + RealtimeWsVersion::V2 => RealtimeConversationVersion::V2, + }, }), }) .await; diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 4ab987121479..8d156d17dd43 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -13,6 +13,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; +use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; @@ -159,6 +160,7 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { .await .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); assert!(started.session_id.is_some()); + assert_eq!(started.version, RealtimeConversationVersion::V1); let session_updated = wait_for_event_match(&test.codex, |msg| match msg { EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index daf3b7d74a39..252626b977b2 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1446,9 +1446,17 @@ pub struct HookCompletedEvent { pub run: HookRunSummary, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum RealtimeConversationVersion { + V1, + V2, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct RealtimeConversationStartedEvent { pub session_id: Option, + pub version: RealtimeConversationVersion, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 37646880e9db..da23d0323e53 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -4,6 +4,7 @@ use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::RealtimeConversationClosedEvent; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeConversationStartedEvent; +use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeEvent; #[cfg(not(target_os = "linux"))] use std::sync::atomic::AtomicUsize; @@ -22,6 +23,7 @@ pub(super) enum RealtimeConversationPhase { #[derive(Default)] pub(super) struct RealtimeConversationUiState { phase: RealtimeConversationPhase, + audio_behavior: RealtimeAudioBehavior, requested_close: bool, session_id: Option, warned_audio_only_submission: bool, @@ -38,6 +40,39 @@ pub(super) struct RealtimeConversationUiState { playback_queued_samples: Arc, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum RealtimeAudioBehavior { + #[default] + Legacy, + PlaybackAware, +} + +impl RealtimeAudioBehavior { + fn from_version(version: RealtimeConversationVersion) -> Self { + match version { + RealtimeConversationVersion::V1 => Self::Legacy, + RealtimeConversationVersion::V2 => Self::PlaybackAware, + } + } + + fn should_interrupt_playback_on_server_event(self) -> bool { + matches!(self, Self::PlaybackAware) + } + + #[cfg(not(target_os = "linux"))] + fn input_behavior( + self, + playback_queued_samples: Arc, + ) -> crate::voice::RealtimeInputBehavior { + match self { + Self::Legacy => crate::voice::RealtimeInputBehavior::Ungated, + Self::PlaybackAware => crate::voice::RealtimeInputBehavior::PlaybackAware { + playback_queued_samples, + }, + } + } +} + impl RealtimeConversationUiState { pub(super) fn is_live(&self) -> bool { matches!( @@ -202,6 +237,7 @@ impl ChatWidget { self.realtime_conversation.phase = RealtimeConversationPhase::Starting; self.realtime_conversation.requested_close = false; self.realtime_conversation.session_id = None; + self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy; self.realtime_conversation.warned_audio_only_submission = false; self.set_footer_hint_override(Some(vec![( "/realtime".to_string(), @@ -241,6 +277,7 @@ impl ChatWidget { self.realtime_conversation.phase = RealtimeConversationPhase::Inactive; self.realtime_conversation.requested_close = false; self.realtime_conversation.session_id = None; + self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy; self.realtime_conversation.warned_audio_only_submission = false; } @@ -255,6 +292,7 @@ impl ChatWidget { } self.realtime_conversation.phase = RealtimeConversationPhase::Active; self.realtime_conversation.session_id = ev.session_id; + self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::from_version(ev.version); self.realtime_conversation.warned_audio_only_submission = false; self.set_footer_hint_override(Some(vec![( "/realtime".to_string(), @@ -274,7 +312,12 @@ impl ChatWidget { } RealtimeEvent::InputAudioSpeechStarted(_) | RealtimeEvent::ResponseCancelled(_) => { #[cfg(not(target_os = "linux"))] - if let Some(player) = &self.realtime_conversation.audio_player { + if self + .realtime_conversation + .audio_behavior + .should_interrupt_playback_on_server_event() + && let Some(player) = &self.realtime_conversation.audio_player + { // Once the server detects user speech or the current response is cancelled, // any buffered assistant audio is stale and should stop gating mic input. player.clear(); @@ -341,7 +384,11 @@ impl ChatWidget { let capture = match crate::voice::VoiceCapture::start_realtime( &self.config, self.app_event_tx.clone(), - Arc::clone(&self.realtime_conversation.playback_queued_samples), + self.realtime_conversation + .audio_behavior + .input_behavior(Arc::clone( + &self.realtime_conversation.playback_queued_samples, + )), ) { Ok(capture) => capture, Err(err) => { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index f537c95bbc03..6d52e020f14f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -147,6 +147,14 @@ mod voice { pub(crate) struct RealtimeAudioPlayer; + #[derive(Clone)] + pub(crate) enum RealtimeInputBehavior { + Ungated, + PlaybackAware { + playback_queued_samples: Arc, + }, + } + impl VoiceCapture { pub fn start() -> Result { Err("voice input is unavailable in this build".to_string()) @@ -155,7 +163,7 @@ mod voice { pub fn start_realtime( _config: &Config, _tx: AppEventSender, - _playback_queued_samples: Arc, + _input_behavior: RealtimeInputBehavior, ) -> Result { Err("voice input is unavailable in this build".to_string()) } diff --git a/codex-rs/tui/src/voice.rs b/codex-rs/tui/src/voice.rs index ba260b028fbb..ba1b3ea90979 100644 --- a/codex-rs/tui/src/voice.rs +++ b/codex-rs/tui/src/voice.rs @@ -44,6 +44,14 @@ const REALTIME_INTERRUPT_INPUT_PEAK_THRESHOLD: u16 = 4_000; // callbacks so trailing syllables are not chopped up between chunks. const REALTIME_INTERRUPT_GRACE_PERIOD: Duration = Duration::from_millis(900); +#[derive(Clone)] +pub(crate) enum RealtimeInputBehavior { + Ungated, + PlaybackAware { + playback_queued_samples: Arc, + }, +} + struct TranscriptionAuthContext { mode: AuthMode, bearer_token: String, @@ -94,7 +102,7 @@ impl VoiceCapture { pub fn start_realtime( config: &Config, tx: AppEventSender, - playback_queued_samples: Arc, + input_behavior: RealtimeInputBehavior, ) -> Result { let (device, config) = select_configured_input_device_and_config(config)?; @@ -110,7 +118,7 @@ impl VoiceCapture { sample_rate, channels, tx, - playback_queued_samples, + input_behavior, last_peak.clone(), )?; stream @@ -354,7 +362,7 @@ fn build_realtime_input_stream( sample_rate: u32, channels: u16, tx: AppEventSender, - playback_queued_samples: Arc, + input_behavior: RealtimeInputBehavior, last_peak: Arc, ) -> Result { match config.sample_format() { @@ -362,7 +370,7 @@ fn build_realtime_input_stream( .build_input_stream( &config.clone().into(), { - let playback_queued_samples = Arc::clone(&playback_queued_samples); + let input_behavior = input_behavior.clone(); let last_peak = Arc::clone(&last_peak); let tx = tx; let mut allow_input_until = None; @@ -370,9 +378,8 @@ fn build_realtime_input_stream( let peak = peak_f32(input); if !should_send_realtime_input( peak, - &playback_queued_samples, + &input_behavior, &mut allow_input_until, - Instant::now(), ) { last_peak.store(0, Ordering::Relaxed); return; @@ -390,7 +397,7 @@ fn build_realtime_input_stream( .build_input_stream( &config.clone().into(), { - let playback_queued_samples = Arc::clone(&playback_queued_samples); + let input_behavior = input_behavior.clone(); let last_peak = Arc::clone(&last_peak); let tx = tx; let mut allow_input_until = None; @@ -398,9 +405,8 @@ fn build_realtime_input_stream( let peak = peak_i16(input); if !should_send_realtime_input( peak, - &playback_queued_samples, + &input_behavior, &mut allow_input_until, - Instant::now(), ) { last_peak.store(0, Ordering::Relaxed); return; @@ -417,7 +423,7 @@ fn build_realtime_input_stream( .build_input_stream( &config.clone().into(), { - let playback_queued_samples = Arc::clone(&playback_queued_samples); + let input_behavior = input_behavior.clone(); let last_peak = Arc::clone(&last_peak); let tx = tx; let mut allow_input_until = None; @@ -426,9 +432,8 @@ fn build_realtime_input_stream( let peak = convert_u16_to_i16_and_peak(input, &mut samples); if !should_send_realtime_input( peak, - &playback_queued_samples, + &input_behavior, &mut allow_input_until, - Instant::now(), ) { last_peak.store(0, Ordering::Relaxed); return; @@ -739,10 +744,22 @@ fn fill_output_u16( /// utterance reaches the server. fn should_send_realtime_input( peak: u16, - playback_queued_samples: &Arc, + input_behavior: &RealtimeInputBehavior, allow_input_until: &mut Option, - now: Instant, ) -> bool { + if matches!(input_behavior, RealtimeInputBehavior::Ungated) { + *allow_input_until = None; + return true; + } + + let now = Instant::now(); + let RealtimeInputBehavior::PlaybackAware { + playback_queued_samples, + } = input_behavior + else { + unreachable!("ungated realtime input should return early"); + }; + if playback_queued_samples.load(Ordering::Relaxed) == 0 { *allow_input_until = None; return true; @@ -1021,11 +1038,18 @@ async fn transcribe_bytes( #[cfg(test)] mod tests { + use super::RealtimeInputBehavior; use super::RecordedAudio; use super::convert_pcm16; use super::encode_wav_normalized; + use super::should_send_realtime_input; use pretty_assertions::assert_eq; use std::io::Cursor; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use std::time::Duration; + use std::time::Instant; #[test] fn convert_pcm16_downmixes_and_resamples_for_model_input() { @@ -1054,4 +1078,39 @@ mod tests { assert_eq!(spec.sample_rate, 24_000); assert_eq!(samples, vec![8_426, 29_490]); } + + #[test] + fn ungated_realtime_input_ignores_playback_backlog() { + let mut allow_input_until = Some(Instant::now() + Duration::from_secs(1)); + let playback_queued_samples = Arc::new(AtomicUsize::new(1024)); + + assert!(should_send_realtime_input( + 0, + &RealtimeInputBehavior::Ungated, + &mut allow_input_until, + )); + assert_eq!(allow_input_until, None); + assert_eq!(playback_queued_samples.load(Ordering::Relaxed), 1024); + } + + #[test] + fn playback_aware_realtime_input_requires_an_interrupt_peak() { + let mut allow_input_until = None; + let playback_queued_samples = Arc::new(AtomicUsize::new(1024)); + let input_behavior = RealtimeInputBehavior::PlaybackAware { + playback_queued_samples: Arc::clone(&playback_queued_samples), + }; + + assert!(!should_send_realtime_input( + 100, + &input_behavior, + &mut allow_input_until, + )); + assert!(should_send_realtime_input( + 5_000, + &input_behavior, + &mut allow_input_until, + )); + assert!(allow_input_until.is_some()); + } } diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index 0fff49fd20c1..472904c2ab2c 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -46,6 +46,7 @@ use codex_protocol::protocol::PlanDeltaEvent; use codex_protocol::protocol::RealtimeConversationClosedEvent; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeConversationStartedEvent; +use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ThreadNameUpdatedEvent; use codex_protocol::protocol::TokenCountEvent; @@ -393,6 +394,10 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent { session_id: notification.session_id, + version: match notification.version.as_str() { + "v2" => RealtimeConversationVersion::V2, + _ => RealtimeConversationVersion::V1, + }, }), }], )), From c9ad9b4ed8c603bfd13dc2b77fb035132eeb6ff6 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 17 Mar 2026 13:47:33 -0700 Subject: [PATCH 2/6] Use one realtime version enum --- .../schema/json/ServerNotification.json | 9 ++++++++- .../json/codex_app_server_protocol.schemas.json | 9 ++++++++- .../json/codex_app_server_protocol.v2.schemas.json | 9 ++++++++- .../json/v2/ThreadRealtimeStartedNotification.json | 11 ++++++++++- .../schema/typescript/RealtimeConversationVersion.ts | 5 +++++ .../app-server-protocol/schema/typescript/index.ts | 1 + .../v2/ThreadRealtimeStartedNotification.ts | 3 ++- codex-rs/app-server-protocol/src/protocol/common.rs | 3 ++- codex-rs/app-server-protocol/src/protocol/v2.rs | 3 ++- codex-rs/app-server/src/bespoke_event_handling.rs | 6 +----- .../tests/suite/v2/realtime_conversation.rs | 3 ++- codex-rs/core/src/config/mod.rs | 8 +------- codex-rs/core/src/realtime_conversation.rs | 6 +----- codex-rs/protocol/src/protocol.rs | 3 ++- codex-rs/tui_app_server/src/app/app_server_adapter.rs | 6 +----- 15 files changed, 54 insertions(+), 31 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/RealtimeConversationVersion.ts diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index d1ff7a279b95..aa66a83097cc 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1694,6 +1694,13 @@ ], "type": "object" }, + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2" + ], + "type": "string" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -2859,7 +2866,7 @@ "type": "string" }, "version": { - "type": "string" + "$ref": "#/definitions/RealtimeConversationVersion" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 32a787a597c4..7d9ee35b2de4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9785,6 +9785,13 @@ } ] }, + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2" + ], + "type": "string" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -12927,7 +12934,7 @@ "type": "string" }, "version": { - "type": "string" + "$ref": "#/definitions/v2/RealtimeConversationVersion" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 3d4746950747..8081af2e3983 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6573,6 +6573,13 @@ } ] }, + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2" + ], + "type": "string" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -10687,7 +10694,7 @@ "type": "string" }, "version": { - "type": "string" + "$ref": "#/definitions/RealtimeConversationVersion" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRealtimeStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRealtimeStartedNotification.json index ce3cb9dbb3fc..dd94a5cc4985 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRealtimeStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRealtimeStartedNotification.json @@ -1,5 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2" + ], + "type": "string" + } + }, "description": "EXPERIMENTAL - emitted when thread realtime startup is accepted.", "properties": { "sessionId": { @@ -12,7 +21,7 @@ "type": "string" }, "version": { - "type": "string" + "$ref": "#/definitions/RealtimeConversationVersion" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationVersion.ts b/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationVersion.ts new file mode 100644 index 000000000000..cedc4bbe5255 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/RealtimeConversationVersion.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RealtimeConversationVersion = "v1" | "v2"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 396d07e12b9b..09e75abed8cf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -49,6 +49,7 @@ export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { ParsedCommand } from "./ParsedCommand"; export type { Personality } from "./Personality"; export type { PlanType } from "./PlanType"; +export type { RealtimeConversationVersion } from "./RealtimeConversationVersion"; export type { ReasoningEffort } from "./ReasoningEffort"; export type { ReasoningItemContent } from "./ReasoningItemContent"; export type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartedNotification.ts index a34aed8904d4..d4941006115d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadRealtimeStartedNotification.ts @@ -1,8 +1,9 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RealtimeConversationVersion } from "../RealtimeConversationVersion"; /** * EXPERIMENTAL - emitted when thread realtime startup is accepted. */ -export type ThreadRealtimeStartedNotification = { threadId: string, sessionId: string | null, version: string, }; +export type ThreadRealtimeStartedNotification = { threadId: string, sessionId: string | null, version: RealtimeConversationVersion, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 350314fca226..0ecd17b3d3cc 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -946,6 +946,7 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::parse_command::ParsedCommand; + use codex_protocol::protocol::RealtimeConversationVersion; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; @@ -1628,7 +1629,7 @@ mod tests { ServerNotification::ThreadRealtimeStarted(v2::ThreadRealtimeStartedNotification { thread_id: "thr_123".to_string(), session_id: Some("sess_456".to_string()), - version: "v1".to_string(), + version: RealtimeConversationVersion::V1, }); let reason = crate::experimental_api::ExperimentalApi::experimental_reason(¬ification); assert_eq!(reason, Some("thread/realtime/started")); diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 62871a68ee3e..1156ed213f40 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -68,6 +68,7 @@ use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess; use codex_protocol::protocol::RealtimeAudioFrame as CoreRealtimeAudioFrame; +use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::ReviewDecision as CoreReviewDecision; use codex_protocol::protocol::SessionSource as CoreSessionSource; use codex_protocol::protocol::SkillDependencies as CoreSkillDependencies; @@ -3775,7 +3776,7 @@ pub struct ThreadRealtimeStopResponse {} pub struct ThreadRealtimeStartedNotification { pub thread_id: String, pub session_id: Option, - pub version: String, + pub version: RealtimeConversationVersion, } /// EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index d65b56d8b157..8a6b48a47de6 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -121,7 +121,6 @@ use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::Op; -use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; @@ -339,10 +338,7 @@ pub(crate) async fn apply_bespoke_event_handling( let notification = ThreadRealtimeStartedNotification { thread_id: conversation_id.to_string(), session_id: event.session_id, - version: match event.version { - RealtimeConversationVersion::V1 => "v1".to_string(), - RealtimeConversationVersion::V2 => "v2".to_string(), - }, + version: event.version, }; outgoing .send_server_notification(ServerNotification::ThreadRealtimeStarted( diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index a08e73120f5c..50f8a270ccd1 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -25,6 +25,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_core::features::FEATURES; use codex_core::features::Feature; +use codex_protocol::protocol::RealtimeConversationVersion; use core_test_support::responses::start_websocket_server; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -115,7 +116,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { .await?; assert_eq!(started.thread_id, thread_start.thread.id); assert!(started.session_id.is_some()); - assert_eq!(started.version, "v1"); + assert_eq!(started.version, RealtimeConversationVersion::V1); let startup_context_request = realtime_server.wait_for_request(0, 0).await; assert_eq!( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 7a543161e6ca..bc436a0cf92e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1535,13 +1535,7 @@ pub enum RealtimeWsMode { Transcription, } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum RealtimeWsVersion { - #[default] - V1, - V2, -} +pub use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index b4d6b6494fd9..2a8a6337a79c 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -32,7 +32,6 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RealtimeConversationClosedEvent; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeConversationStartedEvent; -use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeHandoffRequested; use http::HeaderMap; use http::HeaderValue; @@ -413,10 +412,7 @@ pub(crate) async fn handle_start( id: sub_id.clone(), msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent { session_id: requested_session_id, - version: match version { - RealtimeWsVersion::V1 => RealtimeConversationVersion::V1, - RealtimeWsVersion::V2 => RealtimeConversationVersion::V2, - }, + version, }), }) .await; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 252626b977b2..f8ea7fec54f4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1446,9 +1446,10 @@ pub struct HookCompletedEvent { pub run: HookRunSummary, } -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum RealtimeConversationVersion { + #[default] V1, V2, } diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index 472904c2ab2c..90a481cc6991 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -46,7 +46,6 @@ use codex_protocol::protocol::PlanDeltaEvent; use codex_protocol::protocol::RealtimeConversationClosedEvent; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeConversationStartedEvent; -use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ThreadNameUpdatedEvent; use codex_protocol::protocol::TokenCountEvent; @@ -394,10 +393,7 @@ fn server_notification_thread_events( id: String::new(), msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent { session_id: notification.session_id, - version: match notification.version.as_str() { - "v2" => RealtimeConversationVersion::V2, - _ => RealtimeConversationVersion::V1, - }, + version: notification.version, }), }], )), From 71c5704719e478b77220931f8cca50a67c576556 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 17 Mar 2026 14:00:12 -0700 Subject: [PATCH 3/6] codex: fix CI failure on PR #14984 --- codex-rs/tui/src/chatwidget/realtime.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index da23d0323e53..892e241836bb 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -55,10 +55,6 @@ impl RealtimeAudioBehavior { } } - fn should_interrupt_playback_on_server_event(self) -> bool { - matches!(self, Self::PlaybackAware) - } - #[cfg(not(target_os = "linux"))] fn input_behavior( self, @@ -312,11 +308,10 @@ impl ChatWidget { } RealtimeEvent::InputAudioSpeechStarted(_) | RealtimeEvent::ResponseCancelled(_) => { #[cfg(not(target_os = "linux"))] - if self - .realtime_conversation - .audio_behavior - .should_interrupt_playback_on_server_event() - && let Some(player) = &self.realtime_conversation.audio_player + if matches!( + self.realtime_conversation.audio_behavior, + RealtimeAudioBehavior::PlaybackAware + ) && let Some(player) = &self.realtime_conversation.audio_player { // Once the server detects user speech or the current response is cancelled, // any buffered assistant audio is stale and should stop gating mic input. From ab631ef8be6c2588e5af79b398f4c63418ac2d5e Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 17 Mar 2026 14:20:12 -0700 Subject: [PATCH 4/6] codex: fix CI failure on PR #14984 --- .../tests/suite/v2/realtime_conversation.rs | 2 +- codex-rs/core/config.schema.json | 16 ++++++++-------- codex-rs/tui/src/voice.rs | 3 --- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 50f8a270ccd1..1c30ee530d11 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -116,7 +116,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { .await?; assert_eq!(started.thread_id, thread_start.thread.id); assert!(started.session_id.is_some()); - assert_eq!(started.version, RealtimeConversationVersion::V1); + assert_eq!(started.version, RealtimeConversationVersion::V2); let startup_context_request = realtime_server.wait_for_request(0, 0).await; assert_eq!( diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7d3ecdaa0124..ea00a7a2a2b2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1359,6 +1359,13 @@ }, "type": "object" }, + "RealtimeConversationVersion": { + "enum": [ + "v1", + "v2" + ], + "type": "string" + }, "RealtimeToml": { "additionalProperties": false, "properties": { @@ -1366,7 +1373,7 @@ "$ref": "#/definitions/RealtimeWsMode" }, "version": { - "$ref": "#/definitions/RealtimeWsVersion" + "$ref": "#/definitions/RealtimeConversationVersion" } }, "type": "object" @@ -1378,13 +1385,6 @@ ], "type": "string" }, - "RealtimeWsVersion": { - "enum": [ - "v1", - "v2" - ], - "type": "string" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ diff --git a/codex-rs/tui/src/voice.rs b/codex-rs/tui/src/voice.rs index ba1b3ea90979..fd675fbf444a 100644 --- a/codex-rs/tui/src/voice.rs +++ b/codex-rs/tui/src/voice.rs @@ -370,7 +370,6 @@ fn build_realtime_input_stream( .build_input_stream( &config.clone().into(), { - let input_behavior = input_behavior.clone(); let last_peak = Arc::clone(&last_peak); let tx = tx; let mut allow_input_until = None; @@ -397,7 +396,6 @@ fn build_realtime_input_stream( .build_input_stream( &config.clone().into(), { - let input_behavior = input_behavior.clone(); let last_peak = Arc::clone(&last_peak); let tx = tx; let mut allow_input_until = None; @@ -423,7 +421,6 @@ fn build_realtime_input_stream( .build_input_stream( &config.clone().into(), { - let input_behavior = input_behavior.clone(); let last_peak = Arc::clone(&last_peak); let tx = tx; let mut allow_input_until = None; From 733123bf5dd5cc733a8e0a46418430c834921916 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 17 Mar 2026 14:49:19 -0700 Subject: [PATCH 5/6] codex: address PR review feedback (#14984) --- codex-rs/tui/src/voice.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/voice.rs b/codex-rs/tui/src/voice.rs index fd675fbf444a..510010c3038e 100644 --- a/codex-rs/tui/src/voice.rs +++ b/codex-rs/tui/src/voice.rs @@ -744,18 +744,17 @@ fn should_send_realtime_input( input_behavior: &RealtimeInputBehavior, allow_input_until: &mut Option, ) -> bool { - if matches!(input_behavior, RealtimeInputBehavior::Ungated) { - *allow_input_until = None; - return true; - } + let playback_queued_samples = match input_behavior { + RealtimeInputBehavior::Ungated => { + *allow_input_until = None; + return true; + } + RealtimeInputBehavior::PlaybackAware { + playback_queued_samples, + } => playback_queued_samples, + }; let now = Instant::now(); - let RealtimeInputBehavior::PlaybackAware { - playback_queued_samples, - } = input_behavior - else { - unreachable!("ungated realtime input should return early"); - }; if playback_queued_samples.load(Ordering::Relaxed) == 0 { *allow_input_until = None; From ec2a8e676e62e69e98d74d556fcc27bb5e2a0cf4 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 17 Mar 2026 15:04:20 -0700 Subject: [PATCH 6/6] codex: fix CI failure on PR #14984 --- codex-rs/core/src/auth_env_telemetry.rs | 86 +++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 codex-rs/core/src/auth_env_telemetry.rs diff --git a/codex-rs/core/src/auth_env_telemetry.rs b/codex-rs/core/src/auth_env_telemetry.rs new file mode 100644 index 000000000000..85cd23fe06f7 --- /dev/null +++ b/codex-rs/core/src/auth_env_telemetry.rs @@ -0,0 +1,86 @@ +use codex_otel::AuthEnvTelemetryMetadata; + +use crate::auth::CODEX_API_KEY_ENV_VAR; +use crate::auth::OPENAI_API_KEY_ENV_VAR; +use crate::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use crate::model_provider_info::ModelProviderInfo; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct AuthEnvTelemetry { + pub(crate) openai_api_key_env_present: bool, + pub(crate) codex_api_key_env_present: bool, + pub(crate) codex_api_key_env_enabled: bool, + pub(crate) provider_env_key_name: Option, + pub(crate) provider_env_key_present: Option, + pub(crate) refresh_token_url_override_present: bool, +} + +impl AuthEnvTelemetry { + pub(crate) fn to_otel_metadata(&self) -> AuthEnvTelemetryMetadata { + AuthEnvTelemetryMetadata { + openai_api_key_env_present: self.openai_api_key_env_present, + codex_api_key_env_present: self.codex_api_key_env_present, + codex_api_key_env_enabled: self.codex_api_key_env_enabled, + provider_env_key_name: self.provider_env_key_name.clone(), + provider_env_key_present: self.provider_env_key_present, + refresh_token_url_override_present: self.refresh_token_url_override_present, + } + } +} + +pub(crate) fn collect_auth_env_telemetry( + provider: &ModelProviderInfo, + codex_api_key_env_enabled: bool, +) -> AuthEnvTelemetry { + AuthEnvTelemetry { + openai_api_key_env_present: env_var_present(OPENAI_API_KEY_ENV_VAR), + codex_api_key_env_present: env_var_present(CODEX_API_KEY_ENV_VAR), + codex_api_key_env_enabled, + // Custom provider `env_key` is arbitrary config text, so emit only a safe bucket. + provider_env_key_name: provider.env_key.as_ref().map(|_| "configured".to_string()), + provider_env_key_present: provider.env_key.as_deref().map(env_var_present), + refresh_token_url_override_present: env_var_present(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR), + } +} + +fn env_var_present(name: &str) -> bool { + match std::env::var(name) { + Ok(value) => !value.trim().is_empty(), + Err(std::env::VarError::NotUnicode(_)) => true, + Err(std::env::VarError::NotPresent) => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn collect_auth_env_telemetry_buckets_provider_env_key_name() { + let provider = ModelProviderInfo { + name: "Custom".to_string(), + base_url: None, + env_key: Some("sk-should-not-leak".to_string()), + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: crate::model_provider_info::WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: false, + }; + + let telemetry = collect_auth_env_telemetry(&provider, false); + + assert_eq!( + telemetry.provider_env_key_name, + Some("configured".to_string()) + ); + } +}