Skip to main content

gproxy_protocol/openai/realtime/
mod.rs

1//! OpenAI Realtime handshake, session, and event wire models.
2//!
3//! The local OpenAI snapshot has no Realtime reference page. These call and
4//! session shapes follow the v2 protocol model and captured request evidence.
5
6macro_rules! extensible_string {
7    ($name:ident, $known:ident { $($variant:ident => $wire:literal),+ $(,)? }) => {
8        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9        #[serde(untagged)]
10        #[cfg_attr(not(feature = "exhaustive"), non_exhaustive)]
11        pub enum $name {
12            Known($known),
13            Unknown(String),
14        }
15
16        #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17        #[cfg_attr(not(feature = "exhaustive"), non_exhaustive)]
18        pub enum $known {
19            $(#[serde(rename = $wire)] $variant),+
20        }
21    };
22}
23
24mod audio;
25mod client_events;
26mod items;
27mod response;
28mod server_events;
29mod session;
30
31pub use audio::*;
32pub use client_events::*;
33pub use items::*;
34pub use response::*;
35pub use server_events::*;
36pub use session::*;
37
38use serde::{Deserialize, Serialize};
39
40use crate::openai::common::{OpenAiModelId, OpenAiWireModel, Rest};
41
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, gproxy_protocol_macros::WireBuilder)]
43#[cfg_attr(not(feature = "exhaustive"), non_exhaustive)]
44pub struct CreateRealtimeCallRequest {
45    pub sdp: String,
46    pub session: RealtimeSession,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub model: Option<OpenAiModelId>,
49    #[serde(default, flatten)]
50    pub rest: Rest,
51}
52
53pub type CreateRealtimeCallResponse = String;
54pub type RealtimeWireModel = OpenAiWireModel<RealtimeClientEvent, RealtimeServerEvent>;
55
56#[cfg(test)]
57mod tests {
58    use serde_json::json;
59
60    use super::*;
61
62    #[test]
63    fn realtime_call_round_trip_preserves_session_extensions() {
64        let value = json!({
65            "sdp":"v=0\r\n",
66            "model":"public-route",
67            "session": {
68                "type":"realtime",
69                "model":"gpt-realtime",
70                "audio":{"input":{"future_audio":true}},
71                "future_session":1
72            },
73            "future_call":true
74        });
75        let parsed: super::CreateRealtimeCallRequest =
76            serde_json::from_value(value.clone()).unwrap();
77        assert_eq!(serde_json::to_value(parsed).unwrap(), value);
78    }
79
80    #[test]
81    fn realtime_event_unions_round_trip_known_unknown_and_evolved_payloads() {
82        let client = json!({
83            "type":"session.update",
84            "event_id":"evt_1",
85            "session":{"type":"realtime","model":"gpt-realtime"},
86            "future_client":true
87        });
88        let parsed: RealtimeClientEvent = serde_json::from_value(client.clone()).unwrap();
89        assert!(matches!(
90            &parsed,
91            RealtimeClientEvent::Known(event)
92                if matches!(event.as_ref(), KnownRealtimeClientEvent::SessionUpdate { .. })
93        ));
94        assert_eq!(serde_json::to_value(parsed).unwrap(), client);
95
96        let item = json!({
97            "type":"message",
98            "id":"item_1",
99            "role":"assistant",
100            "content":[{"type":"output_audio","audio":"AAEC","future_part":1}],
101            "future_item":true
102        });
103        let parsed: RealtimeItem = serde_json::from_value(item.clone()).unwrap();
104        assert!(matches!(
105            &parsed,
106            RealtimeItem::Known(KnownRealtimeItem::Message { .. })
107        ));
108        assert_eq!(serde_json::to_value(parsed).unwrap(), item);
109
110        let server = json!({
111            "type":"response.output_audio.delta",
112            "response_id":"resp_1",
113            "item_id":"item_1",
114            "output_index":0,
115            "content_index":0,
116            "delta":"AAEC",
117            "future_server":2
118        });
119        let parsed: RealtimeServerEvent = serde_json::from_value(server.clone()).unwrap();
120        assert!(matches!(
121            &parsed,
122            RealtimeServerEvent::Known(event)
123                if matches!(event.as_ref(), KnownRealtimeServerEvent::OutputAudioDelta(_))
124        ));
125        assert_eq!(serde_json::to_value(parsed).unwrap(), server);
126
127        for unknown in [
128            json!({"type":"response.hologram.delta","delta":"?","future":3}),
129            json!({"type":"response.output_audio.delta","delta":"missing required fields"}),
130        ] {
131            let parsed: RealtimeServerEvent = serde_json::from_value(unknown.clone()).unwrap();
132            assert!(matches!(&parsed, RealtimeServerEvent::Unknown(_)));
133            assert_eq!(serde_json::to_value(parsed).unwrap(), unknown);
134        }
135
136        let evolved_item = json!({
137            "type":"message",
138            "role":"assistant",
139            "content":[{"type":"hologram","payload":"?"}]
140        });
141        let parsed: RealtimeItem = serde_json::from_value(evolved_item.clone()).unwrap();
142        assert!(matches!(&parsed, RealtimeItem::Unknown(_)));
143        assert_eq!(serde_json::to_value(parsed).unwrap(), evolved_item);
144    }
145
146    /// Payloads taken from `upstream_docs/openai/docs/Realtime.md`, fetched after
147    /// the realtime types were written. Two server events had no arm, and the
148    /// client union had no fallback at all — so the translation surface OpenAI
149    /// documents (`session.close`, `session.input_audio_buffer.append`) would have
150    /// failed the connection rather than reaching an upstream that speaks it.
151    #[test]
152    fn realtime_matches_the_documented_wire() {
153        let created = json!({
154            "type":"conversation.created",
155            "event_id":"event_9999",
156            "conversation":{"id":"conv_001","object":"realtime.conversation"}
157        });
158        let parsed: RealtimeServerEvent = serde_json::from_value(created.clone()).unwrap();
159        assert!(matches!(
160            &parsed,
161            RealtimeServerEvent::Known(event)
162                if matches!(event.as_ref(), KnownRealtimeServerEvent::ConversationCreated(_))
163        ));
164        assert_eq!(serde_json::to_value(parsed).unwrap(), created);
165
166        let dtmf = json!({
167            "type":"input_audio_buffer.dtmf_event_received",
168            "event_id":"event_1",
169            "event":"5",
170            "received_at":1_764_000_000.0
171        });
172        let parsed: RealtimeServerEvent = serde_json::from_value(dtmf.clone()).unwrap();
173        assert!(matches!(
174            &parsed,
175            RealtimeServerEvent::Known(event)
176                if matches!(
177                    event.as_ref(),
178                    KnownRealtimeServerEvent::InputAudioBufferDtmfEventReceived(_)
179                )
180        ));
181        assert_eq!(serde_json::to_value(parsed).unwrap(), dtmf);
182
183        for translation in [
184            json!({"type":"session.close","event_id":"evt_2"}),
185            json!({"type":"session.input_audio_buffer.append","audio":"AAEC"}),
186        ] {
187            let parsed: RealtimeClientEvent = serde_json::from_value(translation.clone()).unwrap();
188            assert!(matches!(&parsed, RealtimeClientEvent::Unknown(_)));
189            assert_eq!(serde_json::to_value(parsed).unwrap(), translation);
190        }
191    }
192}