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#[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 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 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 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 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 None,
191 |req| {
192 configure_realtime_call_request(
193 req,
194 event_parser,
195 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}