Skip to content

Commit f2bef15

Browse files
aibrahim-oaicodex
andcommitted
Support ChatGPT realtime calls auth
Route realtime call auth through CoreAuthProvider and send JSON payloads for non-v1 realtime calls while preserving the v1 multipart request shape. Co-authored-by: Codex <noreply@openai.com>
1 parent 7cbc22a commit f2bef15

File tree

5 files changed

+168
-67
lines changed

5 files changed

+168
-67
lines changed

codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs

Lines changed: 139 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
use crate::api_bridge::CoreAuthProvider;
2+
use crate::auth::add_auth_headers_to_header_map;
13
use crate::endpoint::realtime_websocket::methods_common::conversation_handoff_append_message;
24
use crate::endpoint::realtime_websocket::methods_common::conversation_item_create_message;
35
use crate::endpoint::realtime_websocket::methods_common::normalized_session_mode;
46
use crate::endpoint::realtime_websocket::methods_common::session_update_session;
7+
use crate::endpoint::realtime_websocket::methods_common::webrtc_intent;
58
use crate::endpoint::realtime_websocket::protocol::RealtimeAudioFrame;
69
use crate::endpoint::realtime_websocket::protocol::RealtimeEvent;
710
use crate::endpoint::realtime_websocket::protocol::RealtimeEventParser;
@@ -25,6 +28,9 @@ use opus::Application;
2528
use opus::Channels;
2629
use opus::Decoder as OpusDecoder;
2730
use opus::Encoder as OpusEncoder;
31+
use reqwest::multipart::Form;
32+
use reqwest::multipart::Part;
33+
use serde_json::json;
2834
use std::collections::HashMap;
2935
use std::sync::Arc;
3036
use std::sync::Mutex as StdMutex;
@@ -362,12 +368,13 @@ fn append_transcript_delta(entries: &mut Vec<RealtimeTranscriptEntry>, role: &st
362368
}
363369

364370
pub struct RealtimeWebRtcClient {
371+
auth: CoreAuthProvider,
365372
provider: Provider,
366373
}
367374

368375
impl RealtimeWebRtcClient {
369-
pub fn new(provider: Provider) -> Self {
370-
Self { provider }
376+
pub fn new(provider: Provider, auth: CoreAuthProvider) -> Self {
377+
Self { auth, provider }
371378
}
372379

373380
pub async fn connect(
@@ -380,9 +387,12 @@ impl RealtimeWebRtcClient {
380387
let calls_url = calls_url_from_api_url(
381388
self.provider.base_url.as_str(),
382389
self.provider.query_params.as_ref(),
390+
config.model.as_deref(),
391+
config.event_parser,
383392
)?;
384393

385394
let headers = merge_request_headers(
395+
&self.auth,
386396
&self.provider.headers,
387397
with_session_id_header(extra_headers, config.session_id.as_deref())?,
388398
default_headers,
@@ -412,12 +422,14 @@ impl RealtimeWebRtcClient {
412422
}
413423

414424
fn merge_request_headers(
425+
auth: &CoreAuthProvider,
415426
provider_headers: &HeaderMap,
416427
extra_headers: HeaderMap,
417428
default_headers: HeaderMap,
418429
) -> HeaderMap {
419430
let mut headers = provider_headers.clone();
420431
headers.extend(extra_headers);
432+
add_auth_headers_to_header_map(auth, &mut headers);
421433
for (name, value) in &default_headers {
422434
if let http::header::Entry::Vacant(entry) = headers.entry(name) {
423435
entry.insert(value.clone());
@@ -445,6 +457,8 @@ fn with_session_id_header(
445457
fn calls_url_from_api_url(
446458
api_url: &str,
447459
query_params: Option<&HashMap<String, String>>,
460+
model: Option<&str>,
461+
event_parser: RealtimeEventParser,
448462
) -> Result<Url, ApiError> {
449463
let mut url = Url::parse(api_url)
450464
.map_err(|err| ApiError::Stream(format!("failed to parse realtime api_url: {err}")))?;
@@ -466,10 +480,27 @@ fn calls_url_from_api_url(
466480
}
467481
}
468482

469-
if let Some(query_params) = query_params {
483+
let intent = webrtc_intent(event_parser);
484+
let has_extra_query_params = query_params.is_some_and(|query_params| {
485+
query_params
486+
.iter()
487+
.any(|(key, _)| key != "intent" && !(key == "model" && model.is_some()))
488+
});
489+
if intent.is_some() || model.is_some() || has_extra_query_params {
470490
let mut query = url.query_pairs_mut();
471-
for (key, value) in query_params {
472-
query.append_pair(key, value);
491+
if let Some(intent) = intent {
492+
query.append_pair("intent", intent);
493+
}
494+
if let Some(model) = model {
495+
query.append_pair("model", model);
496+
}
497+
if let Some(query_params) = query_params {
498+
for (key, value) in query_params {
499+
if key == "intent" || (key == "model" && model.is_some()) {
500+
continue;
501+
}
502+
query.append_pair(key, value);
503+
}
473504
}
474505
}
475506

@@ -509,7 +540,10 @@ fn normalize_realtime_calls_path(url: &mut Url) {
509540

510541
if path.ends_with("/v1/") {
511542
url.set_path(&format!("{path}realtime/calls"));
543+
return;
512544
}
545+
546+
url.set_path(&format!("{}/realtime/calls", path.trim_end_matches('/')));
513547
}
514548

515549
async fn connect_webrtc_transport(
@@ -814,18 +848,44 @@ async fn fetch_realtime_answer(
814848
{
815849
session_json.insert("model".to_string(), serde_json::Value::String(model));
816850
}
817-
let session_payload = serde_json::to_string(&session_json)
818-
.map_err(|err| ApiError::Stream(format!("failed to encode realtime session: {err}")))?;
819-
let form = reqwest::multipart::Form::new()
820-
.text("sdp", offer_sdp)
821-
.text("session", session_payload);
822-
let response = client
823-
.post(calls_url)
824-
.headers(headers)
825-
.multipart(form)
826-
.send()
827-
.await
828-
.map_err(|err| ApiError::Stream(format!("failed to create realtime WebRTC call: {err}")))?;
851+
let response = if calls_url.path().ends_with("/v1/realtime/calls") {
852+
let session_payload = serde_json::to_string(&session_json)
853+
.map_err(|err| ApiError::Stream(format!("failed to encode realtime session: {err}")))?;
854+
let form = Form::new()
855+
.part(
856+
"sdp",
857+
Part::text(offer_sdp)
858+
.mime_str("application/sdp")
859+
.map_err(|err| {
860+
ApiError::Stream(format!("failed to encode realtime SDP part: {err}"))
861+
})?,
862+
)
863+
.part(
864+
"session",
865+
Part::text(session_payload)
866+
.mime_str("application/json")
867+
.map_err(|err| {
868+
ApiError::Stream(format!("failed to encode realtime session part: {err}"))
869+
})?,
870+
);
871+
client
872+
.post(calls_url)
873+
.headers(headers)
874+
.multipart(form)
875+
.send()
876+
.await
877+
} else {
878+
client
879+
.post(calls_url)
880+
.headers(headers)
881+
.json(&json!({
882+
"sdp": offer_sdp,
883+
"session": session_json,
884+
}))
885+
.send()
886+
.await
887+
}
888+
.map_err(|err| ApiError::Stream(format!("failed to create realtime WebRTC call: {err}")))?;
829889
let status = response.status();
830890
let body = response.text().await.map_err(|err| {
831891
ApiError::Stream(format!("failed to read realtime WebRTC answer SDP: {err}"))
@@ -893,8 +953,13 @@ mod tests {
893953
#[test]
894954
fn calls_url_from_api_url_normalizes_http_root() {
895955
let query_params = HashMap::from([("model".to_string(), "gpt-realtime".to_string())]);
896-
let calls_url =
897-
calls_url_from_api_url("http://example.com", Some(&query_params)).expect("calls url");
956+
let calls_url = calls_url_from_api_url(
957+
"http://example.com",
958+
Some(&query_params),
959+
Some("gpt-realtime"),
960+
RealtimeEventParser::RealtimeV2,
961+
)
962+
.expect("calls url");
898963

899964
assert_eq!(
900965
calls_url.as_str(),
@@ -905,16 +970,68 @@ mod tests {
905970
#[test]
906971
fn calls_url_from_api_url_preserves_v1_realtime_path_and_query() {
907972
let query_params = HashMap::from([("model".to_string(), "gpt-realtime".to_string())]);
908-
let calls_url =
909-
calls_url_from_api_url("wss://example.com/v1/realtime?foo=bar", Some(&query_params))
910-
.expect("calls url");
973+
let calls_url = calls_url_from_api_url(
974+
"wss://example.com/v1/realtime?foo=bar",
975+
Some(&query_params),
976+
Some("gpt-realtime"),
977+
RealtimeEventParser::RealtimeV2,
978+
)
979+
.expect("calls url");
911980

912981
assert_eq!(
913982
calls_url.as_str(),
914983
"https://example.com/v1/realtime/calls?foo=bar&model=gpt-realtime"
915984
);
916985
}
917986

987+
#[test]
988+
fn calls_url_from_api_url_appends_quicksilver_intent_for_v1() {
989+
let calls_url = calls_url_from_api_url(
990+
"wss://example.com/v1/realtime",
991+
None,
992+
Some("quicksilver-test-model"),
993+
RealtimeEventParser::V1,
994+
)
995+
.expect("calls url");
996+
997+
assert_eq!(
998+
calls_url.as_str(),
999+
"https://example.com/v1/realtime/calls?intent=quicksilver&model=quicksilver-test-model"
1000+
);
1001+
}
1002+
1003+
#[test]
1004+
fn calls_url_from_api_url_omits_intent_for_v2() {
1005+
let calls_url = calls_url_from_api_url(
1006+
"wss://example.com/v1/realtime",
1007+
None,
1008+
Some("gpt-realtime"),
1009+
RealtimeEventParser::RealtimeV2,
1010+
)
1011+
.expect("calls url");
1012+
1013+
assert_eq!(
1014+
calls_url.as_str(),
1015+
"https://example.com/v1/realtime/calls?model=gpt-realtime"
1016+
);
1017+
}
1018+
1019+
#[test]
1020+
fn calls_url_from_api_url_appends_calls_path_to_chatgpt_base_url() {
1021+
let calls_url = calls_url_from_api_url(
1022+
"https://chatgpt.com/backend-api/codex",
1023+
None,
1024+
Some("gpt-realtime"),
1025+
RealtimeEventParser::RealtimeV2,
1026+
)
1027+
.expect("calls url");
1028+
1029+
assert_eq!(
1030+
calls_url.as_str(),
1031+
"https://chatgpt.com/backend-api/codex/realtime/calls?model=gpt-realtime"
1032+
);
1033+
}
1034+
9181035
#[test]
9191036
fn parse_session_updated_event() {
9201037
let payload = json!({

codex-rs/codex-api/src/endpoint/realtime_websocket/methods_common.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
use crate::endpoint::realtime_websocket::methods_v1::conversation_handoff_append_message as v1_conversation_handoff_append_message;
22
use crate::endpoint::realtime_websocket::methods_v1::conversation_item_create_message as v1_conversation_item_create_message;
33
use crate::endpoint::realtime_websocket::methods_v1::session_update_session as v1_session_update_session;
4+
use crate::endpoint::realtime_websocket::methods_v1::webrtc_intent as v1_webrtc_intent;
45
use crate::endpoint::realtime_websocket::methods_v2::conversation_handoff_append_message as v2_conversation_handoff_append_message;
56
use crate::endpoint::realtime_websocket::methods_v2::conversation_item_create_message as v2_conversation_item_create_message;
67
use crate::endpoint::realtime_websocket::methods_v2::session_update_session as v2_session_update_session;
8+
use crate::endpoint::realtime_websocket::methods_v2::webrtc_intent as v2_webrtc_intent;
79
use crate::endpoint::realtime_websocket::protocol::RealtimeEventParser;
810
use crate::endpoint::realtime_websocket::protocol::RealtimeOutboundMessage;
911
use crate::endpoint::realtime_websocket::protocol::RealtimeSessionMode;
@@ -57,3 +59,10 @@ pub(super) fn session_update_session(
5759
RealtimeEventParser::RealtimeV2 => v2_session_update_session(instructions, session_mode),
5860
}
5961
}
62+
63+
pub(super) fn webrtc_intent(event_parser: RealtimeEventParser) -> Option<&'static str> {
64+
match event_parser {
65+
RealtimeEventParser::V1 => v1_webrtc_intent(),
66+
RealtimeEventParser::RealtimeV2 => v2_webrtc_intent(),
67+
}
68+
}

codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,7 @@ pub(super) fn session_update_session(instructions: String) -> SessionUpdateSessi
6161
tool_choice: None,
6262
}
6363
}
64+
65+
pub(super) fn webrtc_intent() -> Option<&'static str> {
66+
Some("quicksilver")
67+
}

codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v2.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,7 @@ pub(super) fn session_update_session(
126126
},
127127
}
128128
}
129+
130+
pub(super) fn webrtc_intent() -> Option<&'static str> {
131+
None
132+
}

0 commit comments

Comments
 (0)