Skip to main content

dynamo_protocols/types/
realtime.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Re-exports upstream async-openai realtime types and adds a narrow wrapper for
5// Dynamo-specific client events without replacing the upstream public enum.
6
7pub use async_openai::types::realtime::*;
8use serde::{Deserialize, Serialize};
9
10/// Append UTF-8 text to the current incremental input.
11///
12/// This is a Dynamo extension for clients that receive text progressively,
13/// such as cascaded ASR -> LLM pipelines. The event name follows NVIDIA Speech
14/// NIM's realtime TTS protocol. An append does not finalize a conversation item
15/// or request a response.
16#[derive(Debug, Serialize, Deserialize)]
17pub struct RealtimeClientEventInputTextAppend {
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub event_id: Option<String>,
20    pub text: String,
21}
22
23/// Finalize the current incremental text as a user conversation item.
24#[derive(Debug, Default, Serialize, Deserialize)]
25pub struct RealtimeClientEventInputTextCommit {
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub event_id: Option<String>,
28}
29
30/// Discard the current incremental text without creating an item.
31///
32/// This extends NVIDIA Speech NIM's append/commit lifecycle so clients can
33/// replace revisable ASR hypotheses.
34#[derive(Debug, Default, Serialize, Deserialize)]
35pub struct RealtimeClientEventInputTextClear {
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub event_id: Option<String>,
38}
39
40/// Experimental Dynamo extensions to the OpenAI Realtime client event set.
41///
42/// These events are not part of the OpenAI Realtime API and may change before
43/// stabilization.
44#[derive(Debug, Serialize, Deserialize)]
45#[serde(tag = "type")]
46pub enum RealtimeClientEventExtension {
47    #[serde(rename = "input_text.append")]
48    InputTextAppend(RealtimeClientEventInputTextAppend),
49    #[serde(rename = "input_text.commit")]
50    InputTextCommit(RealtimeClientEventInputTextCommit),
51    #[serde(rename = "input_text.clear")]
52    InputTextClear(RealtimeClientEventInputTextClear),
53}
54
55/// OpenAI Realtime client events plus inference-serving extensions from Dynamo.
56#[derive(Debug, Serialize, Deserialize)]
57#[serde(untagged)]
58pub enum DynamoRealtimeClientEvent {
59    OpenAI(RealtimeClientEvent),
60    Extension(RealtimeClientEventExtension),
61}
62
63impl From<RealtimeClientEvent> for DynamoRealtimeClientEvent {
64    fn from(event: RealtimeClientEvent) -> Self {
65        Self::OpenAI(event)
66    }
67}
68
69/// Returns the `type` wire-tag string for a realtime event variant — useful
70/// for logging, error messages, and metric labels that need a stable name
71/// without reserializing the value.
72///
73/// `async-openai` ships an equivalent `crate::traits::EventType` trait, but it
74/// is gated on the `_api` feature, which pulls reqwest / tokio / secrecy /
75/// eventsource-stream into the build. `dynamo-protocols` is types-only by
76/// design (see the Cargo.toml banner), so we mirror the trait shape locally.
77/// If `_api` ever becomes affordable for this crate, swap `pub use
78/// async_openai::traits::EventType;` in here and remove the impls below; call
79/// sites need no changes.
80///
81/// [NOTE] Could be replaced with a serde-introspection helper (e.g. the
82/// `serde_variant` crate) that reads the wire tag from `#[serde(rename)]`
83/// at runtime; deferred until as clean up work.
84pub trait EventType {
85    fn event_type(&self) -> &'static str;
86}
87
88impl EventType for RealtimeClientEvent {
89    fn event_type(&self) -> &'static str {
90        // `RealtimeClientEvent` is not `#[non_exhaustive]`, so a future upstream
91        // variant breaks this match at compile time rather than silently
92        // returning a stale label.
93        match self {
94            RealtimeClientEvent::SessionUpdate(_) => "session.update",
95            RealtimeClientEvent::InputAudioBufferAppend(_) => "input_audio_buffer.append",
96            RealtimeClientEvent::InputAudioBufferCommit(_) => "input_audio_buffer.commit",
97            RealtimeClientEvent::InputAudioBufferClear(_) => "input_audio_buffer.clear",
98            RealtimeClientEvent::ConversationItemCreate(_) => "conversation.item.create",
99            RealtimeClientEvent::ConversationItemRetrieve(_) => "conversation.item.retrieve",
100            RealtimeClientEvent::ConversationItemTruncate(_) => "conversation.item.truncate",
101            RealtimeClientEvent::ConversationItemDelete(_) => "conversation.item.delete",
102            RealtimeClientEvent::ResponseCreate(_) => "response.create",
103            RealtimeClientEvent::ResponseCancel(_) => "response.cancel",
104            RealtimeClientEvent::OutputAudioBufferClear(_) => "output_audio_buffer.clear",
105        }
106    }
107}
108
109impl EventType for RealtimeClientEventExtension {
110    fn event_type(&self) -> &'static str {
111        match self {
112            RealtimeClientEventExtension::InputTextAppend(_) => "input_text.append",
113            RealtimeClientEventExtension::InputTextCommit(_) => "input_text.commit",
114            RealtimeClientEventExtension::InputTextClear(_) => "input_text.clear",
115        }
116    }
117}
118
119impl EventType for DynamoRealtimeClientEvent {
120    fn event_type(&self) -> &'static str {
121        match self {
122            DynamoRealtimeClientEvent::OpenAI(event) => event.event_type(),
123            DynamoRealtimeClientEvent::Extension(event) => event.event_type(),
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn session_update_accepts_null_turn_detection() {
134        let event: RealtimeClientEvent = serde_json::from_value(serde_json::json!({
135            "type": "session.update",
136            "session": {
137                "type": "transcription",
138                "audio": {
139                    "input": {
140                        "format": { "type": "audio/pcm", "rate": 24000 },
141                        "transcription": { "model": "whisper-1" },
142                        "turn_detection": null
143                    }
144                }
145            }
146        }))
147        .expect("null turn_detection should be accepted");
148
149        let RealtimeClientEvent::SessionUpdate(update) = event else {
150            panic!("expected session.update");
151        };
152        let Session::RealtimeTranscriptionSession(session) = update.session else {
153            panic!("expected transcription session");
154        };
155        assert!(session.audio.input.turn_detection.is_none());
156    }
157
158    #[test]
159    fn text_events_round_trip() {
160        let values = [
161            (
162                serde_json::json!({
163                    "type": "input_text.append",
164                    "event_id": "append-1",
165                    "text": "hello "
166                }),
167                "input_text.append",
168            ),
169            (
170                serde_json::json!({
171                    "type": "input_text.commit",
172                    "event_id": "commit-1"
173                }),
174                "input_text.commit",
175            ),
176            (
177                serde_json::json!({
178                    "type": "input_text.clear",
179                    "event_id": "clear-1"
180                }),
181                "input_text.clear",
182            ),
183        ];
184
185        for (value, event_type) in values {
186            let event: DynamoRealtimeClientEvent =
187                serde_json::from_value(value.clone()).expect("event should deserialize");
188            assert_eq!(event.event_type(), event_type);
189            assert_eq!(serde_json::to_value(event).unwrap(), value);
190        }
191    }
192
193    #[test]
194    fn text_append_requires_non_null_text() {
195        for value in [
196            serde_json::json!({"type": "input_text.append"}),
197            serde_json::json!({"type": "input_text.append", "text": null}),
198        ] {
199            assert!(serde_json::from_value::<DynamoRealtimeClientEvent>(value).is_err());
200        }
201    }
202}