From 2438c901ebdf1509f02c8591f102f1ccdb17c3af Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Mon, 4 May 2026 19:15:51 +0800 Subject: [PATCH 1/6] Ensure ASR connection check validates the configured model Issue #242 asks ASR to reach parity with LLM availability checks. Validation now requires a configured ASR model and verifies it exists in the provider model list, instead of only checking endpoint/auth reachability. Constraint: Keep existing settings flow and IPC shape with minimal UI change Rejected: Add a new ASR test-audio transcription request | adds latency and provider-specific request complexity Confidence: high Scope-risk: narrow Directive: Keep validate and fetch-models semantics aligned; validate should fail when configured model cannot actually be selected Tested: npm run build; cargo check -q Not-tested: Manual Settings UI interaction against live third-party ASR endpoints --- openless-all/app/src-tauri/src/commands.rs | 27 +++++++++++++++++----- openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/pages/Settings.tsx | 10 +++++++- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 9aaa6976..673fea99 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -155,12 +155,9 @@ pub async fn validate_provider_credentials(kind: String) -> Result validate_llm_provider() .await .map(|()| ProviderCheckResult { ok: true }), - "asr" => { - let config = read_openai_provider_config(&kind)?; - fetch_provider_models(&config) - .await - .map(|_| ProviderCheckResult { ok: true }) - } + "asr" => validate_asr_provider() + .await + .map(|()| ProviderCheckResult { ok: true }), _ => Err(format!("unknown provider kind: {kind}")), } } @@ -232,6 +229,24 @@ async fn validate_llm_provider() -> Result<(), String> { }) } +async fn validate_asr_provider() -> Result<(), String> { + let config = read_openai_provider_config("asr")?; + let model = CredentialsVault::get(CredentialAccount::AsrModel) + .map_err(|e| e.to_string())? + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| "asrModelMissing".to_string())?; + let models = fetch_provider_models(&config).await?; + if models.is_empty() { + return Err("modelsEmpty".to_string()); + } + let model = model.trim(); + if models.iter().any(|m| m == model) { + Ok(()) + } else { + Err("asrModelUnavailable".to_string()) + } +} + async fn fetch_provider_models(config: &ProviderConfig) -> Result, String> { let url = models_url(&config.base_url); log::info!("[provider-check] GET {url}"); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index dda72c28..1097d6fe 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -322,6 +322,7 @@ export const en: typeof zhCN = { fetchModels: 'Fetch models', loadingModels: 'Fetching models…', modelMissing: 'No model is configured. Please enter a model ID first.', + asrModelUnavailable: 'Current ASR model is not in the provider model list. Please select or enter a valid model.', modelsEmpty: 'Credentials are valid, but no models were returned.', modelsLoaded: 'Fetched {{count}} models.', selectModel: 'Select a model to fill the field above', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index c5bd4220..d55004ef 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -320,6 +320,7 @@ export const zhCN = { fetchModels: '拉取模型', loadingModels: '拉取模型中…', modelMissing: '未配置模型,请先填写模型 ID。', + asrModelUnavailable: '当前 ASR 模型不在供应商返回列表中,请选择或填写有效模型。', modelsEmpty: '鉴权成功,但没有返回可用模型。', modelsLoaded: '已拉取 {{count}} 个模型。', selectModel: '选择一个模型写入上方字段', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 88cf7d0a..618261f8 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -593,10 +593,18 @@ function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' | setResult(result.ok ? 'success' : 'error', t('settings.providers.validateSuccess')); } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (kind === 'llm' && message === 'llmModelMissing') { + if ((kind === 'llm' && message === 'llmModelMissing') || (kind === 'asr' && message === 'asrModelMissing')) { setResult('empty', t('settings.providers.modelMissing')); return; } + if (kind === 'asr' && message === 'asrModelUnavailable') { + setResult('empty', t('settings.providers.asrModelUnavailable')); + return; + } + if (message === 'modelsEmpty') { + setResult('empty', t('settings.providers.modelsEmpty')); + return; + } setResult('error', providerErrorMessage(error, t)); } }; From 2ae3bdf68a00ec6e5a30fe0065fbad6e0498e9da Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Mon, 4 May 2026 19:41:11 +0800 Subject: [PATCH 2/6] Use real transcription call for ASR connection validation ASR validation now runs one end-to-end /audio/transcriptions request with the currently selected model and a tiny bundled silence WAV payload. This verifies endpoint, auth, model routing, and response schema in one pass instead of model-list reachability only. Constraint: Keep UI/API contract unchanged and avoid touching recording pipeline Rejected: Reuse live microphone capture in settings validation | invasive and non-deterministic for quick checks Confidence: high Scope-risk: narrow Directive: Preserve providerHttpStatus:* error marker so frontend can keep status-specific guidance Tested: npm run build; cargo check -q Not-tested: Live validation against every third-party ASR provider implementation --- openless-all/app/src-tauri/src/commands.rs | 80 +++++++++++++++++++--- 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 673fea99..05222f19 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -235,16 +235,80 @@ async fn validate_asr_provider() -> Result<(), String> { .map_err(|e| e.to_string())? .filter(|s| !s.trim().is_empty()) .ok_or_else(|| "asrModelMissing".to_string())?; - let models = fetch_provider_models(&config).await?; - if models.is_empty() { - return Err("modelsEmpty".to_string()); + validate_asr_transcription(&config, model.trim()).await +} + +async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Result<(), String> { + let url = format!("{}/audio/transcriptions", config.base_url.trim_end_matches('/')); + let wav = encode_wav_16k_mono_silence(250); + let wav_part = reqwest::multipart::Part::bytes(wav) + .file_name("openless-asr-check.wav") + .mime_str("audio/wav") + .map_err(|e| format!("请求体构建失败: {e}"))?; + let form = reqwest::multipart::Form::new() + .part("file", wav_part) + .text("model", model.to_string()); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .build() + .map_err(|e| format!("HTTP client 初始化失败: {e}"))?; + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", config.api_key)) + .multipart(form) + .send() + .await + .map_err(|e| { + if e.is_timeout() { + "请求超时".to_string() + } else { + format!("网络错误: {e}") + } + })?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("读取响应失败: {e}"))?; + if !status.is_success() { + return Err(format!("providerHttpStatus:{}", status.as_u16())); } - let model = model.trim(); - if models.iter().any(|m| m == model) { - Ok(()) - } else { - Err("asrModelUnavailable".to_string()) + let json: Value = + serde_json::from_str(&body).map_err(|e| format!("转写响应不是有效 JSON: {e}"))?; + if !json.is_object() || json.get("text").is_none() { + return Err("ASR 转写响应缺少 text 字段".to_string()); } + Ok(()) +} + +fn encode_wav_16k_mono_silence(duration_ms: u32) -> Vec { + let sample_rate: u32 = 16_000; + let num_channels: u16 = 1; + let bits_per_sample: u16 = 16; + let bytes_per_sample = (bits_per_sample / 8) as usize; + let samples = (sample_rate as usize * duration_ms as usize) / 1000; + let pcm_len = samples * bytes_per_sample; + let data_size = pcm_len as u32; + let byte_rate = sample_rate * num_channels as u32 * bits_per_sample as u32 / 8; + let block_align = num_channels * bits_per_sample / 8; + let chunk_size = 36 + data_size; + + let mut wav = Vec::with_capacity(44 + pcm_len); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&chunk_size.to_le_bytes()); + wav.extend_from_slice(b"WAVE"); + wav.extend_from_slice(b"fmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&num_channels.to_le_bytes()); + wav.extend_from_slice(&sample_rate.to_le_bytes()); + wav.extend_from_slice(&byte_rate.to_le_bytes()); + wav.extend_from_slice(&block_align.to_le_bytes()); + wav.extend_from_slice(&bits_per_sample.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&data_size.to_le_bytes()); + wav.resize(44 + pcm_len, 0); + wav } async fn fetch_provider_models(config: &ProviderConfig) -> Result, String> { From 73dbd4cef714a2de8976f7ff44e8a8e6fd57d0d4 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Mon, 4 May 2026 20:16:59 +0800 Subject: [PATCH 3/6] Harden ASR validation path against endpoint and response regressions Follow-up fixes from dual review keep the new end-to-end ASR check while removing compatibility and robustness regressions: normalize transcription endpoint URL, enforce HTTPS except localhost, cap response size, and switch to stable backend error codes mapped in i18n. Constraint: Preserve issue #242 full-chain validation behavior with minimal surface change Rejected: Revert to model-list-only validation | no longer verifies real transcription route Confidence: high Scope-risk: narrow Directive: Keep providerHttpStatus:* and stable error-code contract synchronized with Settings error mapping Tested: npm run build; cargo check -q Not-tested: Manual validation against all third-party ASR endpoints --- openless-all/app/src-tauri/src/commands.rs | 60 +++++++++++++++++----- openless-all/app/src/i18n/en.ts | 6 ++- openless-all/app/src/i18n/zh-CN.ts | 6 ++- openless-all/app/src/pages/Settings.tsx | 12 +++-- 4 files changed, 65 insertions(+), 19 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 05222f19..fd2ce834 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -239,7 +239,8 @@ async fn validate_asr_provider() -> Result<(), String> { } async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Result<(), String> { - let url = format!("{}/audio/transcriptions", config.base_url.trim_end_matches('/')); + const MAX_ASR_VALIDATE_BODY_BYTES: usize = 1024 * 1024; + let url = asr_transcriptions_url(&config.base_url)?; let wav = encode_wav_16k_mono_silence(250); let wav_part = reqwest::multipart::Part::bytes(wav) .file_name("openless-asr-check.wav") @@ -251,7 +252,7 @@ async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Res let client = reqwest::Client::builder() .timeout(Duration::from_secs(20)) .build() - .map_err(|e| format!("HTTP client 初始化失败: {e}"))?; + .map_err(|_| "providerClientInitFailed".to_string())?; let response = client .post(&url) .header("Authorization", format!("Bearer {}", config.api_key)) @@ -260,27 +261,48 @@ async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Res .await .map_err(|e| { if e.is_timeout() { - "请求超时".to_string() + "providerRequestTimeout".to_string() } else { - format!("网络错误: {e}") + "providerNetworkError".to_string() } })?; let status = response.status(); - let body = response - .text() - .await - .map_err(|e| format!("读取响应失败: {e}"))?; if !status.is_success() { return Err(format!("providerHttpStatus:{}", status.as_u16())); } - let json: Value = - serde_json::from_str(&body).map_err(|e| format!("转写响应不是有效 JSON: {e}"))?; + if let Some(len) = response.content_length() { + if len as usize > MAX_ASR_VALIDATE_BODY_BYTES { + return Err("providerResponseTooLarge".to_string()); + } + } + let body = response + .bytes() + .await + .map_err(|_| "providerReadResponseFailed".to_string())?; + if body.len() > MAX_ASR_VALIDATE_BODY_BYTES { + return Err("providerResponseTooLarge".to_string()); + } + let json: Value = serde_json::from_slice(&body).map_err(|_| "asrInvalidJson".to_string())?; if !json.is_object() || json.get("text").is_none() { - return Err("ASR 转写响应缺少 text 字段".to_string()); + return Err("asrMissingTextField".to_string()); } Ok(()) } +fn asr_transcriptions_url(base_url: &str) -> Result { + let trimmed = base_url.trim().trim_end_matches('/'); + let parsed = reqwest::Url::parse(trimmed).map_err(|_| "endpointInvalid".to_string())?; + let host = parsed.host_str().unwrap_or_default(); + let localhost = host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1"; + if parsed.scheme() != "https" && !localhost { + return Err("endpointMustUseHttps".to_string()); + } + if trimmed.ends_with("/audio/transcriptions") { + return Ok(trimmed.to_string()); + } + Ok(format!("{trimmed}/audio/transcriptions")) +} + fn encode_wav_16k_mono_silence(duration_ms: u32) -> Vec { let sample_rate: u32 = 16_000; let num_channels: u16 = 1; @@ -661,8 +683,8 @@ fn _ensure_snapshot_used(_: CredentialsSnapshot) {} #[cfg(test)] mod tests { use super::{ - fetch_provider_models, models_url, parse_model_ids, persist_settings, ProviderConfig, - SettingsWriter, + asr_transcriptions_url, fetch_provider_models, models_url, parse_model_ids, + persist_settings, ProviderConfig, SettingsWriter, }; use crate::types::{ HotkeyBinding, HotkeyMode, HotkeyTrigger, QaHotkeyBinding, UserPreferences, @@ -706,6 +728,18 @@ mod tests { ); } + #[test] + fn asr_transcriptions_url_accepts_base_or_transcriptions_endpoint() { + assert_eq!( + asr_transcriptions_url("https://api.openai.com/v1").unwrap(), + "https://api.openai.com/v1/audio/transcriptions" + ); + assert_eq!( + asr_transcriptions_url("https://api.openai.com/v1/audio/transcriptions").unwrap(), + "https://api.openai.com/v1/audio/transcriptions" + ); + } + #[test] fn parse_model_ids_sorts_and_deduplicates() { let models = diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 1097d6fe..069b6f52 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -322,13 +322,17 @@ export const en: typeof zhCN = { fetchModels: 'Fetch models', loadingModels: 'Fetching models…', modelMissing: 'No model is configured. Please enter a model ID first.', - asrModelUnavailable: 'Current ASR model is not in the provider model list. Please select or enter a valid model.', modelsEmpty: 'Credentials are valid, but no models were returned.', modelsLoaded: 'Fetched {{count}} models.', selectModel: 'Select a model to fill the field above', modelSaved: 'Saved model {{model}}.', validateSuccess: 'Connection check passed.', providerHttpStatus: 'Provider returned HTTP {{status}}. Check the API key permissions or endpoint.', + endpointMustUseHttps: 'Endpoint must use HTTPS (localhost/127.0.0.1 are allowed for local testing).', + endpointInvalid: 'Endpoint format is invalid.', + responseTooLarge: 'Provider response is too large to validate safely.', + asrInvalidJson: 'ASR response is not valid JSON.', + asrMissingTextField: 'ASR response is missing the text field.', apiKeyMissing: 'API Key is empty.', endpointMissing: 'Endpoint is empty.', requestTimeout: 'Request timed out. Try again later.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index d55004ef..d156ef1c 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -320,13 +320,17 @@ export const zhCN = { fetchModels: '拉取模型', loadingModels: '拉取模型中…', modelMissing: '未配置模型,请先填写模型 ID。', - asrModelUnavailable: '当前 ASR 模型不在供应商返回列表中,请选择或填写有效模型。', modelsEmpty: '鉴权成功,但没有返回可用模型。', modelsLoaded: '已拉取 {{count}} 个模型。', selectModel: '选择一个模型写入上方字段', modelSaved: '已保存模型 {{model}}。', validateSuccess: '连接检查通过。', providerHttpStatus: '供应商接口返回 {{status}},请检查 API Key 权限或 Endpoint。', + endpointMustUseHttps: 'Endpoint 必须使用 HTTPS(本地 localhost/127.0.0.1 测试除外)。', + endpointInvalid: 'Endpoint 格式不合法。', + responseTooLarge: '供应商响应过大,已停止验证以保证安全。', + asrInvalidJson: 'ASR 响应不是有效 JSON。', + asrMissingTextField: 'ASR 响应缺少 text 字段。', apiKeyMissing: 'API Key 为空。', endpointMissing: 'Endpoint 为空。', requestTimeout: '请求超时,请稍后重试。', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 618261f8..71c1abfd 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -597,10 +597,6 @@ function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' | setResult('empty', t('settings.providers.modelMissing')); return; } - if (kind === 'asr' && message === 'asrModelUnavailable') { - setResult('empty', t('settings.providers.asrModelUnavailable')); - return; - } if (message === 'modelsEmpty') { setResult('empty', t('settings.providers.modelsEmpty')); return; @@ -672,6 +668,14 @@ function providerErrorMessage(error: unknown, t: ReturnType Date: Mon, 4 May 2026 20:51:40 +0800 Subject: [PATCH 4/6] Accept /audio base URLs when validating ASR transcription endpoint Some OpenAI-compatible deployments expose ASR under a base URL that already ends with /audio. ASR validation now treats that as a valid prefix and appends only /transcriptions to avoid generating /audio/audio/transcriptions. Constraint: Keep ASR validation URL normalization conservative and backwards-compatible Rejected: Full URL path rewrite heuristics | risk of breaking custom reverse-proxy layouts Confidence: high Scope-risk: narrow Tested: cargo test -q --- openless-all/app/src-tauri/src/commands.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index fd2ce834..57b19613 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -300,6 +300,9 @@ fn asr_transcriptions_url(base_url: &str) -> Result { if trimmed.ends_with("/audio/transcriptions") { return Ok(trimmed.to_string()); } + if trimmed.ends_with("/audio") { + return Ok(format!("{trimmed}/transcriptions")); + } Ok(format!("{trimmed}/audio/transcriptions")) } @@ -734,6 +737,10 @@ mod tests { asr_transcriptions_url("https://api.openai.com/v1").unwrap(), "https://api.openai.com/v1/audio/transcriptions" ); + assert_eq!( + asr_transcriptions_url("https://api.openai.com/v1/audio").unwrap(), + "https://api.openai.com/v1/audio/transcriptions" + ); assert_eq!( asr_transcriptions_url("https://api.openai.com/v1/audio/transcriptions").unwrap(), "https://api.openai.com/v1/audio/transcriptions" From df2d51e01477bf0548fbbdd29dbca6b9be421575 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Mon, 4 May 2026 21:16:46 +0800 Subject: [PATCH 5/6] Normalize ASR transcription endpoint and enforce streaming response cap Addresses review feedback: ASR validation now recognizes OpenAI-style /chat/completions endpoints when building the /audio/transcriptions URL, and response-size protection is enforced while streaming the body instead of after buffering it all. Constraint: Keep ASR validation behavior provider-agnostic and minimal in scope Rejected: Heuristic URL rewrites beyond known suffixes | risk of breaking custom reverse proxies Confidence: high Scope-risk: narrow Directive: If changing reqwest features, keep Cargo.lock in sync and re-run tests Tested: cargo test -q --- openless-all/app/src-tauri/Cargo.lock | 17 ++++++++++++++++- openless-all/app/src-tauri/Cargo.toml | 2 +- openless-all/app/src-tauri/src/commands.rs | 22 ++++++++++++++++------ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 9e4ca497..d55399f2 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -4175,12 +4175,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.4.2", "web-sys", "webpki-roots", ] @@ -4220,7 +4222,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -6147,6 +6149,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 5983378e..537ba000 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -24,7 +24,7 @@ serde_json = "1" tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } futures-util = "0.3" -reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream"] } thiserror = "1" anyhow = "1" log = "0.4" diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 57b19613..e5c9c00c 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -275,12 +275,15 @@ async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Res return Err("providerResponseTooLarge".to_string()); } } - let body = response - .bytes() - .await - .map_err(|_| "providerReadResponseFailed".to_string())?; - if body.len() > MAX_ASR_VALIDATE_BODY_BYTES { - return Err("providerResponseTooLarge".to_string()); + use futures_util::StreamExt; + let mut body = Vec::::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "providerReadResponseFailed".to_string())?; + if body.len().saturating_add(chunk.len()) > MAX_ASR_VALIDATE_BODY_BYTES { + return Err("providerResponseTooLarge".to_string()); + } + body.extend_from_slice(&chunk); } let json: Value = serde_json::from_slice(&body).map_err(|_| "asrInvalidJson".to_string())?; if !json.is_object() || json.get("text").is_none() { @@ -303,6 +306,9 @@ fn asr_transcriptions_url(base_url: &str) -> Result { if trimmed.ends_with("/audio") { return Ok(format!("{trimmed}/transcriptions")); } + if let Some(prefix) = trimmed.strip_suffix("/chat/completions") { + return Ok(format!("{prefix}/audio/transcriptions")); + } Ok(format!("{trimmed}/audio/transcriptions")) } @@ -737,6 +743,10 @@ mod tests { asr_transcriptions_url("https://api.openai.com/v1").unwrap(), "https://api.openai.com/v1/audio/transcriptions" ); + assert_eq!( + asr_transcriptions_url("https://api.openai.com/v1/chat/completions").unwrap(), + "https://api.openai.com/v1/audio/transcriptions" + ); assert_eq!( asr_transcriptions_url("https://api.openai.com/v1/audio").unwrap(), "https://api.openai.com/v1/audio/transcriptions" From cb2eb8e9ce7ec48e7e600365a177fd3ec0deb7d8 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Mon, 4 May 2026 21:35:14 +0800 Subject: [PATCH 6/6] Preserve query parameters when building ASR transcription validation URL ASR validation URL normalization now edits the parsed URL path instead of string-appending to the raw endpoint, so endpoints with query parameters (e.g. api-version) remain valid after adding /audio/transcriptions. Constraint: Keep endpoint normalization minimal and avoid changing query/fragment semantics Rejected: Drop query parameters during validation | would break Azure-style OpenAI-compatible deployments Confidence: high Scope-risk: narrow Tested: cargo test -q --- openless-all/app/src-tauri/src/commands.rs | 32 ++++++++++++++-------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index e5c9c00c..3fef6292 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -293,23 +293,27 @@ async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Res } fn asr_transcriptions_url(base_url: &str) -> Result { - let trimmed = base_url.trim().trim_end_matches('/'); - let parsed = reqwest::Url::parse(trimmed).map_err(|_| "endpointInvalid".to_string())?; + let parsed = reqwest::Url::parse(base_url.trim()).map_err(|_| "endpointInvalid".to_string())?; let host = parsed.host_str().unwrap_or_default(); let localhost = host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1"; if parsed.scheme() != "https" && !localhost { return Err("endpointMustUseHttps".to_string()); } - if trimmed.ends_with("/audio/transcriptions") { - return Ok(trimmed.to_string()); - } - if trimmed.ends_with("/audio") { - return Ok(format!("{trimmed}/transcriptions")); - } - if let Some(prefix) = trimmed.strip_suffix("/chat/completions") { - return Ok(format!("{prefix}/audio/transcriptions")); - } - Ok(format!("{trimmed}/audio/transcriptions")) + + // Work on the URL path only so we don't corrupt query parameters. + let mut url = parsed.clone(); + let path = parsed.path().trim_end_matches('/'); + let next_path = if path.ends_with("/audio/transcriptions") { + path.to_string() + } else if path.ends_with("/audio") { + format!("{path}/transcriptions") + } else if let Some(prefix) = path.strip_suffix("/chat/completions") { + format!("{prefix}/audio/transcriptions") + } else { + format!("{path}/audio/transcriptions") + }; + url.set_path(&next_path); + Ok(url.to_string()) } fn encode_wav_16k_mono_silence(duration_ms: u32) -> Vec { @@ -755,6 +759,10 @@ mod tests { asr_transcriptions_url("https://api.openai.com/v1/audio/transcriptions").unwrap(), "https://api.openai.com/v1/audio/transcriptions" ); + assert_eq!( + asr_transcriptions_url("https://api.openai.com/v1?api-version=2024-12-01").unwrap(), + "https://api.openai.com/v1/audio/transcriptions?api-version=2024-12-01" + ); } #[test]