Skip to main content

codex_api/endpoint/
realtime_call.rs

1use crate::auth::SharedAuthProvider;
2use crate::endpoint::realtime_websocket::RealtimeEventParser;
3use crate::endpoint::realtime_websocket::RealtimeSessionConfig;
4use crate::endpoint::realtime_websocket::session_update_session_json;
5use crate::endpoint::session::EndpointSession;
6use crate::error::ApiError;
7use crate::provider::Provider;
8use bytes::Bytes;
9use codex_client::HttpTransport;
10use codex_client::Request;
11use codex_client::RequestBody;
12use codex_client::RequestTelemetry;
13use http::HeaderMap;
14use http::HeaderValue;
15use http::Method;
16use http::header::CONTENT_TYPE;
17use http::header::LOCATION;
18use serde::Serialize;
19use serde_json::Value;
20use serde_json::to_string;
21use serde_json::to_value;
22use std::sync::Arc;
23use tracing::instrument;
24use tracing::trace;
25
26const MULTIPART_BOUNDARY: &str = "codex-realtime-call-boundary";
27const MULTIPART_CONTENT_TYPE: &str = "multipart/form-data; boundary=codex-realtime-call-boundary";
28
29pub struct RealtimeCallClient<T: HttpTransport> {
30    session: EndpointSession<T>,
31}
32
33/// Answer from creating a WebRTC Realtime call.
34///
35/// `sdp` configures the peer connection. `call_id` is parsed from the response `Location` header
36/// and is later used by the server-side sideband WebSocket to join this exact call.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct RealtimeCallResponse {
39    pub sdp: String,
40    pub call_id: String,
41}
42
43#[derive(Serialize)]
44struct BackendRealtimeCallRequest<'a> {
45    sdp: &'a str,
46    session: &'a Value,
47}
48
49impl<T: HttpTransport> RealtimeCallClient<T> {
50    pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self {
51        Self {
52            session: EndpointSession::new(transport, provider, auth),
53        }
54    }
55
56    pub fn with_telemetry(self, request: Option<Arc<dyn RequestTelemetry>>) -> Self {
57        Self {
58            session: self.session.with_request_telemetry(request),
59        }
60    }
61
62    fn path() -> &'static str {
63        "realtime/calls"
64    }
65
66    fn path_for_session(&self, event_parser: RealtimeEventParser) -> &'static str {
67        if self.uses_backend_request_shape() {
68            return Self::path();
69        }
70
71        match event_parser {
72            RealtimeEventParser::FramelessBidi => "live",
73            RealtimeEventParser::V1 | RealtimeEventParser::RealtimeV2 => Self::path(),
74        }
75    }
76
77    fn uses_backend_request_shape(&self) -> bool {
78        self.session.provider().base_url.contains("/backend-api")
79    }
80
81    #[instrument(
82        name = "realtime_call.create",
83        level = "info",
84        skip_all,
85        fields(
86            http.method = "POST",
87            api.path = "realtime/calls"
88        )
89    )]
90    pub async fn create(&self, sdp: String) -> Result<RealtimeCallResponse, ApiError> {
91        self.create_with_headers(sdp, HeaderMap::new()).await
92    }
93
94    pub async fn create_with_session(
95        &self,
96        sdp: String,
97        session_config: RealtimeSessionConfig,
98    ) -> Result<RealtimeCallResponse, ApiError> {
99        self.create_with_session_and_headers(sdp, session_config, HeaderMap::new())
100            .await
101    }
102
103    pub async fn create_with_headers(
104        &self,
105        sdp: String,
106        extra_headers: HeaderMap,
107    ) -> Result<RealtimeCallResponse, ApiError> {
108        let resp = self
109            .session
110            .execute_with(
111                Method::POST,
112                Self::path(),
113                extra_headers,
114                /*body*/ None,
115                |req| {
116                    req.headers
117                        .insert(CONTENT_TYPE, HeaderValue::from_static("application/sdp"));
118                    req.body = Some(RequestBody::Raw(Bytes::from(sdp.clone())));
119                },
120            )
121            .await?;
122
123        let sdp = decode_sdp_response(resp.body.as_ref())?;
124        let call_id = decode_call_id_from_location(&resp.headers)?;
125
126        Ok(RealtimeCallResponse { sdp, call_id })
127    }
128
129    pub async fn create_with_session_and_headers(
130        &self,
131        sdp: String,
132        session_config: RealtimeSessionConfig,
133        extra_headers: HeaderMap,
134    ) -> Result<RealtimeCallResponse, ApiError> {
135        trace!(target: "codex_api::realtime_websocket::wire", "realtime call request SDP: {sdp}");
136        // WebRTC can begin inference as soon as the peer connection comes up, so the initial
137        // session payload is sent with call creation. Legacy sidebands still send session.update
138        // after joining; Frameless sidebands attach to the session that is already running.
139        validate_avas_session_config(&session_config)?;
140        let event_parser = session_config.event_parser;
141        let path = self.path_for_session(event_parser);
142        let mut session = realtime_session_json(session_config)?;
143        if let Some(session) = session.as_object_mut() {
144            session.remove("id");
145        }
146        // TODO(aibrahim): Align the SIWC route with the API multipart shape and remove this branch.
147        if self.uses_backend_request_shape() {
148            let body = to_value(BackendRealtimeCallRequest {
149                sdp: &sdp,
150                session: &session,
151            })
152            .map_err(|err| ApiError::Stream(format!("failed to encode realtime call: {err}")))?;
153            let resp = self
154                .session
155                .execute_with(Method::POST, path, extra_headers, Some(body), |request| {
156                    configure_realtime_call_request(
157                        request,
158                        event_parser,
159                        /*uses_backend_request_shape*/ true,
160                    )
161                })
162                .await?;
163            let sdp = decode_sdp_response(resp.body.as_ref())?;
164            let call_id = decode_call_id_from_location(&resp.headers)?;
165            return Ok(RealtimeCallResponse { sdp, call_id });
166        }
167
168        let session = to_string(&session).map_err(|err| ApiError::InvalidRequest {
169            message: err.to_string(),
170        })?;
171        let mut body = Vec::new();
172        body.extend_from_slice(format!("--{MULTIPART_BOUNDARY}\r\n").as_bytes());
173        body.extend_from_slice(b"Content-Disposition: form-data; name=\"sdp\"\r\n");
174        body.extend_from_slice(b"Content-Type: application/sdp\r\n\r\n");
175        body.extend_from_slice(sdp.as_bytes());
176        body.extend_from_slice(b"\r\n");
177        body.extend_from_slice(format!("--{MULTIPART_BOUNDARY}\r\n").as_bytes());
178        body.extend_from_slice(b"Content-Disposition: form-data; name=\"session\"\r\n");
179        body.extend_from_slice(b"Content-Type: application/json\r\n\r\n");
180        body.extend_from_slice(session.as_bytes());
181        body.extend_from_slice(b"\r\n");
182        body.extend_from_slice(format!("--{MULTIPART_BOUNDARY}--\r\n").as_bytes());
183
184        let resp = self
185            .session
186            .execute_with(
187                Method::POST,
188                path,
189                extra_headers,
190                /*body*/ None,
191                |req| {
192                    configure_realtime_call_request(
193                        req,
194                        event_parser,
195                        /*uses_backend_request_shape*/ false,
196                    );
197                    req.headers.insert(
198                        CONTENT_TYPE,
199                        HeaderValue::from_static(MULTIPART_CONTENT_TYPE),
200                    );
201                    req.body = Some(RequestBody::Raw(Bytes::from(body.clone())));
202                },
203            )
204            .await?;
205
206        let sdp = decode_sdp_response(resp.body.as_ref())?;
207        let call_id = decode_call_id_from_location(&resp.headers)?;
208
209        Ok(RealtimeCallResponse { sdp, call_id })
210    }
211}
212
213fn configure_realtime_call_request(
214    request: &mut Request,
215    event_parser: RealtimeEventParser,
216    uses_backend_request_shape: bool,
217) {
218    if event_parser == RealtimeEventParser::V1
219        || (uses_backend_request_shape && event_parser == RealtimeEventParser::FramelessBidi)
220    {
221        append_query_pair(&mut request.url, "intent", "quicksilver");
222        append_query_pair(&mut request.url, "architecture", "avas");
223    }
224}
225
226fn validate_avas_session_config(session_config: &RealtimeSessionConfig) -> Result<(), ApiError> {
227    if session_config.event_parser == RealtimeEventParser::RealtimeV2 {
228        return Err(ApiError::InvalidRequest {
229            message: "AVAS realtime calls require realtime v1 or v3".to_string(),
230        });
231    }
232    Ok(())
233}
234
235fn append_query_pair(url: &mut String, key: &str, value: &str) {
236    if url.contains('?') {
237        url.push('&');
238    } else {
239        url.push('?');
240    }
241    url.push_str(key);
242    url.push('=');
243    url.push_str(value);
244}
245
246fn realtime_session_json(session_config: RealtimeSessionConfig) -> Result<Value, ApiError> {
247    session_update_session_json(session_config)
248        .map_err(|err| ApiError::Stream(format!("failed to encode realtime call session: {err}")))
249}
250
251fn decode_sdp_response(body: &[u8]) -> Result<String, ApiError> {
252    String::from_utf8(body.to_vec()).map_err(|err| {
253        ApiError::Stream(format!(
254            "failed to decode realtime call SDP response: {err}"
255        ))
256    })
257}
258
259fn decode_call_id_from_location(headers: &HeaderMap) -> Result<String, ApiError> {
260    let location = headers
261        .get(LOCATION)
262        .ok_or_else(|| ApiError::Stream("realtime call response missing Location".to_string()))?
263        .to_str()
264        .map_err(|err| ApiError::Stream(format!("invalid realtime call Location: {err}")))?;
265    trace!("realtime call Location: {location}");
266
267    location
268        .split('?')
269        .next()
270        .unwrap_or(location)
271        .rsplit('/')
272        .find(|segment| is_realtime_call_id_segment(segment))
273        .map(str::to_string)
274        .ok_or_else(|| {
275            ApiError::Stream(format!(
276                "realtime call Location does not contain a call id: {location}"
277            ))
278        })
279}
280
281fn is_realtime_call_id_segment(segment: &str) -> bool {
282    if segment.starts_with("rtc_") && segment.len() > "rtc_".len() {
283        return true;
284    }
285
286    if segment.len() != 36 {
287        return false;
288    }
289
290    segment.char_indices().all(|(index, ch)| match index {
291        8 | 13 | 18 | 23 => ch == '-',
292        _ => ch.is_ascii_hexdigit(),
293    })
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::auth::AuthProvider;
300    use crate::endpoint::realtime_websocket::RealtimeEventParser;
301    use crate::endpoint::realtime_websocket::RealtimeOutputModality;
302    use crate::endpoint::realtime_websocket::RealtimeSessionMode;
303    use crate::provider::RetryConfig;
304    use codex_client::Request;
305    use codex_client::Response;
306    use codex_client::StreamResponse;
307    use codex_client::TransportError;
308    use codex_protocol::protocol::ConversationTextParams;
309    use codex_protocol::protocol::ConversationTextRole;
310    use codex_protocol::protocol::RealtimeVoice;
311    use http::StatusCode;
312    use pretty_assertions::assert_eq;
313    use std::sync::Mutex;
314    use std::time::Duration;
315
316    #[derive(Clone)]
317    struct CapturingTransport {
318        last_request: Arc<Mutex<Option<Request>>>,
319        response_headers: HeaderMap,
320    }
321
322    impl CapturingTransport {
323        fn new() -> Self {
324            Self::with_location("/v1/realtime/calls/rtc_test")
325        }
326
327        fn with_location(location: &str) -> Self {
328            let mut response_headers = HeaderMap::new();
329            response_headers.insert(LOCATION, HeaderValue::from_str(location).unwrap());
330            Self {
331                last_request: Arc::new(Mutex::new(None)),
332                response_headers,
333            }
334        }
335
336        fn without_location() -> Self {
337            Self {
338                last_request: Arc::new(Mutex::new(None)),
339                response_headers: HeaderMap::new(),
340            }
341        }
342    }
343
344    impl HttpTransport for CapturingTransport {
345        async fn execute(&self, req: Request) -> Result<Response, TransportError> {
346            *self.last_request.lock().unwrap() = Some(req);
347            Ok(Response {
348                status: StatusCode::OK,
349                headers: self.response_headers.clone(),
350                body: Bytes::from_static(b"v=0\r\n"),
351            })
352        }
353
354        async fn stream(&self, _req: Request) -> Result<StreamResponse, TransportError> {
355            Err(TransportError::Build("stream should not run".to_string()))
356        }
357    }
358
359    #[derive(Clone, Default)]
360    struct DummyAuth;
361
362    impl AuthProvider for DummyAuth {
363        fn add_auth_headers(&self, headers: &mut HeaderMap) {
364            headers.insert(
365                http::header::AUTHORIZATION,
366                HeaderValue::from_static("Bearer test-token"),
367            );
368        }
369    }
370
371    fn provider(base_url: &str) -> Provider {
372        Provider {
373            name: "test".to_string(),
374            base_url: base_url.to_string(),
375            query_params: None,
376            headers: HeaderMap::new(),
377            retry: RetryConfig {
378                max_attempts: 1,
379                base_delay: Duration::from_millis(1),
380                retry_429: false,
381                retry_5xx: true,
382                retry_transport: true,
383            },
384            stream_idle_timeout: Duration::from_secs(1),
385        }
386    }
387
388    fn realtime_session_config(session_id: &str) -> RealtimeSessionConfig {
389        RealtimeSessionConfig {
390            instructions: "hi".to_string(),
391            initial_items: Vec::new(),
392            model: Some("gpt-realtime".to_string()),
393            session_id: Some(session_id.to_string()),
394            event_parser: RealtimeEventParser::V1,
395            session_mode: RealtimeSessionMode::Conversational,
396            output_modality: RealtimeOutputModality::Audio,
397            voice: RealtimeVoice::Cove,
398        }
399    }
400
401    fn realtime_v2_session_config(session_id: &str) -> RealtimeSessionConfig {
402        RealtimeSessionConfig {
403            event_parser: RealtimeEventParser::RealtimeV2,
404            voice: RealtimeVoice::Marin,
405            ..realtime_session_config(session_id)
406        }
407    }
408
409    fn frameless_bidi_session_config(session_id: &str) -> RealtimeSessionConfig {
410        RealtimeSessionConfig {
411            event_parser: RealtimeEventParser::FramelessBidi,
412            ..realtime_session_config(session_id)
413        }
414    }
415
416    #[tokio::test]
417    async fn sends_sdp_offer_as_raw_body() {
418        let transport = CapturingTransport::new();
419        let client = RealtimeCallClient::new(
420            transport.clone(),
421            provider("https://api.openai.com/v1"),
422            Arc::new(DummyAuth),
423        );
424
425        let response = client
426            .create("v=offer\r\n".to_string())
427            .await
428            .expect("request should succeed");
429
430        assert_eq!(
431            response,
432            RealtimeCallResponse {
433                sdp: "v=0\r\n".to_string(),
434                call_id: "rtc_test".to_string(),
435            }
436        );
437
438        let request = transport.last_request.lock().unwrap().clone().unwrap();
439        assert_eq!(request.method, Method::POST);
440        assert_eq!(request.url, "https://api.openai.com/v1/realtime/calls");
441        assert_eq!(
442            request.headers.get(CONTENT_TYPE).unwrap(),
443            HeaderValue::from_static("application/sdp")
444        );
445        assert_eq!(
446            request
447                .headers
448                .get(http::header::AUTHORIZATION)
449                .and_then(|value| value.to_str().ok()),
450            Some("Bearer test-token")
451        );
452        assert_eq!(
453            request.body,
454            Some(RequestBody::Raw(Bytes::from_static(b"v=offer\r\n")))
455        );
456    }
457
458    #[tokio::test]
459    async fn extracts_call_id_from_forwarded_backend_location() {
460        let transport =
461            CapturingTransport::with_location("/v1/realtime/calls/calls/rtc_backend_test");
462        let client = RealtimeCallClient::new(
463            transport.clone(),
464            provider("https://chatgpt.com/backend-api/codex"),
465            Arc::new(DummyAuth),
466        );
467
468        let response = client
469            .create("v=offer\r\n".to_string())
470            .await
471            .expect("request should succeed");
472
473        assert_eq!(
474            response,
475            RealtimeCallResponse {
476                sdp: "v=0\r\n".to_string(),
477                call_id: "rtc_backend_test".to_string(),
478            }
479        );
480
481        let request = transport.last_request.lock().unwrap().clone().unwrap();
482        assert_eq!(request.method, Method::POST);
483        assert_eq!(
484            request.url,
485            "https://chatgpt.com/backend-api/codex/realtime/calls"
486        );
487        assert_eq!(
488            request.body,
489            Some(RequestBody::Raw(Bytes::from_static(b"v=offer\r\n")))
490        );
491    }
492
493    #[tokio::test]
494    async fn sends_api_session_call_as_multipart_body() {
495        let transport = CapturingTransport::new();
496        let client = RealtimeCallClient::new(
497            transport.clone(),
498            provider("https://api.openai.com/v1"),
499            Arc::new(DummyAuth),
500        );
501
502        let response = client
503            .create_with_session(
504                "v=offer\r\n".to_string(),
505                realtime_session_config("sess-api"),
506            )
507            .await
508            .expect("request should succeed");
509
510        assert_eq!(
511            response,
512            RealtimeCallResponse {
513                sdp: "v=0\r\n".to_string(),
514                call_id: "rtc_test".to_string(),
515            }
516        );
517
518        let request = transport.last_request.lock().unwrap().clone().unwrap();
519        assert_eq!(request.method, Method::POST);
520        assert_eq!(
521            request.url,
522            "https://api.openai.com/v1/realtime/calls?intent=quicksilver&architecture=avas"
523        );
524        assert_eq!(
525            request.headers.get(CONTENT_TYPE).unwrap(),
526            HeaderValue::from_static(MULTIPART_CONTENT_TYPE)
527        );
528        let Some(RequestBody::Raw(body)) = request.body else {
529            panic!("multipart body should be raw");
530        };
531        let body = std::str::from_utf8(&body).expect("multipart body should be utf-8");
532        let mut session = realtime_session_json(realtime_session_config("sess-api"))
533            .expect("session should encode");
534        session
535            .as_object_mut()
536            .expect("session should be an object")
537            .remove("id");
538        let session = to_string(&session).expect("session should serialize");
539        assert_eq!(
540            body,
541            format!(
542                "--codex-realtime-call-boundary\r\n\
543                 Content-Disposition: form-data; name=\"sdp\"\r\n\
544                 Content-Type: application/sdp\r\n\
545                 \r\n\
546                 v=offer\r\n\
547                 \r\n\
548                 --codex-realtime-call-boundary\r\n\
549                 Content-Disposition: form-data; name=\"session\"\r\n\
550                 Content-Type: application/json\r\n\
551                 \r\n\
552                 {session}\r\n\
553                 --codex-realtime-call-boundary--\r\n"
554            )
555        );
556    }
557
558    #[tokio::test]
559    async fn sends_frameless_session_call_to_live_without_legacy_query_params() {
560        let transport = CapturingTransport::with_location("/v1/live/rtc_frameless");
561        let client = RealtimeCallClient::new(
562            transport.clone(),
563            provider("https://api.openai.com/v1"),
564            Arc::new(DummyAuth),
565        );
566
567        let response = client
568            .create_with_session(
569                "v=offer\r\n".to_string(),
570                frameless_bidi_session_config("sess-api"),
571            )
572            .await
573            .expect("request should succeed");
574
575        assert_eq!(response.call_id, "rtc_frameless");
576        let request = transport.last_request.lock().unwrap().clone().unwrap();
577        assert_eq!(request.method, Method::POST);
578        assert_eq!(request.url, "https://api.openai.com/v1/live");
579        let Some(RequestBody::Raw(body)) = request.body else {
580            panic!("multipart body should be raw");
581        };
582        let body = std::str::from_utf8(&body).expect("multipart body should be utf-8");
583        assert!(body.contains("\"model\":\"gpt-realtime\""));
584        assert!(body.contains("\"delegation\":{\"type\":\"client\"}"));
585        assert!(!body.contains("\"id\":\"sess-api\""));
586    }
587
588    #[tokio::test]
589    async fn sends_session_call_with_avas_query_params() {
590        let transport = CapturingTransport::new();
591        let client = RealtimeCallClient::new(
592            transport.clone(),
593            provider("https://api.openai.com/v1"),
594            Arc::new(DummyAuth),
595        );
596
597        let response = client
598            .create_with_session_and_headers(
599                "v=offer\r\n".to_string(),
600                realtime_session_config("sess-api"),
601                HeaderMap::new(),
602            )
603            .await
604            .expect("request should succeed");
605
606        assert_eq!(
607            response,
608            RealtimeCallResponse {
609                sdp: "v=0\r\n".to_string(),
610                call_id: "rtc_test".to_string(),
611            }
612        );
613
614        let request = transport.last_request.lock().unwrap().clone().unwrap();
615        assert_eq!(request.method, Method::POST);
616        assert_eq!(
617            request.url,
618            "https://api.openai.com/v1/realtime/calls?intent=quicksilver&architecture=avas"
619        );
620    }
621
622    #[tokio::test]
623    async fn rejects_v2_session_call_before_sending_request() {
624        let transport = CapturingTransport::new();
625        let client = RealtimeCallClient::new(
626            transport.clone(),
627            provider("https://api.openai.com/v1"),
628            Arc::new(DummyAuth),
629        );
630
631        let err = client
632            .create_with_session(
633                "v=offer\r\n".to_string(),
634                realtime_v2_session_config("sess-api"),
635            )
636            .await
637            .expect_err("v2 session config should be rejected");
638
639        assert_eq!(
640            err.to_string(),
641            "invalid request: AVAS realtime calls require realtime v1 or v3"
642        );
643        assert!(transport.last_request.lock().unwrap().is_none());
644    }
645
646    #[tokio::test]
647    async fn sends_backend_session_call_as_json_body() {
648        let transport = CapturingTransport::new();
649        let client = RealtimeCallClient::new(
650            transport.clone(),
651            provider("https://chatgpt.com/backend-api/codex"),
652            Arc::new(DummyAuth),
653        );
654
655        let response = client
656            .create_with_session(
657                "v=offer\r\n".to_string(),
658                realtime_session_config("sess-backend"),
659            )
660            .await
661            .expect("request should succeed");
662
663        assert_eq!(
664            response,
665            RealtimeCallResponse {
666                sdp: "v=0\r\n".to_string(),
667                call_id: "rtc_test".to_string(),
668            }
669        );
670
671        let request = transport.last_request.lock().unwrap().clone().unwrap();
672        assert_eq!(request.method, Method::POST);
673        assert_eq!(
674            request.url,
675            "https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas"
676        );
677        let mut expected_session = realtime_session_json(realtime_session_config("sess-backend"))
678            .expect("session should encode");
679        expected_session
680            .as_object_mut()
681            .expect("session should be an object")
682            .remove("id");
683        assert_eq!(
684            request.body,
685            Some(RequestBody::Json(
686                to_value(BackendRealtimeCallRequest {
687                    sdp: "v=offer\r\n",
688                    session: &expected_session,
689                })
690                .expect("request should encode")
691            ))
692        );
693    }
694
695    #[tokio::test]
696    async fn sends_backend_frameless_session_call_to_realtime_calls() {
697        let transport = CapturingTransport::with_location("/v1/live/rtc_backend_frameless");
698        let client = RealtimeCallClient::new(
699            transport.clone(),
700            provider("https://chatgpt.com/backend-api/codex"),
701            Arc::new(DummyAuth),
702        );
703        let mut session_config = frameless_bidi_session_config("sess-backend");
704        session_config.initial_items = vec![
705            ConversationTextParams {
706                text: "Remember this.".to_string(),
707                role: ConversationTextRole::Developer,
708            },
709            ConversationTextParams {
710                text: "Understood.".to_string(),
711                role: ConversationTextRole::Assistant,
712            },
713        ];
714
715        let response = client
716            .create_with_session("v=offer\r\n".to_string(), session_config)
717            .await
718            .expect("request should succeed");
719
720        assert_eq!(response.call_id, "rtc_backend_frameless");
721        let request = transport.last_request.lock().unwrap().clone().unwrap();
722        assert_eq!(request.method, Method::POST);
723        assert_eq!(
724            request.url,
725            "https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas"
726        );
727        let Some(RequestBody::Json(body)) = request.body else {
728            panic!("backend request body should be JSON");
729        };
730        assert_eq!(body["session"]["delegation"]["type"], "client");
731        assert!(body["session"].get("id").is_none());
732        assert_eq!(
733            body["session"]["initial_items"],
734            serde_json::json!([
735                {
736                    "type": "message",
737                    "role": "developer",
738                    "content": [{"type": "input_text", "text": "Remember this."}],
739                },
740                {
741                    "type": "message",
742                    "role": "assistant",
743                    "content": [{"type": "output_text", "text": "Understood."}],
744                },
745            ])
746        );
747    }
748
749    #[tokio::test]
750    async fn errors_when_location_is_missing() {
751        let transport = CapturingTransport::without_location();
752        let client = RealtimeCallClient::new(
753            transport,
754            provider("https://api.openai.com/v1"),
755            Arc::new(DummyAuth),
756        );
757
758        let err = client
759            .create("v=offer\r\n".to_string())
760            .await
761            .expect_err("request should require Location");
762
763        assert_eq!(
764            err.to_string(),
765            "stream error: realtime call response missing Location"
766        );
767    }
768
769    #[test]
770    fn rejects_location_without_call_id() {
771        let mut headers = HeaderMap::new();
772        headers.insert(LOCATION, HeaderValue::from_static("/v1/realtime/calls"));
773
774        let err = decode_call_id_from_location(&headers)
775            .expect_err("Location without rtc_ segment should fail");
776
777        assert_eq!(
778            err.to_string(),
779            "stream error: realtime call Location does not contain a call id: /v1/realtime/calls"
780        );
781    }
782
783    #[test]
784    fn accepts_uuid_call_id_from_location() {
785        let mut headers = HeaderMap::new();
786        headers.insert(
787            LOCATION,
788            HeaderValue::from_static("/v1/realtime/calls/019eb97d-8e9a-7ff3-94b0-ea019babd5d7"),
789        );
790
791        let call_id = decode_call_id_from_location(&headers).expect("UUID call id should parse");
792
793        assert_eq!(call_id, "019eb97d-8e9a-7ff3-94b0-ea019babd5d7");
794    }
795}