mirror of
https://github.com/openai/codex.git
synced 2026-11-13 21:30:47 +00:00
Update realtime model and honor provider-specific voice defaults (#61259)
## Why An unset voice preference should use the call destination's default. Changed or cleared preferences should also take effect on the next V3 call, even when the thread retains an older setting. ## What changed - Use `gpt-live-1.1-codex` as the default V3 realtime model. - Preserve an unset voice until request serialization, defaulting V3 API calls to `marin` and ChatGPT calls to `cove`, while honoring explicit voice selections. - Reload current voice settings when starting a new V3 call through the app server, including cleared preferences. - Populate the TUI voice picker from the V3 catalog, add `marin` to that catalog, and remove voice-catalog lookups from call startup and preference saving. ## Testing Add regression coverage for API and ChatGPT voice defaults, explicit overrides, changed and cleared settings, and TUI call requests and voice selection. GitOrigin-RevId: d0dd4dcd4bcad9ab869d7bf524e7095a88ffcfaa
This commit is contained in:
@@ -10,6 +10,7 @@ use codex_client::HttpTransport;
|
||||
use codex_client::Request;
|
||||
use codex_client::RequestBody;
|
||||
use codex_client::RequestTelemetry;
|
||||
use codex_protocol::protocol::RealtimeVoice;
|
||||
use http::HeaderMap;
|
||||
use http::HeaderValue;
|
||||
use http::Method;
|
||||
@@ -129,7 +130,7 @@ impl<T: HttpTransport> RealtimeCallClient<T> {
|
||||
pub async fn create_with_session_and_headers(
|
||||
&self,
|
||||
sdp: String,
|
||||
session_config: RealtimeSessionConfig,
|
||||
mut session_config: RealtimeSessionConfig,
|
||||
extra_headers: HeaderMap,
|
||||
) -> Result<RealtimeCallResponse, ApiError> {
|
||||
trace!(target: "codex_api::realtime_websocket::wire", "realtime call request SDP: {sdp}");
|
||||
@@ -137,6 +138,10 @@ impl<T: HttpTransport> RealtimeCallClient<T> {
|
||||
// session payload is sent with call creation. Legacy sidebands still send session.update
|
||||
// after joining; Frameless sidebands attach to the session that is already running.
|
||||
validate_avas_session_config(&session_config)?;
|
||||
if self.uses_backend_request_shape() {
|
||||
// ChatGPT keeps its own default; API calls use the wire adapter's default.
|
||||
session_config.voice.get_or_insert(RealtimeVoice::Cove);
|
||||
}
|
||||
let event_parser = session_config.event_parser;
|
||||
let path = self.path_for_session(event_parser);
|
||||
let mut session = realtime_session_json(session_config)?;
|
||||
@@ -396,14 +401,14 @@ mod tests {
|
||||
event_parser: RealtimeEventParser::V1,
|
||||
session_mode: RealtimeSessionMode::Conversational,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
voice: RealtimeVoice::Cove,
|
||||
voice: Some(RealtimeVoice::Cove),
|
||||
}
|
||||
}
|
||||
|
||||
fn realtime_v2_session_config(session_id: &str) -> RealtimeSessionConfig {
|
||||
RealtimeSessionConfig {
|
||||
event_parser: RealtimeEventParser::RealtimeV2,
|
||||
voice: RealtimeVoice::Marin,
|
||||
voice: Some(RealtimeVoice::Marin),
|
||||
..realtime_session_config(session_id)
|
||||
}
|
||||
}
|
||||
@@ -559,32 +564,38 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn sends_frameless_session_call_to_live_without_legacy_query_params() {
|
||||
let transport = CapturingTransport::with_location("/v1/live/rtc_frameless");
|
||||
let client = RealtimeCallClient::new(
|
||||
transport.clone(),
|
||||
provider("https://api.openai.com/v1"),
|
||||
Arc::new(DummyAuth),
|
||||
);
|
||||
for (voice, expected_voice) in [
|
||||
(None, "marin"),
|
||||
(Some(RealtimeVoice::Marin), "marin"),
|
||||
(Some(RealtimeVoice::Cove), "cove"),
|
||||
] {
|
||||
let transport = CapturingTransport::with_location("/v1/live/rtc_frameless");
|
||||
let client = RealtimeCallClient::new(
|
||||
transport.clone(),
|
||||
provider("https://api.openai.com/v1"),
|
||||
Arc::new(DummyAuth),
|
||||
);
|
||||
|
||||
let response = client
|
||||
.create_with_session(
|
||||
"v=offer\r\n".to_string(),
|
||||
frameless_bidi_session_config("sess-api"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
let mut session_config = frameless_bidi_session_config("sess-api");
|
||||
session_config.voice = voice;
|
||||
let response = client
|
||||
.create_with_session("v=offer\r\n".to_string(), session_config)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.call_id, "rtc_frameless");
|
||||
let request = transport.last_request.lock().unwrap().clone().unwrap();
|
||||
assert_eq!(request.method, Method::POST);
|
||||
assert_eq!(request.url, "https://api.openai.com/v1/live");
|
||||
let Some(RequestBody::Raw(body)) = request.body else {
|
||||
panic!("multipart body should be raw");
|
||||
};
|
||||
let body = std::str::from_utf8(&body).expect("multipart body should be utf-8");
|
||||
assert!(body.contains("\"model\":\"gpt-realtime\""));
|
||||
assert!(body.contains("\"delegation\":{\"type\":\"client\"}"));
|
||||
assert!(!body.contains("\"id\":\"sess-api\""));
|
||||
assert_eq!(response.call_id, "rtc_frameless");
|
||||
let request = transport.last_request.lock().unwrap().clone().unwrap();
|
||||
assert_eq!(request.method, Method::POST);
|
||||
assert_eq!(request.url, "https://api.openai.com/v1/live");
|
||||
let Some(RequestBody::Raw(body)) = request.body else {
|
||||
panic!("multipart body should be raw");
|
||||
};
|
||||
let body = std::str::from_utf8(&body).expect("multipart body should be utf-8");
|
||||
assert!(body.contains("\"model\":\"gpt-realtime\""));
|
||||
assert!(body.contains("\"delegation\":{\"type\":\"client\"}"));
|
||||
assert!(!body.contains("\"id\":\"sess-api\""));
|
||||
assert!(body.contains(&format!("\"voice\":\"{expected_voice}\"")));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -696,56 +707,60 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn sends_backend_frameless_session_call_to_realtime_calls() {
|
||||
let transport = CapturingTransport::with_location("/v1/live/rtc_backend_frameless");
|
||||
let client = RealtimeCallClient::new(
|
||||
transport.clone(),
|
||||
provider("https://chatgpt.com/backend-api/codex"),
|
||||
Arc::new(DummyAuth),
|
||||
);
|
||||
let mut session_config = frameless_bidi_session_config("sess-backend");
|
||||
session_config.initial_items = vec![
|
||||
ConversationTextParams {
|
||||
text: "Remember this.".to_string(),
|
||||
role: ConversationTextRole::Developer,
|
||||
},
|
||||
ConversationTextParams {
|
||||
text: "Understood.".to_string(),
|
||||
role: ConversationTextRole::Assistant,
|
||||
},
|
||||
];
|
||||
|
||||
let response = client
|
||||
.create_with_session("v=offer\r\n".to_string(), session_config)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.call_id, "rtc_backend_frameless");
|
||||
let request = transport.last_request.lock().unwrap().clone().unwrap();
|
||||
assert_eq!(request.method, Method::POST);
|
||||
assert_eq!(
|
||||
request.url,
|
||||
"https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas"
|
||||
);
|
||||
let Some(RequestBody::Json(body)) = request.body else {
|
||||
panic!("backend request body should be JSON");
|
||||
};
|
||||
assert_eq!(body["session"]["delegation"]["type"], "client");
|
||||
assert!(body["session"].get("id").is_none());
|
||||
assert_eq!(
|
||||
body["session"]["initial_items"],
|
||||
serde_json::json!([
|
||||
{
|
||||
"type": "message",
|
||||
"role": "developer",
|
||||
"content": [{"type": "input_text", "text": "Remember this."}],
|
||||
for (voice, expected_voice) in [(None, "cove"), (Some(RealtimeVoice::Maple), "maple")] {
|
||||
let transport = CapturingTransport::with_location("/v1/live/rtc_backend_frameless");
|
||||
let client = RealtimeCallClient::new(
|
||||
transport.clone(),
|
||||
provider("https://chatgpt.com/backend-api/codex"),
|
||||
Arc::new(DummyAuth),
|
||||
);
|
||||
let mut session_config = frameless_bidi_session_config("sess-backend");
|
||||
session_config.voice = voice;
|
||||
session_config.initial_items = vec![
|
||||
ConversationTextParams {
|
||||
text: "Remember this.".to_string(),
|
||||
role: ConversationTextRole::Developer,
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "Understood."}],
|
||||
ConversationTextParams {
|
||||
text: "Understood.".to_string(),
|
||||
role: ConversationTextRole::Assistant,
|
||||
},
|
||||
])
|
||||
);
|
||||
];
|
||||
|
||||
let response = client
|
||||
.create_with_session("v=offer\r\n".to_string(), session_config)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.call_id, "rtc_backend_frameless");
|
||||
let request = transport.last_request.lock().unwrap().clone().unwrap();
|
||||
assert_eq!(request.method, Method::POST);
|
||||
assert_eq!(
|
||||
request.url,
|
||||
"https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas"
|
||||
);
|
||||
let Some(RequestBody::Json(body)) = request.body else {
|
||||
panic!("backend request body should be JSON");
|
||||
};
|
||||
assert_eq!(body["session"]["delegation"]["type"], "client");
|
||||
assert_eq!(body["session"]["audio"]["output"]["voice"], expected_voice);
|
||||
assert!(body["session"].get("id").is_none());
|
||||
assert_eq!(
|
||||
body["session"]["initial_items"],
|
||||
serde_json::json!([
|
||||
{
|
||||
"type": "message",
|
||||
"role": "developer",
|
||||
"content": [{"type": "input_text", "text": "Remember this."}],
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "Understood."}],
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1154,6 +1154,7 @@ impl RealtimeWebsocketClient {
|
||||
RealtimeSessionInitialization::ExistingCall => false,
|
||||
};
|
||||
if initialize_session {
|
||||
let voice = config.voice_or_default();
|
||||
debug!(
|
||||
session_id = config.session_id.as_deref().unwrap_or("<none>"),
|
||||
"realtime websocket sending session.update"
|
||||
@@ -1165,7 +1166,7 @@ impl RealtimeWebsocketClient {
|
||||
config.initial_items,
|
||||
config.session_mode,
|
||||
config.output_modality,
|
||||
config.voice,
|
||||
voice,
|
||||
config.delegation_ack_filler,
|
||||
)
|
||||
.await?;
|
||||
@@ -2590,7 +2591,7 @@ mod tests {
|
||||
event_parser: RealtimeEventParser::V1,
|
||||
session_mode: RealtimeSessionMode::Conversational,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
voice: RealtimeVoice::Breeze,
|
||||
voice: Some(RealtimeVoice::Breeze),
|
||||
},
|
||||
HeaderMap::new(),
|
||||
HeaderMap::new(),
|
||||
@@ -2916,7 +2917,7 @@ mod tests {
|
||||
event_parser: RealtimeEventParser::RealtimeV2,
|
||||
session_mode: RealtimeSessionMode::Conversational,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
voice: RealtimeVoice::Cedar,
|
||||
voice: Some(RealtimeVoice::Cedar),
|
||||
},
|
||||
HeaderMap::new(),
|
||||
HeaderMap::new(),
|
||||
@@ -3043,7 +3044,7 @@ mod tests {
|
||||
event_parser: RealtimeEventParser::RealtimeV2,
|
||||
session_mode: RealtimeSessionMode::Transcription,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
voice: RealtimeVoice::Marin,
|
||||
voice: Some(RealtimeVoice::Marin),
|
||||
},
|
||||
HeaderMap::new(),
|
||||
HeaderMap::new(),
|
||||
@@ -3149,7 +3150,7 @@ mod tests {
|
||||
event_parser: RealtimeEventParser::V1,
|
||||
session_mode: RealtimeSessionMode::Transcription,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
voice: RealtimeVoice::Cove,
|
||||
voice: Some(RealtimeVoice::Cove),
|
||||
},
|
||||
HeaderMap::new(),
|
||||
HeaderMap::new(),
|
||||
@@ -3241,7 +3242,7 @@ mod tests {
|
||||
event_parser: RealtimeEventParser::V1,
|
||||
session_mode: RealtimeSessionMode::Conversational,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
voice: RealtimeVoice::Cove,
|
||||
voice: Some(RealtimeVoice::Cove),
|
||||
},
|
||||
HeaderMap::new(),
|
||||
HeaderMap::new(),
|
||||
|
||||
@@ -70,7 +70,7 @@ fn session() -> RealtimeSessionConfig {
|
||||
event_parser: RealtimeEventParser::V1,
|
||||
session_mode: RealtimeSessionMode::Conversational,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
voice: RealtimeVoice::Cedar,
|
||||
voice: Some(RealtimeVoice::Cedar),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,17 +138,16 @@ pub(super) fn session_update_message(
|
||||
}
|
||||
|
||||
pub fn session_update_session_json(config: RealtimeSessionConfig) -> JsonResult<Value> {
|
||||
let voice = config.voice_or_default();
|
||||
match config.event_parser {
|
||||
RealtimeWireAdapter::V1 | RealtimeWireAdapter::RealtimeV2 => {
|
||||
let mut session = match config.event_parser {
|
||||
RealtimeWireAdapter::V1 => {
|
||||
v1_session_update_session(config.instructions, config.voice)
|
||||
}
|
||||
RealtimeWireAdapter::V1 => v1_session_update_session(config.instructions, voice),
|
||||
RealtimeWireAdapter::RealtimeV2 => v2_session_update_session(
|
||||
config.instructions,
|
||||
config.session_mode,
|
||||
config.output_modality,
|
||||
config.voice,
|
||||
voice,
|
||||
),
|
||||
RealtimeWireAdapter::FramelessBidi => unreachable!(),
|
||||
};
|
||||
@@ -160,7 +159,7 @@ pub fn session_update_session_json(config: RealtimeSessionConfig) -> JsonResult<
|
||||
config.model,
|
||||
config.instructions,
|
||||
config.initial_items,
|
||||
config.voice,
|
||||
voice,
|
||||
config.delegation_ack_filler,
|
||||
)),
|
||||
}
|
||||
|
||||
@@ -44,7 +44,19 @@ pub struct RealtimeSessionConfig {
|
||||
pub event_parser: RealtimeEventParser,
|
||||
pub session_mode: RealtimeSessionMode,
|
||||
pub output_modality: RealtimeOutputModality,
|
||||
pub voice: RealtimeVoice,
|
||||
/// Leave unset to select the default for the API or ChatGPT call destination.
|
||||
pub voice: Option<RealtimeVoice>,
|
||||
}
|
||||
|
||||
impl RealtimeSessionConfig {
|
||||
pub(super) fn voice_or_default(&self) -> RealtimeVoice {
|
||||
self.voice.unwrap_or(match self.event_parser {
|
||||
RealtimeEventParser::V1 => RealtimeVoice::Cove,
|
||||
RealtimeEventParser::FramelessBidi | RealtimeEventParser::RealtimeV2 => {
|
||||
RealtimeVoice::Marin
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
Reference in New Issue
Block a user