Skip to content

Commit aed5aea

Browse files
committed
openai-dialog: Improve protocol handling
1 parent 4c6d210 commit aed5aea

5 files changed

Lines changed: 135 additions & 43 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ tokio = { workspace = true, features = ["rt-multi-thread"] }
8080
openai-api-rs = { workspace = true }
8181
serde_json = { workspace = true }
8282
chrono-tz = { version = "0.10.3" }
83+
url = { workspace = true }
8384

8485

8586
# For recognizing audio files in azure-transcribe.

examples/openai-dialog.rs

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ use std::{
1010

1111
use anyhow::{Context, Result, bail};
1212
use chrono::Utc;
13+
use clap::{Parser, ValueEnum};
1314
use context_switch::{InputModality, OutputModality};
1415
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
1516
use openai_api_rs::realtime::types;
16-
use openai_dialog::{OpenAIDialog, ServiceInputEvent, ServiceOutputEvent};
17+
use openai_dialog::{OpenAIDialog, Protocol, ServiceInputEvent, ServiceOutputEvent};
1718
use rodio::{DeviceSinkBuilder, Player, Source};
1819
use serde_json::json;
1920
use tokio::{
@@ -27,8 +28,33 @@ use context_switch_core::{
2728
conversation::{Conversation, Input, Output},
2829
};
2930

31+
#[derive(Debug, Parser)]
32+
struct Cli {
33+
#[arg(long, value_enum)]
34+
protocol: Option<CliProtocol>,
35+
#[arg(long)]
36+
endpoint: Option<String>,
37+
}
38+
39+
#[derive(Debug, Clone, Copy, ValueEnum)]
40+
enum CliProtocol {
41+
#[value(name = "openai")]
42+
OpenAI,
43+
Azure,
44+
}
45+
46+
impl From<CliProtocol> for Protocol {
47+
fn from(value: CliProtocol) -> Self {
48+
match value {
49+
CliProtocol::OpenAI => Protocol::OpenAI,
50+
CliProtocol::Azure => Protocol::Azure,
51+
}
52+
}
53+
}
54+
3055
#[tokio::main]
3156
async fn main() -> Result<()> {
57+
let cli = Cli::parse();
3258
dotenvy::dotenv_override().context("Reading .env file")?;
3359
tracing_subscriber::fmt::init();
3460

@@ -76,11 +102,11 @@ async fn main() -> Result<()> {
76102

77103
let openai = OpenAIDialog;
78104
let mut params = openai_dialog::Params::new(key, model);
79-
if let Ok(endpoint) = env::var("OPENAI_REALTIME_ENDPOINT")
80-
&& !endpoint.trim().is_empty()
81-
{
82-
params.host = Some(endpoint);
83-
}
105+
params.host = cli
106+
.endpoint
107+
.or_else(|| env::var("OPENAI_REALTIME_ENDPOINT").ok())
108+
.filter(|endpoint| !endpoint.trim().is_empty());
109+
params.protocol = cli.protocol.map(Into::into);
84110
params.tools.push(get_time_function_definition());
85111

86112
let (output_sender, output_receiver) = unbounded_channel();

services/openai-dialog/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ base64 = { workspace = true }
2222
serde = { workspace = true }
2323
async-trait = { workspace = true }
2424
uuid = { workspace = true }
25+
url = { workspace = true }

services/openai-dialog/src/lib.rs

Lines changed: 100 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use futures::{
1212
stream::{SplitSink, SplitStream},
1313
};
1414
use openai_api_rs::realtime::{
15-
api::RealtimeClient,
15+
api::{RealtimeClient, RealtimeProtocol},
1616
client_event::{self, ClientEvent},
1717
server_event::{self, ServerEvent},
1818
types::{
@@ -27,6 +27,7 @@ use tokio_tungstenite::{
2727
tungstenite::{Bytes, protocol::Message},
2828
};
2929
use tracing::{debug, info, trace, warn};
30+
use url::Url;
3031
use uuid::Uuid;
3132

3233
use context_switch_core::{
@@ -39,6 +40,7 @@ use context_switch_core::{
3940
pub struct Params {
4041
pub api_key: String,
4142
pub model: String,
43+
pub protocol: Option<Protocol>,
4244
pub host: Option<String>,
4345
pub instructions: Option<String>,
4446
pub voice: Option<RealtimeVoice>,
@@ -53,6 +55,7 @@ impl Params {
5355
Self {
5456
api_key: api_key.into(),
5557
model: model.into(),
58+
protocol: None,
5659
host: None,
5760
instructions: None,
5861
voice: None,
@@ -63,6 +66,22 @@ impl Params {
6366
}
6467
}
6568

69+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70+
#[serde(rename_all = "lowercase")]
71+
pub enum Protocol {
72+
OpenAI,
73+
Azure,
74+
}
75+
76+
impl Protocol {
77+
fn to_realtime_protocol(self) -> RealtimeProtocol {
78+
match self {
79+
Protocol::OpenAI => RealtimeProtocol::OpenAI,
80+
Protocol::Azure => RealtimeProtocol::Azure,
81+
}
82+
}
83+
}
84+
6685
#[derive(Debug)]
6786
pub struct OpenAIDialog;
6887

@@ -80,10 +99,12 @@ impl Service for OpenAIDialog {
8099
bail!("Input and output audio formats must match for OpenAI dialog service");
81100
}
82101

102+
let protocol = resolve_protocol(params.protocol, params.host.as_deref())?;
103+
83104
let host = if let Some(host) = &params.host {
84-
Host::new_with_host(host, &params.api_key, &params.model)
105+
Host::new_with_host(host, &params.api_key, &params.model, protocol)
85106
} else {
86-
Host::new(&params.api_key, &params.model)
107+
Host::new(&params.api_key, &params.model, protocol)
87108
};
88109
info!("Connecting to {host:?}");
89110
let mut client = host.connect().await?;
@@ -107,6 +128,52 @@ impl Service for OpenAIDialog {
107128
}
108129
}
109130

131+
fn resolve_protocol(protocol: Option<Protocol>, host: Option<&str>) -> Result<Protocol> {
132+
let protocol = match protocol {
133+
Some(protocol) => Ok(protocol),
134+
None => infer_protocol_from_host(host),
135+
}?;
136+
137+
validate_protocol_host(protocol, host)?;
138+
Ok(protocol)
139+
}
140+
141+
fn validate_protocol_host(protocol: Protocol, host: Option<&str>) -> Result<()> {
142+
match (protocol, host) {
143+
(Protocol::Azure, None) => {
144+
bail!(
145+
"Protocol `azure` requires an Azure OpenAI `host` endpoint. Set `host` to your Azure OpenAI realtime websocket URL."
146+
)
147+
}
148+
(Protocol::Azure, Some(_)) => Ok(()),
149+
(Protocol::OpenAI, _) => Ok(()),
150+
}
151+
}
152+
153+
fn infer_protocol_from_host(host: Option<&str>) -> Result<Protocol> {
154+
let host = match host {
155+
Some(host) => host,
156+
None => return Ok(Protocol::OpenAI),
157+
};
158+
159+
let parsed = Url::parse(host).with_context(|| format!("Invalid host URL: {host}"))?;
160+
161+
let is_openai_realtime_endpoint = parsed.scheme() == "wss"
162+
&& parsed.host_str() == Some("api.openai.com")
163+
&& parsed.path() == "/v1/realtime";
164+
165+
if is_openai_realtime_endpoint {
166+
return Ok(Protocol::OpenAI);
167+
}
168+
169+
match parsed.host_str() {
170+
Some(host) if host.ends_with(".openai.azure.com") => Ok(Protocol::Azure),
171+
_ => bail!(
172+
"Cannot infer protocol from host `{host}`. Set `protocol` explicitly to `openai` or `azure`, use `wss://api.openai.com/v1/realtime`, or use an Azure OpenAI host."
173+
),
174+
}
175+
}
176+
110177
#[derive(Debug, Serialize, Deserialize)]
111178
#[serde(tag = "type", rename_all = "camelCase")]
112179
pub enum ServiceInputEvent {
@@ -165,18 +232,24 @@ impl fmt::Debug for Host {
165232
}
166233

167234
impl Host {
168-
pub fn new_with_host(host: &str, api_key: &str, model: &str) -> Self {
235+
pub fn new_with_host(host: &str, api_key: &str, model: &str, protocol: Protocol) -> Self {
169236
Host {
170-
client: RealtimeClient::new_with_endpoint(host.into(), api_key.into(), model.into()),
237+
client: RealtimeClient::new_with_endpoint_and_protocol(
238+
host.into(),
239+
api_key.into(),
240+
model.into(),
241+
protocol.to_realtime_protocol(),
242+
),
171243
}
172244
}
173245

174-
pub fn new(api_key: &str, model: &str) -> Self {
246+
pub fn new(api_key: &str, model: &str, protocol: Protocol) -> Self {
175247
Host {
176-
client: RealtimeClient::new_with_endpoint(
248+
client: RealtimeClient::new_with_endpoint_and_protocol(
177249
"wss://api.openai.com/v1/realtime".into(),
178250
api_key.into(),
179251
model.into(),
252+
protocol.to_realtime_protocol(),
180253
),
181254
}
182255
}
@@ -188,23 +261,14 @@ impl Host {
188261
.await
189262
.map_err(|e| anyhow!(e.to_string()))?;
190263

191-
Ok(Client::new(read, write, self.is_azure_host()))
192-
}
193-
194-
fn is_azure_host(&self) -> bool {
195-
self.client
196-
.wss_url
197-
.split('/')
198-
.nth(2)
199-
.map(|host| host.ends_with(".openai.azure.com"))
200-
.unwrap_or(false)
264+
Ok(Client::new(read, write, self.client.protocol))
201265
}
202266
}
203267

204268
pub struct Client {
205269
read: SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>,
206270
write: SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>,
207-
use_tagged_session_update: bool,
271+
protocol: RealtimeProtocol,
208272

209273
response_state: ResponseState,
210274
inflight_prompt: Option<(String, PromptRequest)>,
@@ -222,18 +286,32 @@ impl Client {
222286
fn new(
223287
read: SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>,
224288
write: SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>,
225-
use_tagged_session_update: bool,
289+
protocol: RealtimeProtocol,
226290
) -> Self {
227291
Self {
228292
read,
229293
write,
230-
use_tagged_session_update,
294+
protocol,
231295
response_state: ResponseState::Idle,
232296
inflight_prompt: None,
233297
pending_prompts: Default::default(),
234298
}
235299
}
236300

301+
fn session_update_payload(
302+
&self,
303+
session: types::RealtimeSession,
304+
) -> client_event::SessionUpdatePayload {
305+
match self.protocol {
306+
RealtimeProtocol::OpenAI => client_event::SessionUpdatePayload::Untagged(
307+
types::UntaggedSession::Realtime(session),
308+
),
309+
RealtimeProtocol::Azure => {
310+
client_event::SessionUpdatePayload::Tagged(types::Session::Realtime(session))
311+
}
312+
}
313+
}
314+
237315
/// Run an audio dialog.
238316
pub async fn dialog(
239317
&mut self,
@@ -308,13 +386,7 @@ impl Client {
308386
}
309387

310388
if send_update {
311-
let payload = if self.use_tagged_session_update {
312-
client_event::SessionUpdatePayload::Tagged(types::Session::Realtime(session))
313-
} else {
314-
client_event::SessionUpdatePayload::Untagged(types::UntaggedSession::Realtime(
315-
session,
316-
))
317-
};
389+
let payload = self.session_update_payload(session);
318390

319391
self.send_client_event(ClientEvent::SessionUpdate(client_event::SessionUpdate {
320392
event_id: None,
@@ -514,15 +586,7 @@ impl Client {
514586
..Default::default()
515587
};
516588

517-
let payload = if self.use_tagged_session_update {
518-
client_event::SessionUpdatePayload::Tagged(types::Session::Realtime(
519-
session,
520-
))
521-
} else {
522-
client_event::SessionUpdatePayload::Untagged(
523-
types::UntaggedSession::Realtime(session),
524-
)
525-
};
589+
let payload = self.session_update_payload(session);
526590

527591
let event = ClientEvent::SessionUpdate(client_event::SessionUpdate {
528592
session: payload,

0 commit comments

Comments
 (0)