diff --git a/playwright.config.ts b/playwright.config.ts index bd022eeb2..29eab0b9a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -85,6 +85,7 @@ export default defineConfig({ name: "voice-conversation", testMatch: [ "**/voice-conversation.spec.ts", + "**/voice-endpoints.spec.ts", "**/voice-settings-visual.spec.ts", ], use: { diff --git a/scripts/verify-voice-endpoint-routing.py b/scripts/verify-voice-endpoint-routing.py new file mode 100644 index 000000000..a8ab1ec76 --- /dev/null +++ b/scripts/verify-voice-endpoint-routing.py @@ -0,0 +1,126 @@ +# /// script +# dependencies = ["websockets>=16,<17"] +# /// +"""Exercise berd-call URL overrides with loopback services and a PCM test host.""" +import argparse +import asyncio +import json +import os +import socket +import struct +from pathlib import Path +from tempfile import TemporaryDirectory +from urllib.parse import urlsplit +from websockets.exceptions import ConnectionClosed +from websockets.asyncio.server import serve + +async def main(binary): + observed = [] + sandbox = TemporaryDirectory(prefix="berd-voice-routing-") + config = Path(sandbox.name) / "config" / "openai-voice-endpoints.json" + config.parent.mkdir() + saved_settings = b'{"stt":"wss://saved.example/stt","tts":"https://saved.example/tts","realtime":"wss://saved.example/realtime"}' + config.write_bytes(saved_settings) + async def websocket(ws): + observed.append(urlsplit(ws.request.path).path) + assert ws.request.headers["Authorization"] == "Bearer disposable-routing-test" + try: + async for raw in ws: + event = json.loads(raw) + if event.get('type') == 'session.update': + session = event['session'] + await ws.send(json.dumps({'type': 'session.updated', 'session': { + 'model': 'test-model', 'audio': session.get('audio', {}) + }})) + except ConnectionClosed: + pass + async def synthesis(reader, writer): + headers = await reader.readuntil(b'\r\n\r\n') + length = next(int(line.split(b':', 1)[1]) for line in headers.split(b'\r\n') if line.lower().startswith(b'content-length:')) + body = json.loads(await reader.readexactly(length)) + assert body['input'] == 'A lighthouse guides the boat home.' + observed.append(headers.split(b' ')[1].decode()) + pcm = b'\x00\x00' * 2400 + writer.write(b'HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: ' + str(len(pcm)).encode() + b'\r\nConnection: close\r\n\r\n' + pcm) + await writer.drain() + writer.close() + async with serve(websocket, '127.0.0.1', 0) as ws_server: + http = await asyncio.start_server(synthesis, '127.0.0.1', 0) + ws_port = ws_server.sockets[0].getsockname()[1] + http_port = http.sockets[0].getsockname()[1] + async def run(options, speak=False, defaults=False): + child_audio, host_audio = socket.socketpair() + env = dict(os.environ, OPENAI_API_KEY='disposable-routing-test', OPENAI_REALTIME_MODEL='test-model', OPENAI_REALTIME_ENDPOINT='ws://127.0.0.1:1/unused', OPENAI_BASE_URL='http://127.0.0.1:1/unused') + env['GOOSE_PATH_ROOT'] = sandbox.name + if defaults: + env['OPENAI_REALTIME_ENDPOINT'] = f'ws://127.0.0.1:{ws_port}/default-stt' + env['OPENAI_BASE_URL'] = f'http://127.0.0.1:{http_port}/default' + process = await asyncio.create_subprocess_exec(str(binary), 'session', '--pcm-output-fd', str(child_audio.fileno()), *options, env=env, pass_fds=(child_audio.fileno(),), stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + child_audio.close() + async def send(value): + payload = json.dumps(value).encode() + process.stdin.write(b'BV\x03\x01' + struct.pack(' bool { + let scheme_allowed = match protocol { + EndpointProtocol::WebSocket => matches!(url.scheme(), "ws" | "wss"), + EndpointProtocol::Http => matches!(url.scheme(), "http" | "https"), + }; + let loopback = url.host_str().is_some_and(|host| { + host.eq_ignore_ascii_case("localhost") + || host + .trim_matches(['[', ']']) + .parse::() + .is_ok_and(|address| address.is_loopback()) + }); + scheme_allowed + && url.host_str().is_some() + && (!matches!(url.scheme(), "http" | "ws") || loopback) + && url.username().is_empty() + && url.password().is_none() + && url.fragment().is_none() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enforces_protocol_and_loopback_policy() { + assert!(is_allowed_endpoint_url( + &Url::parse("ws://[::1]:18870/realtime").unwrap(), + EndpointProtocol::WebSocket, + )); + assert!(is_allowed_endpoint_url( + &Url::parse("http://localhost:18870/speech").unwrap(), + EndpointProtocol::Http, + )); + for raw in [ + "http://example.test/speech", + "http://user@example.test/speech", + "https://example.test/speech#fragment", + ] { + assert!(!is_allowed_endpoint_url( + &Url::parse(raw).unwrap(), + EndpointProtocol::Http, + )); + } + assert!(!is_allowed_endpoint_url( + &Url::parse("https://example.test/speech").unwrap(), + EndpointProtocol::WebSocket, + )); + } +} diff --git a/src-tauri/crates/berd-call/src/host_session.rs b/src-tauri/crates/berd-call/src/host_session.rs index cd6820707..d827ae2fc 100644 --- a/src-tauri/crates/berd-call/src/host_session.rs +++ b/src-tauri/crates/berd-call/src/host_session.rs @@ -27,6 +27,7 @@ use berd_call::PocketAudioPlayer; use crate::codex::{self, CodexRecord, CodexRelay, CodexTarget}; use crate::host_control::{ControlServer, HostControl}; +use crate::saved_settings; use crate::session_audio::{ AUDIO_BEGIN_KIND, AUDIO_CANCEL_KIND, AUDIO_CHUNK_KIND, AUDIO_END_KIND, AUDIO_FRAME_HEADER_BYTES, AUDIO_FRAME_MAGIC, AUDIO_FRAME_MARKER, @@ -340,7 +341,7 @@ impl SessionActor { ) -> SessionStart { SessionStart { saved: self.saved.take().map(|(path, mut saved)| { - saved.arguments = restart.arguments[1..].to_vec(); + saved.arguments = saved_settings::persistable_arguments(&restart.arguments[1..]); saved.tts = None; (path, saved) }), @@ -2687,6 +2688,69 @@ mod tests { ); } + #[test] + fn restart_keeps_endpoint_overrides_out_of_saved_preferences() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("settings.json"); + let mut child = Command::new("/bin/cat") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .unwrap(); + let writer = Arc::new(Mutex::new(child.stdin.take().unwrap())); + let (_events_tx, events) = mpsc::sync_channel(1); + let (_commands_tx, commands) = mpsc::sync_channel(1); + let (audio, _audio_rx) = mpsc::sync_channel(1); + let mut actor = test_actor(writer, events, commands, audio); + actor.saved = Some(( + path.clone(), + crate::saved_settings::SavedSettings::default(), + )); + let arguments = [ + "session", + "--mode", + "chained", + "--realtime-url", + "ws://127.0.0.1:18870/realtime", + "--stt-backend", + "openai", + "--stt-url", + "ws://127.0.0.1:18870/stt", + "--tts-backend", + "openai", + "--tts-url", + "http://127.0.0.1:18870/tts", + ] + .map(str::to_string) + .to_vec(); + let (response, _reply) = mpsc::sync_channel(1); + let start = actor.restart_start( + PendingRestart { + arguments: arguments.clone(), + expert_spokesperson: false, + response, + }, + InputDuringTtsPolicy::AllowBargeIn, + ); + assert_eq!(start.arguments, arguments); + let (_, saved) = start.saved.unwrap(); + crate::saved_settings::save(&path, &saved).unwrap(); + assert_eq!( + crate::saved_settings::load(&path).unwrap().arguments, + [ + "--mode", + "chained", + "--stt-backend", + "openai", + "--tts-backend", + "openai", + ] + .map(str::to_string) + ); + drop(actor); + assert!(child.wait().unwrap().success()); + } + #[test] fn restart_preserves_native_intent_before_child_acknowledgement() { for requested in [true, false] { diff --git a/src-tauri/crates/berd-call/src/lib.rs b/src-tauri/crates/berd-call/src/lib.rs index 831260bec..d8c593610 100644 --- a/src-tauri/crates/berd-call/src/lib.rs +++ b/src-tauri/crates/berd-call/src/lib.rs @@ -8,6 +8,7 @@ mod audio_output; pub mod benchmark; pub mod causal_inbox; mod configured_tts; +pub mod endpoint_url; pub mod expert_spokesperson; pub mod input; pub mod local_assets; diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index 92974a01c..c1a2083ba 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -15,17 +15,21 @@ use berd_call::benchmark::{ load_bundled_tts_prompt_manifest, SttBenchmarkEnvironment, SttBenchmarkMode, SttBenchmarkTarget, TtsBenchmarkMode, TtsBenchmarkPromptManifest, TtsBenchmarkTarget, }; +use berd_call::endpoint_url::{is_allowed_endpoint_url, EndpointProtocol}; use berd_call::expert_spokesperson::{ExpertDirectiveOutcome, LiveSideEvent}; use berd_call::input::{ AssistantActivityGuard, InputDuringTtsSlot, InputDuringTtsSnapshot, VoiceInputConfig, VoiceInputControls, VoiceInputEngineConfig, VoiceInputEvent, VoiceInputFrame, VoiceInputRuntime, INPUT_FRAME_SAMPLES, }; -use berd_call::openai_realtime_protocol::{ - expert_handoff_message, expert_transcript_message, RealtimeExpertMessage, - RealtimeExpertMessageMode, RealtimeExpertSpokespersonSession, RealtimeHandoffReminder, - RealtimeTranscriptSpeaker, -}; +use berd_call::openai_realtime_protocol::accepted_handoff_tool_output; +use berd_call::openai_realtime_protocol::expert_handoff_message; +use berd_call::openai_realtime_protocol::expert_transcript_message; +use berd_call::openai_realtime_protocol::RealtimeExpertMessage; +use berd_call::openai_realtime_protocol::RealtimeExpertMessageMode; +use berd_call::openai_realtime_protocol::RealtimeExpertSpokespersonSession; +use berd_call::openai_realtime_protocol::RealtimeHandoffReminder; +use berd_call::openai_realtime_protocol::RealtimeTranscriptSpeaker; use berd_call::openai_spokesperson::{ OpenAiSpokespersonConfig, OpenAiSpokespersonRuntime, SpokespersonCommand, SpokespersonEvent, SpokespersonResponseStatus, @@ -184,6 +188,14 @@ struct SessionConfig { tts: TtsBackendConfig, stt: SttBackendConfig, mode: SessionMode, + endpoints: SessionEndpointOverrides, +} + +#[derive(Clone, Debug, Default, PartialEq)] +struct SessionEndpointOverrides { + realtime: Option, + tts: Option, + stt: Option, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -800,7 +812,7 @@ fn parse_saved_start_args_at(args: &[String], path: PathBuf) -> Result Result<(), String abort_active(&active); return Ok(()); } - let slot = match create_tts_slot(&config.tts) { + let slot = match create_tts_slot(&config.tts, config.endpoints.tts.as_deref()) { Ok(slot) => Arc::new(slot), Err(message) => { write_protocol_fatal( @@ -2059,17 +2071,18 @@ fn run_session(config: SessionConfig, pcm_output_fd: RawFd) -> Result<(), String return Ok(()); } }; - let (runtime, mut events) = match create_input_runtime(&config.stt) { - Ok(runtime) => runtime, - Err(message) => { - write_protocol_fatal( - &mut writer, - &public_stt_startup_error(&config.stt), - &format!("STT startup failed: {message}"), - )?; - return Ok(()); - } - }; + let (runtime, mut events) = + match create_input_runtime(&config.stt, config.endpoints.stt.as_deref()) { + Ok(runtime) => runtime, + Err(message) => { + write_protocol_fatal( + &mut writer, + &public_stt_startup_error(&config.stt), + &format!("STT startup failed: {message}"), + )?; + return Ok(()); + } + }; let readiness = wait_for_input_ready(&mut events, INPUT_STARTUP_TIMEOUT); if let Err(message) = readiness { runtime.cancel(); @@ -3597,11 +3610,23 @@ fn run_expert_spokesperson_session( call_id, message, } => { - record_and_emit_live_event( + let handoff_id = match record_and_emit_live_event( &mut core, &mut emitted_live_token, - LiveSideEvent::Handoff { call_id, message }, + LiveSideEvent::Handoff { + call_id: call_id.clone(), + message, + }, &mut writer, + )? { + LiveSideEvent::Handoff { call_id, .. } => call_id, + _ => return Err("live handoff did not produce a handoff ID".into()), + }; + runtime.as_ref().expect("initialized runtime").send( + SpokespersonCommand::Provider(accepted_handoff_tool_output( + &call_id, + &handoff_id, + )?), )?; } event @ (SpokespersonEvent::Expired(_) | SpokespersonEvent::SessionLost(_)) => { @@ -4005,6 +4030,9 @@ fn run_expert_spokesperson_session( break; } let mut spokesperson_config = OpenAiSpokespersonConfig::from_environment()?; + if let Some(url) = &config.endpoints.realtime { + spokesperson_config.endpoint.clone_from(url); + } apply_spokesperson_startup_settings(&config, &mut spokesperson_config)?; let tts = berd_call::TtsConfigurationSnapshot { revision: 1, @@ -4985,8 +5013,8 @@ fn record_and_emit_live_event( emitted_live_token: &mut u64, event: LiveSideEvent, writer: &mut impl Write, -) -> Result<(), String> { - let (_, expert_delivery) = core.record_live_event_with_delivery(event)?; +) -> Result { + let (recorded, expert_delivery) = core.record_live_event_with_delivery(event)?; emit_live_events(core, emitted_live_token, writer)?; if let Some(delivery) = expert_delivery { write_message( @@ -4999,7 +5027,7 @@ fn record_and_emit_live_event( }, )?; } - Ok(()) + Ok(recorded.payload) } fn publish_live_response_if_complete( @@ -5229,6 +5257,7 @@ fn parse_args(args: &[String]) -> Result { let mut stt_backend = "macos"; let mut stt_model_dir = None; let mut mode = SessionMode::Conventional; + let mut endpoints = SessionEndpointOverrides::default(); let mut index = 2; while index < args.len() { let flag = args[index].as_str(); @@ -5252,6 +5281,11 @@ fn parse_args(args: &[String]) -> Result { } "--stt-backend" => stt_backend = value, "--stt-model-dir" => stt_model_dir = Some(PathBuf::from(value)), + "--realtime-url" => { + endpoints.realtime = Some(parse_endpoint_url(value, "--realtime-url", true)?) + } + "--stt-url" => endpoints.stt = Some(parse_endpoint_url(value, "--stt-url", true)?), + "--tts-url" => endpoints.tts = Some(parse_endpoint_url(value, "--tts-url", false)?), "--mode" => { mode = match value.as_str() { "conventional" => SessionMode::Conventional, @@ -5266,7 +5300,39 @@ fn parse_args(args: &[String]) -> Result { } let tts = build_tts_backend_config(backend, voice, language, model_dir, rate)?; let stt = build_stt_backend_config(stt_backend, stt_model_dir)?; - Ok(SessionConfig { tts, stt, mode }) + if endpoints.realtime.is_some() && mode != SessionMode::ExpertSpokesperson { + return Err("--realtime-url requires --mode expert-spokesperson".into()); + } + if mode == SessionMode::ExpertSpokesperson + && (endpoints.tts.is_some() || endpoints.stt.is_some()) + { + return Err("--tts-url and --stt-url apply only to conventional mode; Expert-Spokesperson uses --realtime-url".into()); + } + if endpoints.tts.is_some() && !matches!(tts, TtsBackendConfig::OpenAi { .. }) { + return Err("--tts-url requires --tts-backend openai".into()); + } + if endpoints.stt.is_some() && !matches!(stt, SttBackendConfig::OpenAi) { + return Err("--stt-url requires --stt-backend openai".into()); + } + Ok(SessionConfig { + tts, + stt, + mode, + endpoints, + }) +} + +fn parse_endpoint_url(value: &str, flag: &str, websocket: bool) -> Result { + let url = reqwest::Url::parse(value).map_err(|error| format!("{flag} is invalid: {error}"))?; + let protocol = if websocket { + EndpointProtocol::WebSocket + } else { + EndpointProtocol::Http + }; + if !is_allowed_endpoint_url(&url, protocol) { + return Err(format!("{flag} requires a full URL with the correct protocol, HTTPS/WSS outside loopback, and no embedded credentials or fragment")); + } + Ok(url.to_string()) } fn parse_pcm_output_fd(args: &[String]) -> Result { @@ -5868,7 +5934,7 @@ fn create_stt_benchmark_report( &pack, config.runs, config.mode, - || create_input_runtime(&config.stt), + || create_input_runtime(&config.stt, None), ))) } @@ -5937,12 +6003,16 @@ fn stt_benchmark_target(config: &SttBackendConfig) -> Result Result { +fn create_tts_configuration( + config: &TtsBackendConfig, + endpoint: Option<&str>, +) -> Result { match config { TtsBackendConfig::OpenAi { rate } => create_openai_tts_configuration( *rate, std::env::var("OPENAI_TTS_MODEL").unwrap_or_else(|_| "gpt-4o-mini-tts".into()), std::env::var("OPENAI_TTS_VOICE").unwrap_or_else(|_| "marin".into()), + endpoint, ), TtsBackendConfig::Siri { voice, @@ -5970,6 +6040,7 @@ fn create_openai_tts_configuration( rate: f32, model: String, voice: String, + endpoint: Option<&str>, ) -> Result { let api_key = std::env::var("OPENAI_API_KEY") .ok() @@ -5978,7 +6049,9 @@ fn create_openai_tts_configuration( let base = std::env::var("OPENAI_BASE_URL").unwrap_or_else(|_| "https://api.openai.com/v1".into()); Ok(TtsConfiguration::openai( - format!("{}/audio/speech", base.trim_end_matches('/')), + endpoint + .map(str::to_string) + .unwrap_or_else(|| format!("{}/audio/speech", base.trim_end_matches('/'))), api_key, model, voice, @@ -5986,7 +6059,10 @@ fn create_openai_tts_configuration( )) } -fn create_tts_slot(config: &TtsBackendConfig) -> Result { +fn create_tts_slot( + config: &TtsBackendConfig, + endpoint: Option<&str>, +) -> Result { #[cfg(not(target_os = "macos"))] if matches!(config, TtsBackendConfig::Siri { .. }) { return Err( @@ -5994,7 +6070,7 @@ fn create_tts_slot(config: &TtsBackendConfig) -> Result format!( @@ -6005,7 +6081,7 @@ fn create_tts_slot(config: &TtsBackendConfig) -> Result Result, String> { - let slot = create_tts_slot(config)?; + let slot = create_tts_slot(config, None)?; Ok(Arc::clone(slot.lease()?.backend())) } @@ -6016,6 +6092,7 @@ fn create_synthesis_backend(config: &SynthesisTtsConfig) -> Result Result<(), SynthesisFailure fn create_input_runtime( config: &SttBackendConfig, + endpoint_override: Option<&str>, ) -> Result< ( VoiceInputRuntime, @@ -6229,9 +6307,13 @@ fn create_input_runtime( .ok() .filter(|key| !key.trim().is_empty()) .ok_or_else(|| "OPENAI_API_KEY is required for OpenAI STT".to_string())?; - let endpoint = std::env::var("OPENAI_REALTIME_ENDPOINT") - .ok() - .filter(|value| !value.trim().is_empty()) + let endpoint = endpoint_override + .map(str::to_string) + .or_else(|| { + std::env::var("OPENAI_REALTIME_ENDPOINT") + .ok() + .filter(|value| !value.trim().is_empty()) + }) .unwrap_or_else(|| { "wss://api.openai.com/v1/realtime?intent=transcription".to_string() }); @@ -7487,6 +7569,35 @@ mod tests { assert_eq!(messages[1]["confirmed_token"], 1); } + #[test] + fn live_handoff_exposes_its_id_for_provider_tool_output() { + let mut core = RealtimeExpertSpokespersonSession::new(0, "external-test"); + let mut emitted_token = 0; + let mut output = Vec::new(); + let handoff_id = match record_and_emit_live_event( + &mut core, + &mut emitted_token, + LiveSideEvent::Handoff { + call_id: "provider-call-1".into(), + message: "Look something up".into(), + }, + &mut output, + ) + .unwrap() + { + LiveSideEvent::Handoff { call_id, .. } => call_id, + _ => panic!("a live handoff must expose its generated ID"), + }; + assert_eq!(handoff_id, "handoff-external-test-1"); + let tool_output = berd_call::openai_realtime_protocol::accepted_handoff_tool_output( + "provider-call-1", + &handoff_id, + ) + .unwrap(); + assert_eq!(tool_output["type"], "conversation.item.create"); + assert_eq!(tool_output["item"]["call_id"], "provider-call-1"); + } + #[test] fn interrupted_spokesperson_history_keeps_only_estimated_delivered_prefix() { let mut response = LiveResponse::new(None, Some(3)); @@ -9358,6 +9469,7 @@ mod tests { }, stt: SttBackendConfig::Macos, mode: SessionMode::Conventional, + endpoints: SessionEndpointOverrides::default(), } ); @@ -9367,6 +9479,7 @@ mod tests { tts: TtsBackendConfig::OpenAi { rate: 1.0 }, stt: SttBackendConfig::Macos, mode: SessionMode::Conventional, + endpoints: SessionEndpointOverrides::default(), } ); } @@ -9442,6 +9555,7 @@ mod tests { }, stt: SttBackendConfig::Macos, mode: SessionMode::Conventional, + endpoints: SessionEndpointOverrides::default(), } ); assert!(parse_args(&args(&[ @@ -9496,6 +9610,7 @@ mod tests { }, stt: SttBackendConfig::Macos, mode: SessionMode::ExpertSpokesperson, + endpoints: SessionEndpointOverrides::default(), }; let mut realtime = OpenAiSpokespersonConfig { endpoint: "ws://localhost".into(), @@ -9516,6 +9631,91 @@ mod tests { assert_eq!(realtime.voice(), "marin"); } + #[test] + fn session_endpoint_overrides_are_independent_and_require_matching_modes() { + let realtime = parse_args(&args(&[ + "berd-call", + "session", + "--tts-backend", + "openai", + "--mode", + "expert-spokesperson", + "--realtime-url", + "ws://127.0.0.1:18870/v1/realtime", + ])) + .unwrap(); + assert_eq!( + realtime.endpoints.realtime.as_deref(), + Some("ws://127.0.0.1:18870/v1/realtime") + ); + assert_eq!(realtime.endpoints.stt, None); + assert_eq!(realtime.endpoints.tts, None); + + let chained = parse_args(&args(&[ + "berd-call", + "session", + "--tts-backend", + "openai", + "--stt-backend", + "openai", + "--tts-url", + "https://proxy.example/v1/audio/speech?api-version=1", + "--stt-url", + "wss://proxy.example/v1/realtime?intent=transcription", + ])) + .unwrap(); + assert_eq!( + chained.endpoints.tts.as_deref(), + Some("https://proxy.example/v1/audio/speech?api-version=1") + ); + assert_eq!( + chained.endpoints.stt.as_deref(), + Some("wss://proxy.example/v1/realtime?intent=transcription") + ); + assert!(parse_endpoint_url("http://example.test/speech", "--tts-url", false).is_err()); + assert!(parse_endpoint_url("ws://example.test/realtime", "--stt-url", true).is_err()); + assert!(parse_endpoint_url("http://127.0.0.1:18870/speech", "--tts-url", false).is_ok()); + assert!(parse_args(&args(&[ + "berd-call", + "session", + "--tts-backend", + "openai", + "--realtime-url", + "ws://localhost/realtime" + ])) + .unwrap_err() + .contains("expert-spokesperson")); + assert!(parse_args(&args(&[ + "berd-call", + "session", + "--tts-url", + "wss://localhost/audio/speech" + ])) + .is_err()); + assert!(parse_args(&args(&[ + "berd-call", + "session", + "--tts-backend", + "openai", + "--stt-url", + "wss://localhost/realtime" + ])) + .unwrap_err() + .contains("--stt-backend openai")); + assert!(parse_args(&args(&[ + "berd-call", + "session", + "--tts-backend", + "openai", + "--mode", + "expert-spokesperson", + "--tts-url", + "https://localhost/audio/speech" + ])) + .unwrap_err() + .contains("only to conventional mode")); + } + #[test] fn cli_requires_explicit_pocket_bundle_and_voice() { assert_eq!( @@ -9538,6 +9738,7 @@ mod tests { }, stt: SttBackendConfig::Macos, mode: SessionMode::Conventional, + endpoints: SessionEndpointOverrides::default(), } ); assert!(parse_args(&args(&[ @@ -9598,6 +9799,7 @@ mod tests { model_dir: PathBuf::from("/models/parakeet") }, mode: SessionMode::Conventional, + endpoints: SessionEndpointOverrides::default(), } ); assert!(parse_args(&args(&[ @@ -10041,6 +10243,7 @@ mod tests { }, stt: SttBackendConfig::OpenAi, mode: SessionMode::Conventional, + endpoints: SessionEndpointOverrides::default(), } ); } @@ -11123,6 +11326,27 @@ mod tests { let saved = changed.saved.unwrap().1; assert_eq!(saved.tts.unwrap().rate(), 1.2); assert!(!saved.arguments.contains(&"--codex".into())); + let with_endpoint = parse_saved_start_args_at( + &args(&[ + "berd-call", + "start", + "--stt-backend", + "openai", + "--stt-url", + "wss://proxy.example/v1/realtime?intent=transcription", + ]), + directory.path().join("settings.json"), + ) + .unwrap(); + assert!(with_endpoint + .session_arguments + .contains(&"--stt-url".into())); + assert!(!with_endpoint + .saved + .unwrap() + .1 + .arguments + .contains(&"--stt-url".into())); } #[test] diff --git a/src-tauri/crates/berd-call/src/saved_settings.rs b/src-tauri/crates/berd-call/src/saved_settings.rs index abb2c210e..8587b52b1 100644 --- a/src-tauri/crates/berd-call/src/saved_settings.rs +++ b/src-tauri/crates/berd-call/src/saved_settings.rs @@ -125,6 +125,20 @@ pub(crate) fn merge_arguments(saved: &[String], explicit: &[String]) -> Vec Vec { + arguments + .chunks_exact(2) + .filter(|pair| { + !matches!( + pair[0].as_str(), + "--realtime-url" | "--stt-url" | "--tts-url" + ) + }) + .flatten() + .cloned() + .collect() +} + pub(crate) fn without_tts(arguments: &[String]) -> Vec { arguments .chunks_exact(2) diff --git a/src-tauri/crates/berd-call/tests/session_protocol.rs b/src-tauri/crates/berd-call/tests/session_protocol.rs index 9960af86c..89c8339a3 100644 --- a/src-tauri/crates/berd-call/tests/session_protocol.rs +++ b/src-tauri/crates/berd-call/tests/session_protocol.rs @@ -64,9 +64,16 @@ impl ExpertSpokespersonTestSession { } let mut child = ChildGuard(Some( command - .args(["--mode", "expert-spokesperson", "--tts-backend", "openai"]) + .args([ + "--mode", + "expert-spokesperson", + "--tts-backend", + "openai", + "--realtime-url", + &endpoint, + ]) .env("OPENAI_API_KEY", "test-key") - .env("OPENAI_REALTIME_ENDPOINT", endpoint) + .env("OPENAI_REALTIME_ENDPOINT", "ws://127.0.0.1:1/unused") .env("OPENAI_REALTIME_MODEL", "test-model") .env("OPENAI_REALTIME_VOICE", "old-voice") .stdin(Stdio::piped()) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0a132a071..36af3b2ca 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -40,6 +40,7 @@ pub mod notifications; pub mod openai_audio; pub mod openai_realtime; mod openai_voice_credentials; +pub(crate) mod openai_voice_endpoints; pub mod path_resolver; pub mod pocket_voice; pub mod pr_tracker; diff --git a/src-tauri/src/commands/native_voice.rs b/src-tauri/src/commands/native_voice.rs index fda95c3c8..421292d53 100644 --- a/src-tauri/src/commands/native_voice.rs +++ b/src-tauri/src/commands/native_voice.rs @@ -1465,12 +1465,12 @@ pub async fn start_native_voice_conversation( "Download the macOS speech recognition model before starting a call.".to_string(), ); } - let openai_api_key = if input_backend == VoiceInputBackend::Openai { - Some(super::openai_audio::stt_api_key()?) + let openai_endpoint_and_key = if input_backend == VoiceInputBackend::Openai { + Some(super::openai_audio::stt_endpoint_and_key()?) } else { None }; - if openai_api_key.is_some() { + if openai_endpoint_and_key.is_some() { let deadline = tokio::time::Instant::now() + Duration::from_secs(1); while !webview_window.is_focused().unwrap_or(false) && tokio::time::Instant::now() < deadline @@ -1525,13 +1525,15 @@ pub async fn start_native_voice_conversation( return Err("macOS speech recognition requires macOS 26 or later.".to_string()); } } - VoiceInputBackend::Openai => super::openai_audio::realtime_endpoint().map(|endpoint| { - berd_call::input::VoiceInputEngineConfig::OpenAi { + VoiceInputBackend::Openai => { + let (endpoint, api_key) = + openai_endpoint_and_key.expect("OpenAI endpoint and key resolved for OpenAI input"); + Ok(berd_call::input::VoiceInputEngineConfig::OpenAi { endpoint, - api_key: openai_api_key.expect("OpenAI key resolved for OpenAI input"), + api_key, model: super::openai_audio::transcription_model(), - } - }), + }) + } }; let engine = match engine { Ok(engine) => engine, diff --git a/src-tauri/src/commands/openai_audio.rs b/src-tauri/src/commands/openai_audio.rs index 3e81d4cdf..0bb86a1a0 100644 --- a/src-tauri/src/commands/openai_audio.rs +++ b/src-tauri/src/commands/openai_audio.rs @@ -28,6 +28,7 @@ use super::pocket_voice::{ use super::{ native_voice::{InterruptionSensitivity, NativeVoiceState}, openai_voice_credentials::{self, OpenAiVoiceCredential}, + openai_voice_endpoints::{self, VoiceEndpointKind, BASE_URL_ENV, SETTINGS_CHANGED_EVENT}, pocket_voice::VoiceInterruptionMode, voice_capture::VoiceCaptureState, }; @@ -37,7 +38,6 @@ use berd_call::input::InputDuringTtsPolicy; #[cfg(any(test, target_os = "macos"))] use std::time::Instant; -const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; const DEFAULT_TRANSCRIPTION_MODEL: &str = "gpt-live-transcribe"; const DEFAULT_TTS_MODEL: &str = "gpt-4o-mini-tts"; const DEFAULT_TTS_VOICE: &str = "marin"; @@ -45,11 +45,9 @@ const TTS_VOICES: &[&str] = &[ "alloy", "ash", "ballad", "cedar", "coral", "echo", "fable", "marin", "nova", "onyx", "sage", "shimmer", "verse", ]; -const BASE_URL_ENV: &str = "BERD_OPENAI_VOICE_BASE_URL"; const STT_MODEL_ENV: &str = "BERD_OPENAI_STT_MODEL"; const TTS_MODEL_ENV: &str = "BERD_OPENAI_TTS_MODEL"; const TTS_VOICE_ENV: &str = "BERD_OPENAI_TTS_VOICE"; -const SETTINGS_CHANGED_EVENT: &str = "openai-voice:settings-changed"; #[cfg(target_os = "macos")] const TTS_SAMPLE_RATE: u32 = 24_000; // Avoid starting the audio device from a tiny first network chunk that can drain @@ -127,6 +125,7 @@ enum OpenAiStreamCommand { pub struct OpenAiVoiceStatus { stt_configured: bool, tts_configured: bool, + realtime_configured: bool, stt_configuration_source: OpenAiVoiceConfigurationSource, tts_configuration_source: OpenAiVoiceConfigurationSource, stt_unavailable_reason: Option, @@ -175,58 +174,8 @@ fn env_trimmed(name: &str) -> Option { .filter(|value| !value.is_empty()) } -#[cfg(target_os = "macos")] -fn tts_api_key() -> Result { - openai_voice_credentials::require(OpenAiVoiceCredential::TextToSpeech) -} - -pub(crate) fn stt_api_key() -> Result { - openai_voice_credentials::require(OpenAiVoiceCredential::SpeechToText) -} - -fn normalize_openai_base_url(raw_url: String) -> Result { - let mut url = reqwest::Url::parse(&raw_url) - .map_err(|error| format!("OpenAI voice endpoint is invalid: {error}"))?; - if url.scheme() != "https" { - return Err("OpenAI voice endpoint must use HTTPS".to_string()); - } - let path = url.path().trim_end_matches('/').to_string(); - if path.is_empty() { - let path = if path.ends_with("/v1") { - path - } else { - format!("{path}/v1") - }; - url.set_path(&path); - } else { - url.set_path(&path); - } - url.set_fragment(None); - Ok(url.to_string().trim_end_matches('/').to_string()) -} - -fn base_url() -> Result { - if let Some(base_url) = env_trimmed(BASE_URL_ENV) { - return normalize_openai_base_url(base_url); - } - Ok(DEFAULT_BASE_URL.to_string()) -} - -pub(crate) fn realtime_endpoint() -> Result { - let mut url = reqwest::Url::parse(&endpoint("realtime")?) - .map_err(|error| format!("OpenAI realtime endpoint is invalid: {error}"))?; - url.query_pairs_mut().append_pair("intent", "transcription"); - match url.scheme() { - "http" => url.set_scheme("ws").expect("compatible scheme"), - "https" => url.set_scheme("wss").expect("compatible scheme"), - "ws" | "wss" => {} - scheme => { - return Err(format!( - "OpenAI realtime endpoint has unsupported scheme: {scheme}" - )) - } - } - Ok(url.to_string()) +pub(crate) fn stt_endpoint_and_key() -> Result<(String, String), String> { + openai_voice_credentials::require_endpoint(OpenAiVoiceCredential::SpeechToText) } pub(crate) fn transcription_model() -> String { @@ -263,18 +212,6 @@ fn stt_configuration_source() -> OpenAiVoiceConfigurationSource { } } -fn endpoint(path: &str) -> Result { - endpoint_for_base_url(&base_url()?, path) -} - -fn endpoint_for_base_url(base_url: &str, path: &str) -> Result { - let mut url = reqwest::Url::parse(base_url) - .map_err(|error| format!("OpenAI voice endpoint is invalid: {error}"))?; - let base_path = url.path().trim_end_matches('/'); - url.set_path(&format!("{base_path}/{}", path.trim_start_matches('/'))); - Ok(url.to_string()) -} - fn voice_settings_path() -> Result { Ok(crate::services::goose_config::config_path()? .parent() @@ -377,21 +314,29 @@ pub async fn get_openai_voice_status( let tts_available = cfg!(target_os = "macos"); let credential_revision = state.credential_revision.load(Ordering::Acquire); let credential_result = tauri::async_runtime::spawn_blocking(move || { - openai_voice_credentials::read(OpenAiVoiceCredential::SpeechToText) + ( + openai_voice_credentials::is_present(OpenAiVoiceCredential::SpeechToText), + openai_voice_credentials::is_present(OpenAiVoiceCredential::TextToSpeech), + openai_voice_credentials::is_present(OpenAiVoiceCredential::SelectedRealtimeAssistant), + ) }) .await .map_err(|error| format!("Could not check OpenAI voice credentials: {error}"))?; - let credential_error = credential_result.as_ref().err().cloned(); - let stt_error = credential_error.clone(); - let tts_error = tts_available.then_some(credential_error).flatten(); - let stt_configured = credential_result.unwrap_or(None).is_some(); - let tts_configured = tts_available && stt_configured; + let (stt_result, tts_result, realtime_result) = credential_result; + let stt_error = stt_result.as_ref().err().cloned(); + let tts_error = tts_available + .then(|| tts_result.as_ref().err().cloned()) + .flatten(); + let stt_configured = stt_result.unwrap_or(false); + let tts_configured = tts_available && tts_result.unwrap_or(false); + let realtime_configured = realtime_result?; if state.credential_revision.load(Ordering::Acquire) == credential_revision { state.configured.store(stt_configured, Ordering::Release); } Ok(OpenAiVoiceStatus { stt_configured, tts_configured, + realtime_configured, stt_configuration_source: stt_configuration_source(), tts_configuration_source: tts_configuration_source(), stt_unavailable_reason: stt_error, @@ -418,6 +363,7 @@ pub async fn set_openai_stt_api_key( state: State<'_, OpenAiVoiceState>, native_voice: State<'_, NativeVoiceState>, capture: State<'_, VoiceCaptureState>, + expected_url: String, api_key: String, ) -> Result<(), String> { let api_key = api_key.trim(); @@ -427,7 +373,11 @@ pub async fn set_openai_stt_api_key( native_voice .stop_active_then(&app, &capture, || { stop_openai_voice_inner(&state)?; - openai_voice_credentials::store(OpenAiVoiceCredential::SpeechToText, api_key)?; + openai_voice_endpoints::with_selected_endpoint( + VoiceEndpointKind::Stt, + &expected_url, + || openai_voice_credentials::store(OpenAiVoiceCredential::SpeechToText, api_key), + )?; state.credential_revision.fetch_add(1, Ordering::AcqRel); state.configured.store(true, Ordering::Release); app.emit(SETTINGS_CHANGED_EVENT, ()) @@ -442,11 +392,16 @@ pub async fn clear_openai_stt_api_key( state: State<'_, OpenAiVoiceState>, native_voice: State<'_, NativeVoiceState>, capture: State<'_, VoiceCaptureState>, + expected_url: String, ) -> Result<(), String> { native_voice .stop_active_then(&app, &capture, || { stop_openai_voice_inner(&state)?; - openai_voice_credentials::clear(OpenAiVoiceCredential::SpeechToText)?; + openai_voice_endpoints::with_selected_endpoint( + VoiceEndpointKind::Stt, + &expected_url, + || openai_voice_credentials::clear(OpenAiVoiceCredential::SpeechToText), + )?; state.credential_revision.fetch_add(1, Ordering::AcqRel); state.configured.store(false, Ordering::Release); app.emit(SETTINGS_CHANGED_EVENT, ()) @@ -461,6 +416,7 @@ pub async fn set_openai_tts_api_key( state: State<'_, OpenAiVoiceState>, native_voice: State<'_, NativeVoiceState>, capture: State<'_, VoiceCaptureState>, + expected_url: String, api_key: String, ) -> Result<(), String> { let api_key = api_key.trim(); @@ -470,9 +426,16 @@ pub async fn set_openai_tts_api_key( native_voice .stop_active_then(&app, &capture, || { stop_openai_voice_inner(&state)?; - openai_voice_credentials::store(OpenAiVoiceCredential::TextToSpeech, api_key)?; + openai_voice_endpoints::with_selected_endpoint( + VoiceEndpointKind::Tts, + &expected_url, + || openai_voice_credentials::store(OpenAiVoiceCredential::TextToSpeech, api_key), + )?; state.credential_revision.fetch_add(1, Ordering::AcqRel); - state.configured.store(true, Ordering::Release); + state.configured.store( + openai_voice_credentials::is_present(OpenAiVoiceCredential::SpeechToText)?, + Ordering::Release, + ); app.emit(SETTINGS_CHANGED_EVENT, ()) .map_err(|error| format!("Could not refresh OpenAI voice settings: {error}")) }) @@ -485,13 +448,21 @@ pub async fn clear_openai_tts_api_key( state: State<'_, OpenAiVoiceState>, native_voice: State<'_, NativeVoiceState>, capture: State<'_, VoiceCaptureState>, + expected_url: String, ) -> Result<(), String> { native_voice .stop_active_then(&app, &capture, || { stop_openai_voice_inner(&state)?; - openai_voice_credentials::clear(OpenAiVoiceCredential::TextToSpeech)?; + openai_voice_endpoints::with_selected_endpoint( + VoiceEndpointKind::Tts, + &expected_url, + || openai_voice_credentials::clear(OpenAiVoiceCredential::TextToSpeech), + )?; state.credential_revision.fetch_add(1, Ordering::AcqRel); - state.configured.store(false, Ordering::Release); + state.configured.store( + openai_voice_credentials::is_present(OpenAiVoiceCredential::SpeechToText)?, + Ordering::Release, + ); app.emit(SETTINGS_CHANGED_EVENT, ()) .map_err(|error| format!("Could not refresh OpenAI voice settings: {error}")) }) @@ -551,7 +522,8 @@ pub fn start_openai_voice_stream( else { return Ok(false); }; - let key = tts_api_key()?; + let (endpoint, key) = + openai_voice_credentials::require_endpoint(OpenAiVoiceCredential::TextToSpeech)?; { let mut playback = state .playback @@ -584,6 +556,7 @@ pub fn start_openai_voice_stream( let result = run_openai_voice_stream( &app, &stream_id, + endpoint, key, active.clone(), receiver, @@ -694,6 +667,7 @@ pub fn reset_openai_voice_settings( state: State<'_, OpenAiVoiceState>, ) -> Result<(), String> { let defaults = OpenAiVoiceSettings::default(); + openai_voice_endpoints::reset()?; { let mut playback = state .playback @@ -798,6 +772,7 @@ impl From for StreamFailure { fn run_openai_voice_stream( app: &AppHandle, stream_id: &str, + endpoint: String, key: String, active: Arc, receiver: mpsc::Receiver, @@ -810,7 +785,7 @@ fn run_openai_voice_stream( voice: String, ) -> Result { let tts = ConfiguredTtsSlot::new(TtsConfiguration::openai( - endpoint("audio/speech")?, + endpoint, key, speech_model(), voice, @@ -1163,43 +1138,6 @@ mod tests { assert!(!active.load(Ordering::SeqCst)); } - #[test] - fn voice_base_url_configuration_resolves_to_the_v1_api_root() { - assert_eq!( - normalize_openai_base_url("https://proxy.example".to_string()).unwrap(), - "https://proxy.example/v1" - ); - assert_eq!( - normalize_openai_base_url("https://proxy.example/v1/".to_string()).unwrap(), - "https://proxy.example/v1" - ); - } - - #[test] - fn openai_voice_endpoints_require_https() { - assert_eq!( - normalize_openai_base_url("http://proxy.example".to_string()) - .expect_err("plaintext endpoint must be rejected"), - "OpenAI voice endpoint must use HTTPS" - ); - } - - #[test] - fn openai_base_url_preserves_custom_paths_and_query_parameters() { - assert_eq!( - normalize_openai_base_url("https://proxy.example".to_string()).unwrap(), - "https://proxy.example/v1" - ); - let base = normalize_openai_base_url( - "https://proxy.example/openai?api-version=2026-01-01".to_string(), - ) - .unwrap(); - assert_eq!( - endpoint_for_base_url(&base, "audio/speech").unwrap(), - "https://proxy.example/openai/audio/speech?api-version=2026-01-01" - ); - } - #[test] fn voice_configuration_uses_berd_scoped_environment_names() { assert_eq!(BASE_URL_ENV, "BERD_OPENAI_VOICE_BASE_URL"); diff --git a/src-tauri/src/commands/openai_realtime.rs b/src-tauri/src/commands/openai_realtime.rs index f4740b500..11922daea 100644 --- a/src-tauri/src/commands/openai_realtime.rs +++ b/src-tauri/src/commands/openai_realtime.rs @@ -20,6 +20,7 @@ use std::{ use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow}; use super::openai_voice_credentials::{self, OpenAiVoiceCredential}; +use super::openai_voice_endpoints::{self, VoiceEndpointKind}; use super::voice_capture::VoiceCaptureState; const OPENAI_REALTIME_CLIENT_SECRETS_URL: &str = @@ -75,20 +76,64 @@ pub struct OpenAiRealtimeSession { client_secret: String, } -fn stored_openai_api_key() -> Result, String> { - openai_voice_credentials::read(OpenAiVoiceCredential::Realtime) -} - #[tauri::command] pub async fn get_openai_realtime_status() -> Result { - let configured = stored_openai_api_key()?.is_some(); + let configured = + openai_voice_credentials::is_present(OpenAiVoiceCredential::DefaultRealtimeDictation)?; Ok(OpenAiRealtimeStatus { configured }) } +#[tauri::command] +pub async fn set_openai_realtime_api_key( + app: AppHandle, + api_key: String, + expected_url: String, +) -> Result<(), String> { + let api_key = api_key.trim().to_string(); + if api_key.is_empty() { + return Err("Realtime API key cannot be empty".into()); + } + tauri::async_runtime::spawn_blocking(move || { + openai_voice_endpoints::with_selected_endpoint( + VoiceEndpointKind::Realtime, + &expected_url, + || { + openai_voice_credentials::store( + OpenAiVoiceCredential::SelectedRealtimeAssistant, + &api_key, + ) + }, + ) + }) + .await + .map_err(|error| format!("Could not save Realtime key: {error}"))??; + app.emit(openai_voice_endpoints::SETTINGS_CHANGED_EVENT, ()) + .map_err(|error| format!("Could not refresh Realtime settings: {error}")) +} + +#[tauri::command] +pub async fn clear_openai_realtime_api_key( + app: AppHandle, + expected_url: String, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + openai_voice_endpoints::with_selected_endpoint( + VoiceEndpointKind::Realtime, + &expected_url, + || openai_voice_credentials::clear(OpenAiVoiceCredential::SelectedRealtimeAssistant), + ) + }) + .await + .map_err(|error| format!("Could not clear Realtime key: {error}"))??; + app.emit(openai_voice_endpoints::SETTINGS_CHANGED_EVENT, ()) + .map_err(|error| format!("Could not refresh Realtime settings: {error}")) +} + #[tauri::command] pub async fn create_openai_realtime_session() -> Result { - let api_key = openai_voice_credentials::require(OpenAiVoiceCredential::Realtime)?; + let api_key = + openai_voice_credentials::require(OpenAiVoiceCredential::DefaultRealtimeDictation)?; let response = realtime_transcription_client_secret_request(&reqwest::Client::new(), &api_key) .send() .await @@ -124,8 +169,11 @@ pub fn start_openai_realtime_spokesperson_runtime( return Err("This window already owns an OpenAI Realtime runtime session".into()); } - let api_key = openai_voice_credentials::require(OpenAiVoiceCredential::Realtime)?; - let config = OpenAiSpokespersonConfig::new(api_key, options, Vec::new()); + let (endpoint, api_key) = openai_voice_credentials::require_endpoint( + OpenAiVoiceCredential::SelectedRealtimeAssistant, + )?; + let mut config = OpenAiSpokespersonConfig::new(api_key, options, Vec::new()); + config.endpoint = endpoint; let semantic_revision = Arc::new(AtomicU64::new(0)); let event_window = webview_window.clone(); let event_session_id = session_id.clone(); diff --git a/src-tauri/src/commands/openai_voice_credentials.rs b/src-tauri/src/commands/openai_voice_credentials.rs index 725cc69e6..270c59b56 100644 --- a/src-tauri/src/commands/openai_voice_credentials.rs +++ b/src-tauri/src/commands/openai_voice_credentials.rs @@ -1,5 +1,9 @@ //! Berd-owned credentials for OpenAI voice services. +use sha2::{Digest, Sha256}; + +use super::openai_voice_endpoints::{self, VoiceEndpointKind}; + const KEYCHAIN_SERVICE: &str = "berd-openai-voice"; const KEYCHAIN_ACCOUNT: &str = "api-key"; @@ -7,31 +11,62 @@ const KEYCHAIN_ACCOUNT: &str = "api-key"; pub(crate) enum OpenAiVoiceCredential { SpeechToText, TextToSpeech, - Realtime, + SelectedRealtimeAssistant, + DefaultRealtimeDictation, } impl OpenAiVoiceCredential { - const fn account(self) -> &'static str { + const fn selected_kind(self) -> Option { match self { - Self::SpeechToText | Self::TextToSpeech | Self::Realtime => KEYCHAIN_ACCOUNT, + Self::SpeechToText => Some(VoiceEndpointKind::Stt), + Self::TextToSpeech => Some(VoiceEndpointKind::Tts), + Self::SelectedRealtimeAssistant => Some(VoiceEndpointKind::Realtime), + Self::DefaultRealtimeDictation => None, } } const fn missing_message(self) -> &'static str { match self { Self::SpeechToText => { - "OpenAI speech-to-text is not configured. Add the shared OpenAI voice API key in Voice settings, then try again." + "OpenAI speech-to-text is not configured. Add an API key for the selected transcription URL in Voice settings, then try again." } Self::TextToSpeech => { - "OpenAI text-to-speech is not configured. Add the shared OpenAI voice API key in Voice settings, then try again." + "OpenAI text-to-speech is not configured. Add an API key for the selected playback URL in Voice settings, then try again." + } + Self::SelectedRealtimeAssistant => { + "OpenAI Realtime voice is not configured. Add an API key for the selected Realtime URL in Voice settings, then try again." } - Self::Realtime => { - "OpenAI Realtime voice is not configured. Add the shared OpenAI voice API key in Voice settings, then try again." + Self::DefaultRealtimeDictation => { + "OpenAI Realtime dictation needs an API key for the default OpenAI endpoint" } } } } +fn account(credential: OpenAiVoiceCredential) -> Result { + let Some(kind) = credential.selected_kind() else { + return Ok(KEYCHAIN_ACCOUNT.to_string()); + }; + let url = openai_voice_endpoints::effective_url(kind)?; + account_for_selected_url(kind, &url) +} + +fn account_for_selected_url(kind: VoiceEndpointKind, url: &str) -> Result { + // Environment routing predates URL-scoped keys and retains its existing shared credential. + // Saved URLs never inherit this account, even while the environment override is present. + if openai_voice_endpoints::uses_legacy_environment_credential(kind)? { + return Ok(KEYCHAIN_ACCOUNT.to_string()); + } + Ok(account_for_url(kind, url)) +} + +fn account_for_url(kind: VoiceEndpointKind, url: &str) -> String { + if url == kind.default_url() { + return KEYCHAIN_ACCOUNT.to_string(); + } + format!("endpoint-{}", hex::encode(Sha256::digest(url.as_bytes()))) +} + fn entry(account: &str) -> Result { keyring::Entry::new(KEYCHAIN_SERVICE, account) .map_err(|error| format!("Could not access Berd's OpenAI voice credentials: {error}")) @@ -59,31 +94,191 @@ fn clear_account(account: &str) -> Result<(), String> { } pub(crate) fn read(credential: OpenAiVoiceCredential) -> Result, String> { - read_account(credential.account()) + read_account(&account(credential)?) +} + +/// Check only Keychain item metadata; status polling must never request secret access. +pub(crate) fn is_present(credential: OpenAiVoiceCredential) -> Result { + let account = account(credential)?; + is_present_account(&account) +} + +fn is_present_account(account: &str) -> Result { + #[cfg(target_os = "macos")] + { + use security_framework::item::{ItemClass, ItemSearchOptions}; + match ItemSearchOptions::new() + .class(ItemClass::generic_password()) + .service(KEYCHAIN_SERVICE) + .account(account) + .load_attributes(true) + .skip_authenticated_items(true) + .search() + { + Ok(items) => Ok(!items.is_empty()), + Err(error) if error.code() == -25300 => Ok(false), + Err(error) => Err(format!("Could not check Berd's voice credential: {error}")), + } + } + #[cfg(not(target_os = "macos"))] + { + Ok(read_account(account)?.is_some()) + } } pub(crate) fn store(credential: OpenAiVoiceCredential, api_key: &str) -> Result<(), String> { - let entry = entry(credential.account())?; + let entry = entry(&account(credential)?)?; entry .set_password(api_key) .map_err(|error| format!("Could not save Berd's OpenAI voice credential: {error}")) } pub(crate) fn clear(credential: OpenAiVoiceCredential) -> Result<(), String> { - clear_account(credential.account()) + clear_account(&account(credential)?) } pub(crate) fn require(credential: OpenAiVoiceCredential) -> Result { read(credential)?.ok_or_else(|| credential.missing_message().to_string()) } +pub(crate) fn require_endpoint( + credential: OpenAiVoiceCredential, +) -> Result<(String, String), String> { + require_endpoint_with(credential, read_account) +} + +fn require_endpoint_with( + credential: OpenAiVoiceCredential, + read: impl FnOnce(&str) -> Result, String>, +) -> Result<(String, String), String> { + let kind = credential + .selected_kind() + .ok_or_else(|| "Dictation uses the fixed default endpoint".to_string())?; + openai_voice_endpoints::resolve_with_url(kind, |url| { + read(&account_for_selected_url(kind, url)?)? + .ok_or_else(|| credential.missing_message().to_string()) + }) +} + #[cfg(test)] mod tests { use super::*; #[test] - fn speech_services_use_the_shared_voice_keychain_account() { - assert_eq!(OpenAiVoiceCredential::SpeechToText.account(), "api-key"); - assert_eq!(OpenAiVoiceCredential::TextToSpeech.account(), "api-key"); - assert_eq!(OpenAiVoiceCredential::Realtime.account(), "api-key"); + fn environment_endpoint_upgrade_preserves_the_legacy_shared_credential() { + const CHILD: &str = "BERD_TEST_ENVIRONMENT_CREDENTIAL_UPGRADE"; + if std::env::var_os(CHILD).is_none() { + let root = tempfile::tempdir().unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "commands::openai_voice_credentials::tests::environment_endpoint_upgrade_preserves_the_legacy_shared_credential", "--nocapture"]) + .env(CHILD, "1") + .env("GOOSE_PATH_ROOT", root.path()) + .env(openai_voice_endpoints::BASE_URL_ENV, "https://legacy.test/openai") + .output().unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + for (credential, destination) in [ + ( + OpenAiVoiceCredential::SpeechToText, + "wss://legacy.test/openai/realtime?intent=transcription", + ), + ( + OpenAiVoiceCredential::TextToSpeech, + "https://legacy.test/openai/audio/speech", + ), + ] { + let (url, key) = require_endpoint_with(credential, |account| { + Ok((account == KEYCHAIN_ACCOUNT).then(|| "legacy-disposable-key".to_string())) + }) + .unwrap(); + assert_eq!(url, destination); + assert_eq!(key, "legacy-disposable-key"); + } + let custom = openai_voice_endpoints::VoiceEndpointSettings { + stt: Some("wss://custom.test/realtime".into()), + tts: Some("https://custom.test/speech".into()), + realtime: Some("wss://custom.test/assistant".into()), + }; + openai_voice_endpoints::restore(&custom).unwrap(); + for credential in [ + OpenAiVoiceCredential::SpeechToText, + OpenAiVoiceCredential::TextToSpeech, + OpenAiVoiceCredential::SelectedRealtimeAssistant, + ] { + assert!( + require_endpoint_with(credential, |account| { + Ok((account == KEYCHAIN_ACCOUNT).then(|| "legacy-disposable-key".to_string())) + }) + .is_err(), + "a saved custom endpoint must not inherit the legacy shared key" + ); + } + } + + #[test] + fn speech_services_use_independent_endpoints() { + assert!(matches!( + OpenAiVoiceCredential::SpeechToText.selected_kind(), + Some(VoiceEndpointKind::Stt) + )); + assert!(matches!( + OpenAiVoiceCredential::TextToSpeech.selected_kind(), + Some(VoiceEndpointKind::Tts) + )); + assert!(matches!( + OpenAiVoiceCredential::SelectedRealtimeAssistant.selected_kind(), + Some(VoiceEndpointKind::Realtime) + )); + assert_eq!( + account(OpenAiVoiceCredential::DefaultRealtimeDictation).unwrap(), + KEYCHAIN_ACCOUNT + ); + } + + #[test] + fn custom_endpoint_keys_never_alias_the_default_or_other_urls() { + assert_eq!( + account_for_url( + VoiceEndpointKind::Realtime, + VoiceEndpointKind::Realtime.default_url() + ), + "api-key" + ); + assert_eq!( + account_for_url(VoiceEndpointKind::Stt, VoiceEndpointKind::Stt.default_url()), + "api-key" + ); + assert_ne!( + account_for_url( + VoiceEndpointKind::Realtime, + "wss://frankie.test/v1/realtime" + ), + "api-key" + ); + assert_ne!( + account_for_url( + VoiceEndpointKind::Realtime, + "wss://frankie.test/v1/realtime" + ), + account_for_url(VoiceEndpointKind::Realtime, "wss://other.test/v1/realtime") + ); + } + + #[test] + fn missing_key_guidance_applies_to_the_selected_endpoint() { + for credential in [ + OpenAiVoiceCredential::SpeechToText, + OpenAiVoiceCredential::TextToSpeech, + OpenAiVoiceCredential::SelectedRealtimeAssistant, + ] { + let message = credential.missing_message(); + assert!(message.contains("selected")); + assert!(!message.contains("shared")); + } } } diff --git a/src-tauri/src/commands/openai_voice_endpoints.rs b/src-tauri/src/commands/openai_voice_endpoints.rs new file mode 100644 index 000000000..dfca2eede --- /dev/null +++ b/src-tauri/src/commands/openai_voice_endpoints.rs @@ -0,0 +1,500 @@ +//! Independent, user-selected OpenAI-compatible voice endpoints. + +use berd_call::endpoint_url::{is_allowed_endpoint_url, EndpointProtocol}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter}; + +use crate::services::atomic_file::write_bytes_atomically; + +pub(crate) const REALTIME_DEFAULT: &str = "wss://api.openai.com/v1/realtime"; +pub(crate) const STT_DEFAULT: &str = "wss://api.openai.com/v1/realtime?intent=transcription"; +pub(crate) const TTS_DEFAULT: &str = "https://api.openai.com/v1/audio/speech"; +pub(crate) const SETTINGS_CHANGED_EVENT: &str = "openai-voice:settings-changed"; +const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; +pub(crate) const BASE_URL_ENV: &str = "BERD_OPENAI_VOICE_BASE_URL"; +static SETTINGS_UPDATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum VoiceEndpointKind { + Realtime, + Stt, + Tts, +} + +impl VoiceEndpointKind { + pub(crate) const fn default_url(self) -> &'static str { + match self { + Self::Realtime => REALTIME_DEFAULT, + Self::Stt => STT_DEFAULT, + Self::Tts => TTS_DEFAULT, + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct VoiceEndpointSettings { + #[serde(default)] + pub realtime: Option, + #[serde(default)] + pub stt: Option, + #[serde(default)] + pub tts: Option, +} + +impl VoiceEndpointSettings { + fn get(&self, kind: VoiceEndpointKind) -> Option<&str> { + match kind { + VoiceEndpointKind::Realtime => self.realtime.as_deref(), + VoiceEndpointKind::Stt => self.stt.as_deref(), + VoiceEndpointKind::Tts => self.tts.as_deref(), + } + } + + fn set(&mut self, kind: VoiceEndpointKind, value: Option) { + *match kind { + VoiceEndpointKind::Realtime => &mut self.realtime, + VoiceEndpointKind::Stt => &mut self.stt, + VoiceEndpointKind::Tts => &mut self.tts, + } = value; + } +} + +fn settings_path() -> Result { + Ok(crate::services::goose_config::config_path()? + .parent() + .ok_or_else(|| "Could not resolve Goose's configuration directory".to_string())? + .join("openai-voice-endpoints.json")) +} + +fn read_settings() -> Result { + let path = settings_path()?; + read_settings_from(&path) +} + +fn read_settings_from(path: &std::path::Path) -> Result { + match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|error| format!("Could not read OpenAI voice endpoints: {error}")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Default::default()), + Err(error) => Err(format!("Could not read OpenAI voice endpoints: {error}")), + } +} + +fn persist(settings: &VoiceEndpointSettings) -> Result<(), String> { + let _guard = SETTINGS_UPDATE_LOCK + .lock() + .map_err(|_| "OpenAI voice settings lock is poisoned".to_string())?; + let path = settings_path()?; + persist_to(&path, settings) +} + +fn persist_to(path: &std::path::Path, settings: &VoiceEndpointSettings) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + format!("Could not create OpenAI voice settings directory: {error}") + })?; + } + let bytes = serde_json::to_vec_pretty(settings) + .map_err(|error| format!("Could not encode OpenAI voice endpoints: {error}"))?; + write_bytes_atomically(path, &bytes) + .map_err(|error| format!("Could not save OpenAI voice endpoints: {error}")) +} + +fn update_settings_file( + path: &std::path::Path, + update: impl FnOnce(&mut VoiceEndpointSettings), +) -> Result<(), String> { + let _guard = SETTINGS_UPDATE_LOCK + .lock() + .map_err(|_| "OpenAI voice settings lock is poisoned".to_string())?; + let mut settings = read_settings_from(path)?; + update(&mut settings); + persist_to(path, &settings) +} + +fn validate(kind: VoiceEndpointKind, raw: &str) -> Result, String> { + let raw = raw.trim(); + if raw.is_empty() { + return Ok(None); + } + let mut url = url::Url::parse(raw).map_err(|error| format!("Invalid endpoint URL: {error}"))?; + let protocol = match kind { + VoiceEndpointKind::Realtime | VoiceEndpointKind::Stt => EndpointProtocol::WebSocket, + VoiceEndpointKind::Tts => EndpointProtocol::Http, + }; + if !is_allowed_endpoint_url(&url, protocol) { + return Err("Endpoint must be a full URL with the correct protocol, HTTPS/WSS outside loopback, and no embedded credentials or fragment".into()); + } + url.set_fragment(None); + Ok(Some(url.to_string())) +} + +fn normalize_base_url(raw_url: String) -> Result { + let mut url = url::Url::parse(&raw_url) + .map_err(|error| format!("OpenAI voice endpoint is invalid: {error}"))?; + if url.scheme() != "https" { + return Err("OpenAI voice endpoint must use HTTPS".to_string()); + } + let path = url.path().trim_end_matches('/').to_string(); + if path.is_empty() { + url.set_path("/v1"); + } else { + url.set_path(&path); + } + url.set_fragment(None); + Ok(url.to_string().trim_end_matches('/').to_string()) +} + +fn base_url() -> Result { + let base = std::env::var(BASE_URL_ENV) + .ok() + .map(|value| value.trim().to_string()); + match base.filter(|value| !value.is_empty()) { + Some(base) => normalize_base_url(base), + None => Ok(DEFAULT_BASE_URL.to_string()), + } +} + +fn endpoint_for_base_url(base_url: &str, path: &str) -> Result { + let mut url = url::Url::parse(base_url) + .map_err(|error| format!("OpenAI voice endpoint is invalid: {error}"))?; + let base_path = url.path().trim_end_matches('/'); + url.set_path(&format!("{base_path}/{}", path.trim_start_matches('/'))); + Ok(url.to_string()) +} + +pub(crate) fn uses_legacy_environment_credential(kind: VoiceEndpointKind) -> Result { + Ok(!matches!(kind, VoiceEndpointKind::Realtime) + && std::env::var_os(BASE_URL_ENV).is_some() + && read_settings()?.get(kind).is_none()) +} + +pub(crate) fn effective_url(kind: VoiceEndpointKind) -> Result { + effective_url_from(&read_settings()?, kind) +} + +fn effective_url_from( + settings: &VoiceEndpointSettings, + kind: VoiceEndpointKind, +) -> Result { + if let Some(saved) = settings.get(kind) { + return Ok(saved.to_string()); + } + if !matches!(kind, VoiceEndpointKind::Realtime) + && std::env::var_os("BERD_OPENAI_VOICE_BASE_URL").is_some() + { + let base = base_url()?; + let path = match kind { + VoiceEndpointKind::Stt => "realtime", + VoiceEndpointKind::Tts => "audio/speech", + VoiceEndpointKind::Realtime => unreachable!(), + }; + let mut url = url::Url::parse(&endpoint_for_base_url(&base, path)?) + .map_err(|error| format!("Invalid OpenAI voice endpoint: {error}"))?; + if matches!(kind, VoiceEndpointKind::Stt) { + url.set_scheme("wss").expect("https can become wss"); + url.query_pairs_mut().append_pair("intent", "transcription"); + } + return Ok(url.to_string()); + } + Ok(kind.default_url().to_string()) +} + +/// Resolve an endpoint and its credential without allowing a concurrent selection change. +pub(crate) fn resolve_with_url( + kind: VoiceEndpointKind, + resolve: impl FnOnce(&str) -> Result, +) -> Result<(String, T), String> { + resolve_with_url_at(&settings_path()?, kind, resolve) +} + +fn resolve_with_url_at( + path: &std::path::Path, + kind: VoiceEndpointKind, + resolve: impl FnOnce(&str) -> Result, +) -> Result<(String, T), String> { + let _guard = SETTINGS_UPDATE_LOCK + .lock() + .map_err(|_| "OpenAI voice settings lock is poisoned".to_string())?; + let destination = effective_url_from(&read_settings_from(path)?, kind)?; + let credential = resolve(&destination)?; + Ok((destination, credential)) +} + +#[tauri::command] +pub(crate) fn get_openai_voice_endpoints() -> Result { + read_settings() +} + +#[tauri::command] +pub(crate) fn set_openai_voice_endpoint( + app: AppHandle, + kind: VoiceEndpointKind, + url: String, +) -> Result<(), String> { + set_endpoint_at(&settings_path()?, kind, &url)?; + app.emit(SETTINGS_CHANGED_EVENT, ()) + .map_err(|error| format!("Could not refresh OpenAI voice settings: {error}")) +} + +fn set_endpoint_at( + path: &std::path::Path, + kind: VoiceEndpointKind, + url: &str, +) -> Result<(), String> { + let selected = validate(kind, url)?; + update_settings_file(path, |settings| { + settings.set(kind, selected); + }) +} + +/// Keep endpoint selection stable while a credential mutation uses it. +pub(crate) fn with_selected_endpoint( + kind: VoiceEndpointKind, + expected_url: &str, + mutate: impl FnOnce() -> Result, +) -> Result { + with_selected_endpoint_at(&settings_path()?, kind, expected_url, mutate) +} + +fn with_selected_endpoint_at( + path: &std::path::Path, + kind: VoiceEndpointKind, + expected_url: &str, + mutate: impl FnOnce() -> Result, +) -> Result { + let expected = validate(kind, expected_url)?; + let _guard = SETTINGS_UPDATE_LOCK + .lock() + .map_err(|_| "OpenAI voice settings lock is poisoned".to_string())?; + let settings = read_settings_from(path)?; + if settings.get(kind) != expected.as_deref() { + return Err("The endpoint changed in another window. Reopen Voice settings before changing its key.".into()); + } + mutate() +} + +pub(crate) fn reset() -> Result<(), String> { + persist(&VoiceEndpointSettings::default()) +} + +pub(crate) fn restore(settings: &VoiceEndpointSettings) -> Result<(), String> { + persist(settings) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + use std::time::Duration; + + #[test] + fn startup_keeps_the_credential_and_destination_paired_during_endpoint_changes() { + for kind in [ + VoiceEndpointKind::Stt, + VoiceEndpointKind::Tts, + VoiceEndpointKind::Realtime, + ] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("endpoints.json"); + let (first, second) = if matches!(kind, VoiceEndpointKind::Tts) { + ("https://first.test/speech", "https://second.test/speech") + } else { + ("wss://first.test/realtime", "wss://second.test/realtime") + }; + update_settings_file(&path, |settings| settings.set(kind, Some(first.into()))).unwrap(); + let update_path = path.clone(); + let (started_tx, started_rx) = mpsc::channel(); + let (finished_tx, finished_rx) = mpsc::channel(); + let mut update = None; + let (destination, credential_url) = resolve_with_url_at(&path, kind, |url| { + update = Some(std::thread::spawn(move || { + started_tx.send(()).unwrap(); + update_settings_file(&update_path, |settings| { + settings.set(kind, Some(second.into())) + }) + .unwrap(); + finished_tx.send(()).unwrap(); + })); + started_rx.recv().unwrap(); + // A concurrent save must not change the destination while its key is being read. + let _ = finished_rx.recv_timeout(Duration::from_millis(100)); + Ok(url.to_string()) + }) + .unwrap(); + update.unwrap().join().unwrap(); + assert_eq!( + destination, credential_url, + "a key must only be sent to its own endpoint" + ); + assert_eq!(destination, first); + assert_eq!(read_settings_from(&path).unwrap().get(kind), Some(second)); + } + } + + #[test] + fn credential_mutations_reject_a_stale_displayed_endpoint() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("endpoints.json"); + let mut settings = VoiceEndpointSettings::default(); + settings.set( + VoiceEndpointKind::Stt, + Some("wss://second.test/realtime".into()), + ); + persist_to(&path, &settings).unwrap(); + let mut mutated = false; + let result = with_selected_endpoint_at( + &path, + VoiceEndpointKind::Stt, + "wss://first.test/realtime", + || { + mutated = true; + Ok(()) + }, + ); + assert!(result.unwrap_err().contains("another window")); + assert!(!mutated); + with_selected_endpoint_at( + &path, + VoiceEndpointKind::Stt, + " wss://second.test/realtime ", + || { + mutated = true; + Ok(()) + }, + ) + .unwrap(); + assert!(mutated); + } + + #[test] + fn explicit_default_url_overrides_legacy_environment_routing() { + const CHILD: &str = "BERD_TEST_EXPLICIT_OPENAI_URL"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "commands::openai_voice_endpoints::tests::explicit_default_url_overrides_legacy_environment_routing", "--nocapture"]) + .env(CHILD, "1") + .env(BASE_URL_ENV, "https://legacy.test/openai") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + return; + } + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("endpoints.json"); + for kind in [VoiceEndpointKind::Stt, VoiceEndpointKind::Tts] { + assert_ne!( + effective_url_from(&read_settings_from(&path).unwrap(), kind).unwrap(), + kind.default_url() + ); + set_endpoint_at(&path, kind, kind.default_url()).unwrap(); + let settings = read_settings_from(&path).unwrap(); + assert_eq!(settings.get(kind), Some(kind.default_url())); + assert_eq!( + effective_url_from(&settings, kind).unwrap(), + kind.default_url() + ); + with_selected_endpoint_at(&path, kind, kind.default_url(), || Ok(())).unwrap(); + } + set_endpoint_at(&path, VoiceEndpointKind::Stt, "").unwrap(); + assert_ne!( + effective_url_from(&read_settings_from(&path).unwrap(), VoiceEndpointKind::Stt) + .unwrap(), + STT_DEFAULT + ); + assert_eq!( + effective_url_from(&read_settings_from(&path).unwrap(), VoiceEndpointKind::Tts) + .unwrap(), + TTS_DEFAULT + ); + } + + #[test] + fn concurrent_endpoint_updates_preserve_both_services() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("endpoints.json"); + let first_path = path.clone(); + let second_path = path.clone(); + let (first_started_tx, first_started_rx) = mpsc::channel(); + let (second_finished_tx, second_finished_rx) = mpsc::channel(); + + let first = std::thread::spawn(move || { + update_settings_file(&first_path, |settings| { + settings.set(VoiceEndpointKind::Stt, Some("wss://stt.example".into())); + first_started_tx.send(()).unwrap(); + // Hold the first update while a concurrent save attempts to run; both must survive. + let _ = second_finished_rx.recv_timeout(Duration::from_millis(300)); + }) + .unwrap(); + }); + first_started_rx.recv().unwrap(); + let second = std::thread::spawn(move || { + update_settings_file(&second_path, |settings| { + settings.set(VoiceEndpointKind::Tts, Some("https://tts.example".into())); + }) + .unwrap(); + let _ = second_finished_tx.send(()); + }); + first.join().unwrap(); + second.join().unwrap(); + + let saved = read_settings_from(&path).unwrap(); + assert_eq!(saved.stt.as_deref(), Some("wss://stt.example")); + assert_eq!(saved.tts.as_deref(), Some("https://tts.example")); + } + + #[test] + fn base_url_environment_override_keeps_the_v1_root_and_custom_path() { + assert_eq!( + normalize_base_url("https://proxy.example".into()).unwrap(), + "https://proxy.example/v1" + ); + assert_eq!( + normalize_base_url("https://proxy.example/v1/".into()).unwrap(), + "https://proxy.example/v1" + ); + assert_eq!( + normalize_base_url("http://proxy.example".into()).unwrap_err(), + "OpenAI voice endpoint must use HTTPS" + ); + let base = normalize_base_url("https://proxy.example/openai?api-version=2026-01-01".into()) + .unwrap(); + assert_eq!( + endpoint_for_base_url(&base, "audio/speech").unwrap(), + "https://proxy.example/openai/audio/speech?api-version=2026-01-01" + ); + } + + #[test] + fn endpoints_are_full_urls_with_independent_openai_defaults() { + assert_eq!(VoiceEndpointKind::Realtime.default_url(), REALTIME_DEFAULT); + assert_eq!(VoiceEndpointKind::Stt.default_url(), STT_DEFAULT); + assert_eq!(VoiceEndpointKind::Tts.default_url(), TTS_DEFAULT); + assert!(validate(VoiceEndpointKind::Stt, "wss://example.test/stt?mode=live").is_ok()); + assert!(validate( + VoiceEndpointKind::Tts, + "https://example.test/audio/speech?api-version=1" + ) + .is_ok()); + assert!(validate(VoiceEndpointKind::Tts, "wss://example.test/audio/speech").is_err()); + assert!(validate(VoiceEndpointKind::Tts, "http://example.test/audio/speech").is_err()); + assert!(validate(VoiceEndpointKind::Stt, "ws://example.test/realtime").is_err()); + assert!(validate( + VoiceEndpointKind::Tts, + "http://localhost:18870/v1/audio/speech" + ) + .is_ok()); + assert!(validate(VoiceEndpointKind::Stt, "ws://[::1]:18870/v1/realtime").is_ok()); + assert!(validate( + VoiceEndpointKind::Realtime, + "wss://key@example.test/realtime" + ) + .is_err()); + } +} diff --git a/src-tauri/src/commands/voice_settings.rs b/src-tauri/src/commands/voice_settings.rs index b58dba7ea..7ae40b3c1 100644 --- a/src-tauri/src/commands/voice_settings.rs +++ b/src-tauri/src/commands/voice_settings.rs @@ -1,6 +1,6 @@ use tauri::{AppHandle, Emitter, State}; -use super::{openai_audio, pocket_voice, siri_voice}; +use super::{openai_audio, openai_voice_endpoints, pocket_voice, siri_voice}; const OPENAI_SETTINGS_CHANGED_EVENT: &str = "openai-voice:settings-changed"; @@ -58,6 +58,24 @@ fn reset_transaction( Ok(()) } +fn reset_openai_with_endpoints( + previous_endpoints: &E, + mut reset_endpoints: impl FnMut() -> Result<(), String>, + mut reset_playback: impl FnMut() -> Result<(), String>, + mut restore_endpoints: impl FnMut(&E) -> Result<(), String>, +) -> Result<(), String> { + reset_endpoints()?; + if let Err(error) = reset_playback() { + let rollback = restore_endpoints(previous_endpoints) + .err() + .map(|cause| format!("endpoints: {cause}")) + .into_iter() + .collect(); + return Err(transaction_error(error, rollback)); + } + Ok(()) +} + #[tauri::command] pub fn reset_all_voice_backend_settings( app: AppHandle, @@ -74,12 +92,20 @@ pub fn reset_all_voice_backend_settings( let previous_pocket = pocket_voice::settings(&pocket_base); let previous_siri = siri_voice::read_settings(&siri_path); + let previous_endpoints = openai_voice_endpoints::get_openai_voice_endpoints()?; reset_transaction( &previous_pocket, &previous_siri, || pocket_voice::write_settings(&pocket_base, &Default::default()), || siri_voice::write_settings(&siri_path, &Default::default()), - || openai_audio::replace_voice_settings(&openai_state, &Default::default()), + || { + reset_openai_with_endpoints( + &previous_endpoints, + openai_voice_endpoints::reset, + || openai_audio::replace_voice_settings(&openai_state, &Default::default()), + openai_voice_endpoints::restore, + ) + }, |settings| pocket_voice::write_settings(&pocket_base, settings), |settings| siri_voice::write_settings(&siri_path, settings), )?; @@ -94,7 +120,7 @@ pub fn reset_all_voice_backend_settings( mod tests { use std::cell::Cell; - use super::reset_transaction; + use super::{reset_openai_with_endpoints, reset_transaction}; #[test] fn restores_completed_resets_when_a_later_backend_fails() { @@ -127,4 +153,23 @@ mod tests { assert_eq!(pocket.get(), 7); assert_eq!(siri.get(), 8); } + + #[test] + fn restores_endpoint_urls_when_playback_reset_fails() { + let endpoints = Cell::new(7); + let result = reset_openai_with_endpoints( + &7, + || { + endpoints.set(0); + Ok(()) + }, + || Err("playback unavailable".to_string()), + |previous| { + endpoints.set(*previous); + Ok(()) + }, + ); + assert!(result.is_err()); + assert_eq!(endpoints.get(), 7); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 79cdcf904..b570db5ab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -605,6 +605,8 @@ pub fn run() { commands::model_setup::clear_model_setup_status, commands::notifications::show_completion_notification, commands::openai_realtime::get_openai_realtime_status, + commands::openai_realtime::set_openai_realtime_api_key, + commands::openai_realtime::clear_openai_realtime_api_key, commands::openai_realtime::create_openai_realtime_session, commands::openai_realtime::start_openai_realtime_spokesperson_runtime, commands::openai_realtime::send_openai_realtime_spokesperson_runtime_event, @@ -679,6 +681,8 @@ pub fn run() { commands::pocket_voice::stop_pocket_voice, commands::pocket_voice::remove_voice_model, commands::openai_audio::get_openai_voice_status, + commands::openai_voice_endpoints::get_openai_voice_endpoints, + commands::openai_voice_endpoints::set_openai_voice_endpoint, commands::openai_audio::set_openai_stt_api_key, commands::openai_audio::clear_openai_stt_api_key, commands::openai_audio::set_openai_tts_api_key, diff --git a/src/features/voice-conversation/api/openAiVoice.ts b/src/features/voice-conversation/api/openAiVoice.ts index 3380b0772..ee5534154 100644 --- a/src/features/voice-conversation/api/openAiVoice.ts +++ b/src/features/voice-conversation/api/openAiVoice.ts @@ -10,6 +10,7 @@ import type { export interface OpenAiVoiceStatus { sttConfigured: boolean; ttsConfigured: boolean; + realtimeConfigured: boolean; sttConfigurationSource: "default" | "environment"; ttsConfigurationSource: "default" | "environment"; sttUnavailableReason: string | null; @@ -34,20 +35,56 @@ export const getOpenAiVoiceStatus = shareInFlight( (): Promise => invoke("get_openai_voice_status"), ); -export function setOpenAiTtsApiKey(apiKey: string): Promise { - return invoke("set_openai_tts_api_key", { apiKey }); +export function setOpenAiTtsApiKey( + apiKey: string, + expectedUrl = "", +): Promise { + return invoke("set_openai_tts_api_key", { apiKey, expectedUrl }); +} + +export type OpenAiVoiceEndpointKind = "realtime" | "stt" | "tts"; + +export interface OpenAiVoiceEndpoints { + realtime: string | null; + stt: string | null; + tts: string | null; } -export function setOpenAiSttApiKey(apiKey: string): Promise { - return invoke("set_openai_stt_api_key", { apiKey }); +export function getOpenAiVoiceEndpoints(): Promise { + return invoke("get_openai_voice_endpoints"); +} + +export function setOpenAiVoiceEndpoint( + kind: OpenAiVoiceEndpointKind, + url: string, +): Promise { + return invoke("set_openai_voice_endpoint", { kind, url }); +} + +export function setOpenAiRealtimeApiKey( + apiKey: string, + expectedUrl = "", +): Promise { + return invoke("set_openai_realtime_api_key", { apiKey, expectedUrl }); +} + +export function clearOpenAiRealtimeApiKey(expectedUrl = ""): Promise { + return invoke("clear_openai_realtime_api_key", { expectedUrl }); +} + +export function setOpenAiSttApiKey( + apiKey: string, + expectedUrl = "", +): Promise { + return invoke("set_openai_stt_api_key", { apiKey, expectedUrl }); } -export function clearOpenAiSttApiKey(): Promise { - return invoke("clear_openai_stt_api_key"); +export function clearOpenAiSttApiKey(expectedUrl = ""): Promise { + return invoke("clear_openai_stt_api_key", { expectedUrl }); } -export function clearOpenAiTtsApiKey(): Promise { - return invoke("clear_openai_tts_api_key"); +export function clearOpenAiTtsApiKey(expectedUrl = ""): Promise { + return invoke("clear_openai_tts_api_key", { expectedUrl }); } export function listenToOpenAiVoiceSettings( diff --git a/src/features/voice-conversation/hooks/useOpenAiVoiceSetup.test.tsx b/src/features/voice-conversation/hooks/useOpenAiVoiceSetup.test.tsx index 7cdd391a5..9ea56817b 100644 --- a/src/features/voice-conversation/hooks/useOpenAiVoiceSetup.test.tsx +++ b/src/features/voice-conversation/hooks/useOpenAiVoiceSetup.test.tsx @@ -37,6 +37,7 @@ function status(configured: boolean): OpenAiVoiceStatus { return { sttConfigured: configured, ttsConfigured: configured, + realtimeConfigured: configured, sttConfigurationSource: "default", ttsConfigurationSource: "default", sttUnavailableReason: null, diff --git a/src/features/voice-conversation/ui/OpenAiApiKeyField.tsx b/src/features/voice-conversation/ui/OpenAiApiKeyField.tsx deleted file mode 100644 index d856892d3..000000000 --- a/src/features/voice-conversation/ui/OpenAiApiKeyField.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useId, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; - -interface OpenAiApiKeyFieldProps { - label: string; - configured: boolean; - onSave: (apiKey: string) => Promise; - onClear: () => Promise; - description?: string; -} - -export function OpenAiApiKeyField({ - label, - configured, - onSave, - onClear, - description, -}: OpenAiApiKeyFieldProps) { - const { t } = useTranslation("settings"); - const inputId = useId(); - const [apiKey, setApiKey] = useState(""); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - const save = async () => { - setSaving(true); - setError(null); - try { - await onSave(apiKey); - setApiKey(""); - } catch (cause) { - setError(cause instanceof Error ? cause.message : String(cause)); - } finally { - setSaving(false); - } - }; - - const clear = async () => { - setSaving(true); - setError(null); - try { - await onClear(); - setApiKey(""); - } catch (cause) { - setError(cause instanceof Error ? cause.message : String(cause)); - } finally { - setSaving(false); - } - }; - - return ( -
- -
- setApiKey(event.target.value)} - placeholder={configured ? t("voice.openAiApiKeySaved") : "sk-…"} - autoComplete="off" - spellCheck={false} - /> - - {configured ? ( - - ) : null} -
-

- {description ?? - (configured - ? t("voice.openAiApiKeyConfigured") - : t("voice.openAiApiKeyNotConfigured"))} -

- {error ? ( -

- {error} -

- ) : null} -
- ); -} diff --git a/src/features/voice-conversation/ui/OpenAiEndpointField.test.tsx b/src/features/voice-conversation/ui/OpenAiEndpointField.test.tsx new file mode 100644 index 000000000..1f496297c --- /dev/null +++ b/src/features/voice-conversation/ui/OpenAiEndpointField.test.tsx @@ -0,0 +1,82 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, expect, it, vi } from "vitest"; +import { renderWithProviders } from "@/test/render"; +import { OpenAiEndpointField } from "./OpenAiEndpointField"; + +const voiceApi = vi.hoisted(() => ({ + getEndpoints: vi.fn(async () => ({ realtime: null, stt: null, tts: null })), + getStatus: vi.fn(async () => ({ sttConfigured: true })), +})); + +vi.mock("../api/openAiVoice", () => ({ + getOpenAiVoiceEndpoints: voiceApi.getEndpoints, + getOpenAiVoiceStatus: voiceApi.getStatus, + setOpenAiVoiceEndpoint: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); + voiceApi.getEndpoints.mockResolvedValue({ + realtime: null, + stt: null, + tts: null, + }); +}); + +it("updates a locally saved key indicator when another default service clears the shared key", async () => { + const user = userEvent.setup(); + const onSaveKey = vi.fn(async () => {}); + const props = { + kind: "stt" as const, + label: "STT URL", + keyLabel: "STT key", + onSaveKey, + onClearKey: vi.fn(async () => {}), + }; + const view = renderWithProviders( + , + ); + await screen.findByText("Key saved for this URL in macOS Keychain."); + await user.type(screen.getByLabelText("STT key"), "new-key"); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(onSaveKey).toHaveBeenCalledWith("new-key", ""); + + view.rerender(); + expect(await screen.findByText(/No key set/)).toBeInTheDocument(); +}); + +it("binds key save and removal to the displayed endpoint", async () => { + voiceApi.getEndpoints.mockResolvedValue({ + realtime: null, + stt: "wss://first.test/realtime", + tts: null, + } as never); + const user = userEvent.setup(); + const onSaveKey = vi.fn(async () => {}); + const onClearKey = vi.fn(async () => {}); + renderWithProviders( + , + ); + await screen.findByDisplayValue("wss://first.test/realtime"); + voiceApi.getEndpoints.mockResolvedValue({ + realtime: null, + stt: "wss://second.test/realtime", + tts: null, + } as never); + await user.type(screen.getByLabelText("STT key"), "first-key"); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(onSaveKey).toHaveBeenCalledWith( + "first-key", + "wss://first.test/realtime", + ); + await user.click(screen.getByRole("button", { name: "Remove" })); + expect(onClearKey).toHaveBeenCalledWith("wss://first.test/realtime"); +}); diff --git a/src/features/voice-conversation/ui/OpenAiEndpointField.tsx b/src/features/voice-conversation/ui/OpenAiEndpointField.tsx new file mode 100644 index 000000000..9970ec312 --- /dev/null +++ b/src/features/voice-conversation/ui/OpenAiEndpointField.tsx @@ -0,0 +1,213 @@ +import { useEffect, useId, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + getOpenAiVoiceEndpoints, + getOpenAiVoiceStatus, + setOpenAiVoiceEndpoint, + type OpenAiVoiceEndpointKind, +} from "../api/openAiVoice"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; + +const DEFAULT_URLS: Record = { + realtime: "wss://api.openai.com/v1/realtime", + stt: "wss://api.openai.com/v1/realtime?intent=transcription", + tts: "https://api.openai.com/v1/audio/speech", +}; + +export function OpenAiEndpointField({ + kind, + label, + keyLabel, + configured, + onSaveKey, + onClearKey, +}: { + kind: OpenAiVoiceEndpointKind; + label: string; + keyLabel: string; + configured: boolean; + onSaveKey: (apiKey: string, expectedUrl: string) => Promise; + onClearKey: (expectedUrl: string) => Promise; +}) { + const { t } = useTranslation("settings"); + const id = useId(); + const keyId = useId(); + const [url, setUrl] = useState(""); + const [savedUrl, setSavedUrl] = useState(""); + const [localKeyStatus, setLocalKeyStatus] = useState<{ + url: string; + configured: boolean; + parentConfiguredAtObservation: boolean; + } | null>(null); + const [apiKey, setApiKey] = useState(""); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + void getOpenAiVoiceEndpoints().then( + (settings) => { + if (active) { + const value = settings[kind] ?? ""; + setUrl(value); + setSavedUrl(value); + setLoaded(true); + } + }, + (cause) => { + if (active) setError(String(cause)); + }, + ); + return () => { + active = false; + }; + }, [kind]); + + const changed = url.trim() !== savedUrl; + // A parent status change supersedes a local save or clear observation. + const observedKeyConfigured = + localKeyStatus?.url === savedUrl && + localKeyStatus.parentConfiguredAtObservation === configured + ? localKeyStatus.configured + : configured; + const keyConfigured = !changed && observedKeyConfigured; + + const save = async () => { + setSaving(true); + setError(null); + let targetUrl = savedUrl; + let urlSaved = false; + const savingKey = Boolean(apiKey.trim()); + try { + // Commit the draft URL first, then bind the key mutation to that same URL. + if (changed) { + await setOpenAiVoiceEndpoint(kind, url); + urlSaved = true; + targetUrl = url.trim(); + setUrl(targetUrl); + setSavedUrl(targetUrl); + setLocalKeyStatus({ + url: targetUrl, + configured: false, + parentConfiguredAtObservation: configured, + }); + } + if (savingKey) { + await onSaveKey(apiKey, targetUrl); + setApiKey(""); + setLocalKeyStatus({ + url: targetUrl, + configured: true, + parentConfiguredAtObservation: configured, + }); + } else if (changed) { + // Metadata-only lookup; never request the Keychain secret to render settings. + const status = await getOpenAiVoiceStatus(); + setLocalKeyStatus({ + url: targetUrl, + configured: status[`${kind}Configured`], + parentConfiguredAtObservation: configured, + }); + } + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + setError( + urlSaved + ? t( + savingKey + ? "voice.endpointSavedKeyError" + : "voice.endpointSavedStatusError", + { error: message }, + ) + : message, + ); + } finally { + setSaving(false); + } + }; + + const clear = async () => { + setSaving(true); + setError(null); + try { + await onClearKey(savedUrl); + setApiKey(""); + setLocalKeyStatus({ + url: savedUrl, + configured: false, + parentConfiguredAtObservation: configured, + }); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setSaving(false); + } + }; + + return ( +
+ + setUrl(event.target.value)} + placeholder={DEFAULT_URLS[kind]} + autoComplete="off" + spellCheck={false} + /> +

+ {t("voice.endpointDefaultHint")} +

+ + setApiKey(event.target.value)} + placeholder={keyConfigured ? "••••••••••••••••••••" : "sk-…"} + autoComplete="off" + spellCheck={false} + /> +
+ + {keyConfigured ? ( + + ) : null} +
+

+ {changed + ? t("voice.endpointUnsavedHint") + : keyConfigured + ? t("voice.openAiApiKeyConfigured") + : t("voice.openAiApiKeyNotConfigured")} +

+ {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/src/features/voice-conversation/ui/RealtimeVoiceSettings.test.tsx b/src/features/voice-conversation/ui/RealtimeVoiceSettings.test.tsx index 1d1569c0f..0a9ab47bf 100644 --- a/src/features/voice-conversation/ui/RealtimeVoiceSettings.test.tsx +++ b/src/features/voice-conversation/ui/RealtimeVoiceSettings.test.tsx @@ -7,22 +7,29 @@ import { RealtimeVoiceSettings } from "./RealtimeVoiceSettings"; const openAiVoiceMocks = vi.hoisted(() => ({ clearApiKey: vi.fn(() => Promise.resolve()), - getStatus: vi.fn(() => Promise.resolve({ sttConfigured: true })), + getStatus: vi.fn(() => Promise.resolve({ realtimeConfigured: true })), + getEndpoints: vi.fn(() => + Promise.resolve({ realtime: null, stt: null, tts: null }), + ), + setEndpoint: vi.fn(() => Promise.resolve()), listenToSettings: vi.fn(() => Promise.resolve(() => undefined)), setApiKey: vi.fn(() => Promise.resolve()), })); vi.mock("../api/openAiVoice", () => ({ - clearOpenAiSttApiKey: openAiVoiceMocks.clearApiKey, + clearOpenAiRealtimeApiKey: openAiVoiceMocks.clearApiKey, + getOpenAiVoiceEndpoints: openAiVoiceMocks.getEndpoints, + setOpenAiVoiceEndpoint: openAiVoiceMocks.setEndpoint, getOpenAiVoiceStatus: openAiVoiceMocks.getStatus, listenToOpenAiVoiceSettings: openAiVoiceMocks.listenToSettings, - setOpenAiSttApiKey: openAiVoiceMocks.setApiKey, + setOpenAiRealtimeApiKey: openAiVoiceMocks.setApiKey, })); describe("RealtimeVoiceSettings", () => { beforeEach(async () => { window.localStorage.clear(); vi.clearAllMocks(); + openAiVoiceMocks.getStatus.mockResolvedValue({ realtimeConfigured: true }); await i18n.changeLanguage("en"); }); @@ -61,14 +68,81 @@ describe("RealtimeVoiceSettings", () => { ).toHaveTextContent("Debug — show agent routing"); }); - it("stores the Realtime key through the shared OpenAI voice credential path", async () => { + it("saves the displayed realtime URL before its URL-scoped key with one action", async () => { const user = userEvent.setup(); renderWithProviders(); + expect(screen.getByLabelText("Realtime endpoint URL")).toHaveAttribute( + "placeholder", + "wss://api.openai.com/v1/realtime", + ); + await user.type( + screen.getByLabelText("Realtime endpoint URL"), + "ws://127.0.0.1:18870/v1/realtime", + ); await user.type(screen.getByLabelText("OpenAI API key"), " sk-shared "); - await user.click(screen.getByRole("button", { name: "Save key" })); + expect(screen.getAllByRole("button", { name: "Save" })).toHaveLength(1); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(openAiVoiceMocks.setEndpoint).toHaveBeenCalledWith( + "realtime", + "ws://127.0.0.1:18870/v1/realtime", + ); + expect(openAiVoiceMocks.setApiKey).toHaveBeenCalledWith( + " sk-shared ", + "ws://127.0.0.1:18870/v1/realtime", + ); + expect( + openAiVoiceMocks.setEndpoint.mock.invocationCallOrder[0], + ).toBeLessThan(openAiVoiceMocks.setApiKey.mock.invocationCallOrder[0]); + }); + + it("does not save a key if the endpoint URL is rejected", async () => { + openAiVoiceMocks.setEndpoint.mockRejectedValueOnce( + new Error("Invalid URL"), + ); + const user = userEvent.setup(); + renderWithProviders(); + await user.type(screen.getByLabelText("Realtime endpoint URL"), "invalid"); + await user.type(screen.getByLabelText("OpenAI API key"), "new-key"); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Invalid URL"); + expect(openAiVoiceMocks.setApiKey).not.toHaveBeenCalled(); + }); + + it("shows masked placeholder text for a saved key without filling the input", async () => { + renderWithProviders(); + expect( + await screen.findByText("Key saved for this URL in macOS Keychain."), + ).toBeInTheDocument(); + const input = screen.getByLabelText("OpenAI API key"); + expect(input).toHaveValue(""); + expect(input).toHaveAttribute("placeholder", "••••••••••••••••••••"); + }); - expect(openAiVoiceMocks.setApiKey).toHaveBeenCalledWith(" sk-shared "); + it("saves a URL without a key but shows that it cannot be used yet", async () => { + openAiVoiceMocks.getStatus.mockResolvedValue({ + realtimeConfigured: false, + }); + const user = userEvent.setup(); + renderWithProviders(); + await user.type( + screen.getByLabelText("Realtime endpoint URL"), + "ws://127.0.0.1:18870/v1/realtime", + ); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(openAiVoiceMocks.setApiKey).not.toHaveBeenCalled(); + expect(screen.getByLabelText("OpenAI API key")).toHaveAttribute( + "placeholder", + "sk-…", + ); + expect( + await screen.findByText( + "No key set. Enter a key for this URL before using it. Default OpenAI endpoints share one key.", + ), + ).toBeInTheDocument(); }); it("reveals the supported advanced session controls", async () => { diff --git a/src/features/voice-conversation/ui/RealtimeVoiceSettings.tsx b/src/features/voice-conversation/ui/RealtimeVoiceSettings.tsx index b7c9a33fc..33e7d317b 100644 --- a/src/features/voice-conversation/ui/RealtimeVoiceSettings.tsx +++ b/src/features/voice-conversation/ui/RealtimeVoiceSettings.tsx @@ -26,9 +26,12 @@ import { type RealtimeTurnDetection, useRealtimeVoicePreference, } from "../lib/realtimeVoicePreference"; -import { clearOpenAiSttApiKey, setOpenAiSttApiKey } from "../api/openAiVoice"; +import { + clearOpenAiRealtimeApiKey, + setOpenAiRealtimeApiKey, +} from "../api/openAiVoice"; import { useOpenAiVoiceSetup } from "../hooks/useOpenAiVoiceSetup"; -import { OpenAiApiKeyField } from "./OpenAiApiKeyField"; +import { OpenAiEndpointField } from "./OpenAiEndpointField"; import { PlaybackSpeedRow } from "./PlaybackSpeedRow"; import { SimpleVoicePickerDialog } from "./SimpleVoicePickerDialog"; import { @@ -125,12 +128,13 @@ export function RealtimeVoiceSettings() { return (
-
diff --git a/src/features/voice-conversation/ui/VoiceSettings.test.tsx b/src/features/voice-conversation/ui/VoiceSettings.test.tsx index f26a185ff..13609db47 100644 --- a/src/features/voice-conversation/ui/VoiceSettings.test.tsx +++ b/src/features/voice-conversation/ui/VoiceSettings.test.tsx @@ -98,6 +98,22 @@ const openAiStatusState = vi.hoisted(() => ({ }, })); const openAiApiMocks = vi.hoisted(() => ({ + getStatus: vi.fn(() => + Promise.resolve({ + sttConfigured: false, + ttsConfigured: false, + realtimeConfigured: false, + }), + ), + getEndpoints: vi.fn(() => + Promise.resolve({ + realtime: null as string | null, + stt: null as string | null, + tts: null as string | null, + }), + ), + setRealtimeApiKey: vi.fn(() => Promise.resolve()), + setEndpoint: vi.fn(() => Promise.resolve()), setSttApiKey: vi.fn(() => Promise.resolve()), clearSttApiKey: vi.fn(() => Promise.resolve()), setTtsApiKey: vi.fn(() => Promise.resolve()), @@ -109,6 +125,11 @@ const openAiApiMocks = vi.hoisted(() => ({ })); vi.mock("../api/openAiVoice", () => ({ + setOpenAiRealtimeApiKey: openAiApiMocks.setRealtimeApiKey, + clearOpenAiRealtimeApiKey: vi.fn(() => Promise.resolve()), + getOpenAiVoiceEndpoints: openAiApiMocks.getEndpoints, + getOpenAiVoiceStatus: openAiApiMocks.getStatus, + setOpenAiVoiceEndpoint: openAiApiMocks.setEndpoint, setOpenAiPlaybackSpeed: vi.fn(() => Promise.resolve()), setOpenAiSpeechVoice: openAiApiMocks.setSpeechVoice, setOpenAiSttApiKey: openAiApiMocks.setSttApiKey, @@ -328,6 +349,11 @@ describe("VoiceSettings", () => { openAiApiMocks.setSttApiKey.mockClear(); openAiApiMocks.clearSttApiKey.mockClear(); openAiApiMocks.setSpeechVoice.mockClear(); + openAiApiMocks.getEndpoints.mockReset().mockResolvedValue({ + realtime: null, + stt: null, + tts: null, + }); openAiApiMocks.resetAll.mockReset().mockResolvedValue(undefined); openAiApiMocks.resetPocket.mockClear(); openAiApiMocks.resetSiri.mockClear(); @@ -416,6 +442,40 @@ describe("VoiceSettings", () => { expect(preferenceMocks.setRealtimePreference).toHaveBeenCalledOnce(); }); + it("refreshes the mounted Realtime endpoint after resetting all voice settings", async () => { + modeState.mode = "openai-realtime"; + openAiApiMocks.getEndpoints.mockResolvedValue({ + realtime: "wss://previous.test/realtime", + stt: null, + tts: null, + }); + openAiApiMocks.resetAll.mockImplementationOnce(async () => { + openAiApiMocks.getEndpoints.mockResolvedValue({ + realtime: null, + stt: null, + tts: null, + }); + }); + renderWithProviders(); + const user = userEvent.setup(); + expect( + await screen.findByDisplayValue("wss://previous.test/realtime"), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Reset to defaults" })); + await user.click( + within( + screen.getByRole("dialog", { name: "Reset all voice settings?" }), + ).getByRole("button", { name: "Reset to defaults" }), + ); + expect(screen.getByLabelText("Realtime endpoint URL")).toHaveValue(""); + await user.type(screen.getByLabelText("OpenAI API key"), "disposable-key"); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(openAiApiMocks.setRealtimeApiKey).toHaveBeenCalledWith( + "disposable-key", + "", + ); + }); + it("waits for Apple capability detection before offering reset", () => { macSpeechSetupState.current = { ...macSpeechSetupState.current, @@ -620,9 +680,7 @@ describe("VoiceSettings", () => { ).toBeInTheDocument(); expect(screen.getByText("Playback speed")).toBeInTheDocument(); expect( - screen.getAllByText( - "Saved securely and shared by OpenAI transcription and voice playback.", - ), + screen.getAllByText("Key saved for this URL in macOS Keychain."), ).toHaveLength(2); }); @@ -636,9 +694,37 @@ describe("VoiceSettings", () => { screen.getByLabelText("OpenAI speech-to-text API key"), "stt-secret", ); - await user.click(screen.getAllByRole("button", { name: "Save key" })[0]); + await user.click(screen.getAllByRole("button", { name: "Save" })[0]); - expect(openAiApiMocks.setSttApiKey).toHaveBeenCalledWith("stt-secret"); + expect(openAiApiMocks.setSttApiKey).toHaveBeenCalledWith("stt-secret", ""); + }); + + it.each([ + ["stt", "Speech-to-text endpoint URL", "OpenAI speech-to-text API key"], + ["tts", "Text-to-speech endpoint URL", "OpenAI text-to-speech API key"], + ] as const)("saves a custom %s URL before its key with one click", async (kind, urlLabel, keyLabel) => { + inputState.backend = kind === "stt" ? "openai" : "parakeet"; + outputState.backend = kind === "tts" ? "openai" : "pocket"; + setupState.current = setup(pocketStatus()); + renderWithProviders(); + const user = userEvent.setup(); + const url = + kind === "stt" + ? "ws://127.0.0.1:18870/v1/realtime?intent=transcription" + : "http://127.0.0.1:18870/v1/audio/speech"; + await user.type(screen.getByLabelText(urlLabel), url); + await user.type(screen.getByLabelText(keyLabel), "local-test"); + expect(screen.getAllByRole("button", { name: "Save" })).toHaveLength(1); + await user.click(screen.getByRole("button", { name: "Save" })); + const saveKey = + kind === "stt" + ? openAiApiMocks.setSttApiKey + : openAiApiMocks.setTtsApiKey; + expect(openAiApiMocks.setEndpoint).toHaveBeenCalledWith(kind, url); + expect(saveKey).toHaveBeenCalledWith("local-test", url); + expect(openAiApiMocks.setEndpoint.mock.invocationCallOrder[0]).toBeLessThan( + saveKey.mock.invocationCallOrder[0], + ); }); it("labels purpose-specific environment overrides", async () => { @@ -683,9 +769,9 @@ describe("VoiceSettings", () => { screen.getByLabelText("OpenAI text-to-speech API key"), "tts-secret", ); - await user.click(screen.getByRole("button", { name: "Save key" })); + await user.click(screen.getByRole("button", { name: "Save" })); - expect(openAiApiMocks.setTtsApiKey).toHaveBeenCalledWith("tts-secret"); + expect(openAiApiMocks.setTtsApiKey).toHaveBeenCalledWith("tts-secret", ""); }); it("uses OpenAI guidance when only the selected OpenAI input is not ready", async () => { @@ -700,7 +786,7 @@ describe("VoiceSettings", () => { expect( await screen.findByText( - "OpenAI transcription is not ready. Add the shared OpenAI voice API key below, then try again.", + "OpenAI transcription is not ready. Add an API key for the selected transcription URL below, then try again.", ), ).toBeInTheDocument(); expect( @@ -708,6 +794,33 @@ describe("VoiceSettings", () => { ).not.toBeInTheDocument(); }); + it("does not call a custom endpoint key shared", async () => { + inputState.backend = "openai"; + outputState.backend = "openai"; + openAiStatusState.current = { + ...openAiStatusState.current, + sttConfigured: false, + ttsConfigured: false, + unavailableReason: "missingApiKey", + }; + openAiApiMocks.getEndpoints.mockResolvedValue({ + realtime: null, + stt: "ws://127.0.0.1:18870/v1/realtime?intent=transcription", + tts: "http://127.0.0.1:18870/v1/audio/speech", + }); + setupState.current = setup(pocketStatus()); + renderWithProviders(); + + expect( + await screen.findByDisplayValue( + "ws://127.0.0.1:18870/v1/realtime?intent=transcription", + ), + ).toBeInTheDocument(); + expect( + screen.queryAllByText(/shared OpenAI|shared by OpenAI|their shared/i), + ).toHaveLength(0); + }); + it("reports missing OpenAI input and Pocket output together", async () => { inputState.backend = "openai"; outputState.backend = "pocket"; @@ -721,7 +834,7 @@ describe("VoiceSettings", () => { expect( await screen.findByText( - "The shared OpenAI voice API key is missing, and Pocket TTS is not installed. Complete both steps below to use Voice Conversation.", + "The API key for the selected transcription URL is missing, and Pocket TTS is not installed. Complete both steps below to use Voice Conversation.", ), ).toBeInTheDocument(); }); @@ -749,7 +862,7 @@ describe("VoiceSettings", () => { expect( await screen.findByText( - "The shared OpenAI voice API key is missing, and no installed Siri voice is selected. Complete both steps below to use Voice Conversation.", + "The API key for the selected transcription URL is missing, and no installed Siri voice is selected. Complete both steps below to use Voice Conversation.", ), ).toBeInTheDocument(); }); @@ -784,7 +897,7 @@ describe("VoiceSettings", () => { expect( await screen.findByText( - "OpenAI voice playback is not ready. Add the shared OpenAI voice API key below, then try again.", + "OpenAI voice playback is not ready. Add an API key for the selected playback URL below, then try again.", ), ).toBeInTheDocument(); expect( diff --git a/src/features/voice-conversation/ui/VoiceSettings.tsx b/src/features/voice-conversation/ui/VoiceSettings.tsx index eea396b17..ea1782d0b 100644 --- a/src/features/voice-conversation/ui/VoiceSettings.tsx +++ b/src/features/voice-conversation/ui/VoiceSettings.tsx @@ -62,7 +62,7 @@ import { SiriVoiceSettings } from "./SiriVoiceSettings"; import { PlaybackSpeedRow } from "./PlaybackSpeedRow"; import { SimpleVoicePickerDialog } from "./SimpleVoicePickerDialog"; import { useOpenAiVoiceSetup } from "../hooks/useOpenAiVoiceSetup"; -import { OpenAiApiKeyField } from "./OpenAiApiKeyField"; +import { OpenAiEndpointField } from "./OpenAiEndpointField"; import { RealtimeVoiceSettings } from "./RealtimeVoiceSettings"; import { getDefaultRealtimeVoicePreference, @@ -158,6 +158,7 @@ export function VoiceSettings() { const [resetDialogOpen, setResetDialogOpen] = useState(false); const [resetting, setResetting] = useState(false); const [resetError, setResetError] = useState(null); + const [endpointResetRevision, setEndpointResetRevision] = useState(0); const input = useVoiceInputPreference( isMacSpeechAvailable(macSpeechSetup.status, macSpeechSetup.loading), ); @@ -238,6 +239,7 @@ export function VoiceSettings() { setResetError(null); try { await resetAllVoiceBackendSettings(); + setEndpointResetRevision((revision) => revision + 1); await setup.refreshSettings(); await siriSetup.refreshSettings(); setRealtimeVoicePreference(getDefaultRealtimeVoicePreference()); @@ -392,11 +394,14 @@ export function VoiceSettings() { details={ input.backend === "openai" ? (
-

{openAiError ?? @@ -483,11 +488,14 @@ export function VoiceSettings() { details={ output.backend === "openai" ? (

-

{openAiError ?? @@ -606,7 +614,7 @@ export function VoiceSettings() {

) : ( - + )}
Microphone.", - "openAiApiKeyConfigured": "Saved securely and shared by OpenAI transcription and voice playback.", - "openAiApiKeyNotConfigured": "This key is shared by OpenAI transcription and voice playback. It is separate from provider credentials used by Goose.", - "openAiApiKeySaved": "API key saved", + "openAiApiKeyConfigured": "Key saved for this URL in macOS Keychain.", + "openAiApiKeyNotConfigured": "No key set. Enter a key for this URL before using it. Default OpenAI endpoints share one key.", "openAiChecking": "Checking OpenAI voice settings…", "openAiEnvironmentOverride": "Development configuration is overridden by the Berd process environment.", "openAiSttApiKey": "OpenAI speech-to-text API key", "openAiSttConfigured": "Uses {{model}}.", - "openAiSttNotConfigured": "Add the shared OpenAI voice API key to use OpenAI transcription.", + "openAiSttNotConfigured": "Add an API key for the selected transcription URL to use it.", "openAiTtsApiKey": "OpenAI text-to-speech API key", "openAiTtsConfigured": "Uses {{model}} and the {{voice}} voice. OpenAI voices are AI-generated.", - "openAiTtsNeedsKey": "Add the shared OpenAI voice API key to use this voice.", + "openAiTtsNeedsKey": "Add an API key for the selected playback URL to use this voice.", "openAiTtsUnsupportedPlatform": "OpenAI voice playback is currently supported on macOS only.", "outputBackendDescription": "Choose a backend.", "playbackSpeed": "Playback speed", @@ -1087,7 +1093,6 @@ "removeModelTitle": "Remove {{model}}?", "removingModel": "Removing model…", "retryDownload": "Retry model download", - "saveApiKey": "Save key", "settingsDescription": "Choose how speech and your coding agent work together.", "siriLanguage": "Language", "siriLoading": "Loading Siri voices…", diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index adbace8e3..96221ddbb 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -948,7 +948,14 @@ "resettingToDefaults": "Restableciendo…", "realtimeAdvanced": "Avanzado", "realtimeApiKey": "Clave API de OpenAI", - "realtimeApiKeyDescription": "Se guarda de forma segura en el llavero de macOS y se comparte entre la escucha y la voz de Realtime.", + "realtimeEndpoint": "URL del servicio en tiempo real", + "openAiSttEndpoint": "URL del servicio de voz a texto", + "openAiTtsEndpoint": "URL del servicio de texto a voz", + "saveEndpointSettings": "Guardar", + "endpointDefaultHint": "Deja este campo vacío para usar la URL de OpenAI indicada arriba. Las URL personalizadas tienen claves separadas; las URL predeterminadas de OpenAI comparten una.", + "endpointUnsavedHint": "Guarda esta URL y la clave juntas. Si dejas la clave vacía, se conserva la clave existente para esta URL; una URL sin clave no se puede usar.", + "endpointSavedKeyError": "Se guardó la URL, pero no la clave: {{error}}", + "endpointSavedStatusError": "Se guardó la URL, pero no se pudo comprobar el estado de la clave: {{error}}", "realtimeCreateResponse": "Responder automáticamente", "realtimeCreateResponseDescription": "Genera una respuesta del asistente de voz cuando termina un turno detectado del usuario.", "realtimeEagerness": "Rapidez para tomar el turno", @@ -1040,19 +1047,19 @@ "modelMissingSize": "No instalado · descarga de {{size}}", "modelNotInstalled": "No instalado", "notReadyInput": "Parakeet STT no está instalado. Descárgalo abajo para usar la conversación por voz.", - "notReadyInputAndOpenAiOutput": "Parakeet STT no está instalado y falta la clave API compartida de voz de OpenAI. Completa ambos pasos abajo para usar la conversación por voz.", + "notReadyInputAndOpenAiOutput": "Parakeet STT no está instalado y falta la clave API para la URL de reproducción seleccionada. Completa ambos pasos abajo para usar la conversación por voz.", "notReadyInputAndPocketOutput": "Parakeet STT y Pocket TTS no están instalados. Descarga ambos abajo para usar la conversación por voz.", "notReadyInputAndSiriOutput": "Parakeet STT no está instalado y no hay ninguna voz de Siri instalada seleccionada. Completa ambos pasos abajo para usar la conversación por voz.", "notReadyMacInput": "El modelo de dictado de Apple no está instalado. Descárgalo abajo para usar la conversación por voz.", - "notReadyMacInputAndOpenAiOutput": "El modelo de dictado de Apple no está instalado y falta la clave API compartida de voz de OpenAI. Completa ambos pasos abajo para usar la conversación por voz.", + "notReadyMacInputAndOpenAiOutput": "El modelo de dictado de Apple no está instalado y falta la clave API para la URL de reproducción seleccionada. Completa ambos pasos abajo para usar la conversación por voz.", "notReadyMacInputAndPocketOutput": "El modelo de dictado de Apple y Pocket TTS no están instalados. Completa ambos pasos abajo para usar la conversación por voz.", "notReadyMacInputAndSiriOutput": "El modelo de dictado de Apple no está instalado y no hay ninguna voz de Siri instalada seleccionada. Completa ambos pasos abajo para usar la conversación por voz.", "notReadyOpenAi": "La voz de OpenAI no está lista. Añade abajo la clave API necesaria e inténtalo de nuevo.", - "notReadyOpenAiStt": "La transcripción de OpenAI no está lista. Añade abajo la clave API compartida de voz de OpenAI e inténtalo de nuevo.", - "notReadyOpenAiSttAndPocketOutput": "Falta la clave API compartida de voz de OpenAI y Pocket TTS no está instalado. Completa ambos pasos abajo para usar la conversación por voz.", - "notReadyOpenAiSttAndSiriOutput": "Falta la clave API compartida de voz de OpenAI y no hay ninguna voz de Siri instalada seleccionada. Completa ambos pasos abajo para usar la conversación por voz.", - "notReadyOpenAiSttAndTts": "La transcripción y la reproducción de voz de OpenAI no están listas. Añade abajo su clave API compartida de voz de OpenAI e inténtalo de nuevo.", - "notReadyOpenAiTts": "La reproducción de voz de OpenAI no está lista. Añade abajo la clave API compartida de voz de OpenAI e inténtalo de nuevo.", + "notReadyOpenAiStt": "La transcripción de OpenAI no está lista. Añade abajo una clave API para la URL de transcripción seleccionada e inténtalo de nuevo.", + "notReadyOpenAiSttAndPocketOutput": "Falta la clave API para la URL de transcripción seleccionada y Pocket TTS no está instalado. Completa ambos pasos abajo para usar la conversación por voz.", + "notReadyOpenAiSttAndSiriOutput": "Falta la clave API para la URL de transcripción seleccionada y no hay ninguna voz de Siri instalada seleccionada. Completa ambos pasos abajo para usar la conversación por voz.", + "notReadyOpenAiSttAndTts": "La transcripción y la reproducción de voz de OpenAI no están listas. Añade abajo las claves API necesarias e inténtalo de nuevo.", + "notReadyOpenAiTts": "La reproducción de voz de OpenAI no está lista. Añade abajo una clave API para la URL de reproducción seleccionada e inténtalo de nuevo.", "notReadyPocketOutput": "Pocket TTS no está instalado. Descárgalo abajo para usar la conversación por voz.", "notReadySiriOutput": "No hay ninguna voz de Siri instalada seleccionada. Descarga o selecciona una abajo para usar la conversación por voz.", "notReadyTitle": "La conversación por voz no está lista", @@ -1061,17 +1068,16 @@ "noVoiceSelected": "No hay ninguna voz seleccionada", "openMicrophoneSettings": "Abrir ajustes del micrófono", "openMicrophoneSettingsError": "No se pudieron abrir los ajustes del micrófono. Abre Ajustes del Sistema y selecciona Privacidad y seguridad > Micrófono.", - "openAiApiKeyConfigured": "Guardada de forma segura y compartida por la transcripción y la reproducción de voz de OpenAI.", - "openAiApiKeyNotConfigured": "Esta clave se comparte entre la transcripción y la reproducción de voz de OpenAI. Es independiente de las credenciales de proveedor que usa Goose.", - "openAiApiKeySaved": "Clave API guardada", + "openAiApiKeyConfigured": "Clave guardada para esta URL en el llavero de macOS.", + "openAiApiKeyNotConfigured": "No hay ninguna clave configurada. Introduce una para esta URL antes de usarla. Los servicios predeterminados de OpenAI comparten una clave.", "openAiChecking": "Comprobando los ajustes de voz de OpenAI…", "openAiEnvironmentOverride": "La configuración de desarrollo está reemplazada por el entorno del proceso de Berd.", "openAiSttApiKey": "Clave API de voz a texto de OpenAI", "openAiSttConfigured": "Usa {{model}}.", - "openAiSttNotConfigured": "Añade la clave API compartida de voz de OpenAI para usar la transcripción de OpenAI.", + "openAiSttNotConfigured": "Añade una clave API para la URL de transcripción seleccionada para usarla.", "openAiTtsApiKey": "Clave API de texto a voz de OpenAI", "openAiTtsConfigured": "Usa {{model}} y la voz {{voice}}. Las voces de OpenAI son generadas por IA.", - "openAiTtsNeedsKey": "Añade la clave API compartida de voz de OpenAI para usar esta voz.", + "openAiTtsNeedsKey": "Añade una clave API para la URL de reproducción seleccionada para usar esta voz.", "openAiTtsUnsupportedPlatform": "La reproducción de voz de OpenAI solo es compatible actualmente con macOS.", "outputBackendDescription": "Elige un backend.", "playbackSpeed": "Velocidad de reproducción", @@ -1086,7 +1092,6 @@ "removeModelTitle": "¿Eliminar {{model}}?", "removingModel": "Eliminando modelo…", "retryDownload": "Reintentar descarga del modelo", - "saveApiKey": "Guardar clave", "settingsDescription": "Elige cómo funcionan juntos la voz y tu agente de programación.", "siriLanguage": "Idioma", "siriLoading": "Cargando voces de Siri…", diff --git a/tests/e2e/fixtures/tauri-mock.ts b/tests/e2e/fixtures/tauri-mock.ts index 997e99844..d29b2cd22 100644 --- a/tests/e2e/fixtures/tauri-mock.ts +++ b/tests/e2e/fixtures/tauri-mock.ts @@ -160,6 +160,10 @@ export function buildInitScript(options?: { const CALLBACKS = new Map(); const EVENT_LISTENERS = new Map(); const ACP_SOCKETS = new Set(); + const VOICE_ENDPOINTS = JSON.parse( + localStorage.getItem("goose:e2e:voice-endpoints") ?? + '{"realtime":null,"stt":null,"tts":null}', + ); const LAYOUT_CONSTRAINTS = { minCenter: -1000000, maxCenter: 1000000, @@ -754,6 +758,16 @@ export function buildInitScript(options?: { ttsAvailable: true, unavailableReason: null, }); + case "get_openai_voice_endpoints": + return Promise.resolve(clone(VOICE_ENDPOINTS)); + case "set_openai_voice_endpoint": + VOICE_ENDPOINTS[args.kind] = args.url.trim() || null; + localStorage.setItem( + "goose:e2e:voice-endpoints", + JSON.stringify(VOICE_ENDPOINTS), + ); + emitTauriEvent("openai-voice:settings-changed", null); + return Promise.resolve(null); case "speak_pocket_voice": POCKET_VOICE_SPOKEN_TEXTS.push(args?.text); return new Promise((resolve) => diff --git a/tests/e2e/voice-endpoints.spec.ts b/tests/e2e/voice-endpoints.spec.ts new file mode 100644 index 000000000..d17d5c881 --- /dev/null +++ b/tests/e2e/voice-endpoints.spec.ts @@ -0,0 +1,109 @@ +import { test as base, expect, type Page } from "@playwright/test"; + +import { buildInitScript } from "./fixtures/tauri-mock"; + +const test = base.extend<{ settings: Page }>({ + settings: async ({ page }, use) => { + await page.addInitScript({ + content: buildInitScript({ enabledExperiments: ["voice-conversation"] }), + }); + await page.addInitScript(() => { + if (localStorage.getItem("goose:voice-conversation-mode") === null) { + localStorage.setItem("goose:voice-conversation-mode", "chained"); + } + if (localStorage.getItem("goose:voice-input-backend") === null) { + localStorage.setItem("goose:voice-input-backend", "openai"); + } + if (localStorage.getItem("goose:voice-output-backend") === null) { + localStorage.setItem("goose:voice-output-backend", "openai"); + } + }); + await use(page); + }, +}); + +test.use({ screenshot: "off", trace: "off", video: "off" }); + +async function saveEndpoint( + page: Page, + kind: "realtime" | "stt" | "tts", + label: string, + url: string, +) { + const input = page.getByLabel(label); + await input.fill(url); + await page + .getByTestId(`openai-${kind}-endpoint-settings`) + .getByRole("button", { name: "Save" }) + .click(); + await expect(input).toHaveValue(url); +} + +test("keeps Realtime, STT, and TTS URLs independent across settings reloads", async ({ + settings: page, +}) => { + await page.goto("/"); + await page.locator("[data-sidebar-nav-id=settings]").click(); + await page.locator("[data-sidebar-nav-id=settings-voice]").click(); + + const stt = page.getByLabel("Speech-to-text endpoint URL"); + const tts = page.getByLabel("Text-to-speech endpoint URL"); + await expect(stt).toHaveAttribute( + "placeholder", + "wss://api.openai.com/v1/realtime?intent=transcription", + ); + await expect(tts).toHaveAttribute( + "placeholder", + "https://api.openai.com/v1/audio/speech", + ); + await saveEndpoint( + page, + "stt", + "Speech-to-text endpoint URL", + "ws://127.0.0.1:18870/v1/realtime?intent=transcription", + ); + await saveEndpoint( + page, + "tts", + "Text-to-speech endpoint URL", + "http://127.0.0.1:18870/v1/audio/speech", + ); + + await page + .getByRole("radio", { name: /Talk through a voice assistant/ }) + .click(); + const realtime = page.getByLabel("Realtime endpoint URL"); + await expect(realtime).toHaveAttribute( + "placeholder", + "wss://api.openai.com/v1/realtime", + ); + await saveEndpoint( + page, + "realtime", + "Realtime endpoint URL", + "ws://127.0.0.1:18870/v1/realtime", + ); + + await page.goto("/"); + await page.locator("[data-sidebar-nav-id=settings]").click(); + await page.locator("[data-sidebar-nav-id=settings-voice]").click(); + await expect(page.getByLabel("Realtime endpoint URL")).toHaveValue( + "ws://127.0.0.1:18870/v1/realtime", + ); + await page.getByRole("radio", { name: /Talk to your coding agent/ }).click(); + await expect(page.getByLabel("Speech-to-text endpoint URL")).toHaveValue( + "ws://127.0.0.1:18870/v1/realtime?intent=transcription", + ); + await expect(page.getByLabel("Text-to-speech endpoint URL")).toHaveValue( + "http://127.0.0.1:18870/v1/audio/speech", + ); + + await saveEndpoint(page, "stt", "Speech-to-text endpoint URL", ""); + await page.goto("/"); + await page.locator("[data-sidebar-nav-id=settings]").click(); + await page.locator("[data-sidebar-nav-id=settings-voice]").click(); + await expect(page.getByLabel("Speech-to-text endpoint URL")).toHaveValue(""); + await expect(page.getByLabel("Text-to-speech endpoint URL")).toHaveValue( + "http://127.0.0.1:18870/v1/audio/speech", + ); +});