CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix: resolve speech-to-text crashes and post-API-key error storm #4738

Merged⚡ AI-generatedXSccantynz wants to mergeclaude/speech-to-text-crashes-lxc2x4mainopened Jun 15, 2026
4 changed files+111−55
Modifiedsrc-tauri/src/commands/grammar.rs+20−4View fileUnifiedSplit
104104 .filter(|s| !s.is_empty())
105105 .or_else(|| config.voxlen_context.clone());
106106
107 // Prefer Voxlen proxy (no user API key needed) over direct provider calls
107 let has_direct_key = config.api_key.as_ref().filter(|k| !k.is_empty()).is_some();
108
109 // Prefer Voxlen proxy (no user API key needed) over direct provider calls.
110 // Voxlen-first with BYOK fallback: if the proxy call fails and the user has
111 // their own provider key, fall back to it rather than failing the request.
108112 if let Some(voxlen_key) = config.voxlen_api_key.as_ref().filter(|k| !k.is_empty()) {
109113 let mut proxy_config = config.clone();
110114 proxy_config.voxlen_context = effective_context.clone();
111 return correct_with_voxlen_proxy(
115 match correct_with_voxlen_proxy(
112116 &text, voxlen_key,
113117 proxy_config.voxlen_context.as_deref(),
114118 &proxy_config, &vocab
115 ).await;
119 ).await {
120 Ok(result) => return Ok(result),
121 Err(e) => {
122 if has_direct_key {
123 log::warn!(
124 "Voxlen grammar proxy failed ({e}); falling back to direct provider key"
125 );
126 } else {
127 return Err(e);
128 }
129 }
130 }
116131 }
117132
118133 let api_key = config
119134 .api_key
120135 .as_ref()
121 .ok_or("Not connected to a Voxlen account. Open Settings → Account, sign in at voxlen.ai/dashboard, and paste your account key.")?;
136 .filter(|k| !k.is_empty())
137 .ok_or("No grammar AI key configured. Open Settings → Account to connect a Voxlen account, or add your own provider key under Grammar.")?;
122138
123139 let mut effective_config = config.clone();
124140 if let Some(ctx) = effective_context {
Modifiedsrc-tauri/src/commands/settings.rs+8−12View fileUnifiedSplit
250250
251251 let voxlen_key = s.voxlen_api_key.clone().filter(|k| !k.is_empty());
252252
253 // When a Voxlen account key is present, STT is proxied through
254 // voxlen.ai/api — user does not need their own provider keys.
255 let resolved_api_key = if voxlen_key.is_some() {
256 None // voxlen_api_key takes precedence; direct key unused
257 } else {
258 s.stt_api_key.clone().filter(|k| !k.is_empty())
259 };
253 // Keep the user's direct provider key even when a Voxlen account key is
254 // present. The engine layer applies Voxlen-first precedence and uses the
255 // direct key as a fallback; blanking it here silently destroyed a working
256 // BYOK key the moment a (possibly bad) Voxlen key was entered.
257 let resolved_api_key = s.stt_api_key.clone().filter(|k| !k.is_empty());
260258
261259 let stt_config = SttConfig {
262260 engine: stt_engine_type,
286284 "technical" => WritingStyle::Technical,
287285 _ => WritingStyle::Professional,
288286 };
289 let grammar_api_key = if voxlen_key.is_some() {
290 None
291 } else {
292 s.grammar_api_key.clone().filter(|k| !k.is_empty())
293 };
287 // Keep the direct grammar key as a fallback even when a Voxlen key is set
288 // (engine applies Voxlen-first precedence with BYOK fallback).
289 let grammar_api_key = s.grammar_api_key.clone().filter(|k| !k.is_empty());
294290
295291 let grammar_config = GrammarConfig {
296292 enabled: s.grammar_enabled,
Modifiedsrc-tauri/src/stt/cloud.rs+20−4View fileUnifiedSplit
8080 let api_key = config
8181 .api_key
8282 .as_ref()
83 .ok_or_else(|| anyhow::anyhow!("OpenAI API key not configured"))?;
83 .filter(|k| !k.is_empty())
84 .ok_or_else(|| anyhow::anyhow!("OpenAI API key not configured. Add an OpenAI key in Settings, or switch the STT engine to Deepgram / connect a Voxlen account."))?;
8485
8586 let part = reqwest::multipart::Part::bytes(wav_data.to_vec())
8687 .file_name("audio.wav")
146147 wav_data: &[u8],
147148 config: &SttConfig,
148149) -> anyhow::Result<TranscriptionResult> {
149 // Route through Voxlen proxy when account key is present
150 let has_direct_key = config.api_key.as_ref().filter(|k| !k.is_empty()).is_some();
151
152 // Route through the Voxlen proxy when an account key is present (Voxlen
153 // first). On failure, fall back to the user's direct Deepgram key if set.
150154 if let Some(voxlen_key) = config.voxlen_api_key.as_ref().filter(|k| !k.is_empty()) {
151 return voxlen_proxy_transcribe(wav_data, voxlen_key, config).await;
155 match voxlen_proxy_transcribe(wav_data, voxlen_key, config).await {
156 Ok(result) => return Ok(result),
157 Err(e) => {
158 if has_direct_key {
159 log::warn!(
160 "Voxlen transcription proxy failed ({e}); falling back to direct Deepgram key"
161 );
162 } else {
163 return Err(e);
164 }
165 }
166 }
152167 }
153168
154169 let api_key = config
155170 .api_key
156171 .as_ref()
157 .ok_or_else(|| anyhow::anyhow!("Not connected to a Voxlen account. Open Settings → Account, sign in at voxlen.ai/dashboard, and paste your account key."))?;
172 .filter(|k| !k.is_empty())
173 .ok_or_else(|| anyhow::anyhow!("No transcription key configured. Open Settings → Account to connect a Voxlen account, or add your own Deepgram key."))?;
158174
159175 let mut url = String::from("https://api.deepgram.com/v1/listen?model=nova-3&mip_opt_out=true");
160176
Modifiedsrc-tauri/src/stt/streaming.rs+63−35View fileUnifiedSplit
7272 .ok_or_else(|| anyhow::anyhow!("No key in Voxlen deepgram-token response"))
7373}
7474
75/// Acquire a Deepgram key following the product's precedence rule:
76/// the Voxlen account key takes precedence (exchanged for a short-lived temp
77/// key via the proxy), and the user's direct BYOK key is used as a fallback if
78/// the exchange fails or no Voxlen key is configured. Returns `None` (after
79/// emitting a clear `transcription-error`) when no key can be obtained.
80async fn acquire_deepgram_key(
81 direct_key: Option<&str>,
82 voxlen_key: Option<&str>,
83 app_handle: &AppHandle,
84) -> Option<String> {
85 if let Some(vk) = voxlen_key {
86 match fetch_deepgram_temp_key(vk).await {
87 Ok(k) => return Some(k),
88 Err(e) => {
89 if let Some(dk) = direct_key {
90 log::warn!(
91 "Voxlen token exchange failed ({e}); falling back to direct provider key"
92 );
93 return Some(dk.to_string());
94 }
95 let _ = app_handle.emit(
96 "transcription-error",
97 format!("Voxlen account key rejected: {}. Check it in Settings.", e),
98 );
99 return None;
100 }
101 }
102 }
103 if let Some(dk) = direct_key {
104 return Some(dk.to_string());
105 }
106 let _ = app_handle.emit(
107 "transcription-error",
108 "No API key configured — add a Voxlen account key or a provider key in Settings",
109 );
110 None
111}
112
75113/// Start a real-time streaming session with Deepgram using full SttConfig.
76114pub fn start_streaming(
77115 config: SttConfig,
99137 let stop_on_exit = stop_flag.clone();
100138 let handle = tokio::spawn(async move {
101139 let session_task = async {
102 // Fetch the initial Deepgram key
103 let initial_key = match direct_key.as_deref() {
104 Some(k) => k.to_string(),
105 None => match fetch_deepgram_temp_key(voxlen_key.as_deref().unwrap_or("")).await {
106 Ok(k) => k,
107 Err(e) => {
108 let _ = app_handle.emit("transcription-error", format!("Token exchange failed: {}", e));
109 return;
110 }
111 },
112 };
140 // Acquire the initial Deepgram key using the Voxlen-first / direct
141 // BYOK fallback precedence.
142 let initial_key =
143 match acquire_deepgram_key(direct_key.as_deref(), voxlen_key.as_deref(), &app_handle)
144 .await
145 {
146 Some(k) => k,
147 None => return, // error already emitted
148 };
113149
114150 run_streaming_session(
115151 &initial_key,
176212 ).await {
177213 Ok(SessionOutcome::StoppedByUser) => break,
178214 Ok(SessionOutcome::AuthFailed) => {
179 // For Voxlen users the temp key may have expired (30s TTL).
180 // Try to fetch a fresh one before giving up.
181 if let Some(vk) = voxlen_key.filter(|_| direct_key.is_none()) {
182 match fetch_deepgram_temp_key(vk).await {
183 Ok(new_key) => {
184 log::info!("Refreshed Deepgram temp key after auth failure");
215 // For Voxlen users the temp key may simply have expired (30s
216 // TTL) — re-acquire (which also keeps the BYOK fallback) before
217 // giving up. For a direct-only key, auth failure means the key
218 // itself is bad, so fail fast with a clear message.
219 if voxlen_key.is_some() {
220 match acquire_deepgram_key(direct_key, voxlen_key, &app_handle).await {
221 Some(new_key) => {
222 log::info!("Re-acquired Deepgram key after auth failure");
185223 current_key = new_key;
186224 // Fall through to reconnect logic below
187225 }
188 Err(e) => {
189 let _ = app_handle.emit(
190 "transcription-error",
191 format!("Token refresh failed: {}", e),
192 );
193 break;
194 }
226 None => break, // error already emitted
195227 }
196228 } else {
197229 let _ = app_handle.emit(
233265 }
234266 }
235267
236 // Refresh Deepgram temp key for Voxlen users before reconnecting
237 if let Some(vk) = voxlen_key.filter(|_| direct_key.is_none()) {
238 match fetch_deepgram_temp_key(vk).await {
239 Ok(new_key) => { current_key = new_key; }
240 Err(e) => {
241 let _ = app_handle.emit(
242 "transcription-error",
243 format!("Token refresh failed: {}", e),
244 );
245 break;
246 }
268 // Re-acquire the Deepgram key before reconnecting. Voxlen temp
269 // keys are short-lived, so this refreshes them (and re-applies
270 // the BYOK fallback). Direct-only keys need no refresh.
271 if voxlen_key.is_some() {
272 match acquire_deepgram_key(direct_key, voxlen_key, &app_handle).await {
273 Some(new_key) => { current_key = new_key; }
274 None => break, // error already emitted
247275 }
248276 }
249277
250278
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts