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
This commit is contained in:
1 parent
2ae3bdf68a
commit
73dbd4cef7
4 files changed
+65
-19
No files matched your search
@@ -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<String, String> {
|
||||
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<u8> {
|
||||
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 =
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: '请求超时,请稍后重试。',
|
||||
|
||||
@@ -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<typeof useTranslatio
|
||||
if (message.startsWith('providerHttpStatus:')) {
|
||||
return t('settings.providers.providerHttpStatus', { status: message.split(':')[1] || '?' });
|
||||
}
|
||||
if (message === 'endpointMustUseHttps') return t('settings.providers.endpointMustUseHttps');
|
||||
if (message === 'endpointInvalid') return t('settings.providers.endpointInvalid');
|
||||
if (message === 'providerResponseTooLarge') return t('settings.providers.responseTooLarge');
|
||||
if (message === 'asrInvalidJson') return t('settings.providers.asrInvalidJson');
|
||||
if (message === 'asrMissingTextField') return t('settings.providers.asrMissingTextField');
|
||||
if (message === 'providerNetworkError') return t('common.networkError');
|
||||
if (message === 'providerReadResponseFailed' || message === 'providerClientInitFailed') return t('common.operationFailed');
|
||||
if (message === 'providerRequestTimeout') return t('settings.providers.requestTimeout');
|
||||
if (message.includes('API Key')) return t('settings.providers.apiKeyMissing');
|
||||
if (message.includes('Endpoint')) return t('settings.providers.endpointMissing');
|
||||
if (message.includes('timeout') || message.includes('超时')) return t('settings.providers.requestTimeout');
|
||||
|
||||
Reference in new issue
Block a user