fix(asr): 火山渠道「验证」接入真实 WS 连通检查,修复必报「API Key 为空」 (#944)
* fix(asr): 火山渠道「验证」接入真实 WS 连通检查,修复必报「API Key 为空」 validate_asr_provider 此前没有火山分支,火山渠道点「验证」会掉进 OpenAI 兼容 HTTP 兜底——那条路只认 asr.api_key(火山从不写入的槽位),导致无论 哪种鉴权模式、凭据填得多对,连接检查都报「API Key 为空」。 - 新增 validate_volcengine_asr_provider:按鉴权模式取各自槽位凭据 (旧版 volcengine.access_key / 新版控制台 volcengine.api_key), 真连官方 WS 端点 + 1s 静音 + 收尾,与讯飞验证器同构;纯静音会话 服务端不回 final(NoFinalResult)不算失败——握手成功已证明凭据有效 - 凭据缺失按模式返回哨兵串(volcengineAppIdMissing 等),前端映射为 多语言文案,直接指出该补哪个输入框(en/ja/ko/zh-CN/zh-TW 同步) - 握手被拒(401/403)/ 被限流(429)的报错原样透传给验证按钮, 不再折叠成笼统的「操作失败」 - 补 volcengine_missing_credential_error 单测锁定两种模式的判定 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(asr): 统一火山 Resource ID 空白回退 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
9 files changed
+163
-8
No files matched your search
@@ -30,7 +30,7 @@ use super::{AudioConsumer, DictionaryHotword, RawTranscript};
|
||||
const ENDPOINT_APP_ID_TOKEN: &str = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async";
|
||||
const ENDPOINT_API_KEY: &str = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async";
|
||||
/// 200 ms of 16 kHz / 16-bit / mono PCM.
|
||||
const TARGET_AUDIO_CHUNK_BYTES: usize = 6_400;
|
||||
pub(crate) const TARGET_AUDIO_CHUNK_BYTES: usize = 6_400;
|
||||
/// 16 kHz · 16-bit · mono = 32 000 bytes/sec → 32 bytes/ms.
|
||||
const BYTES_PER_MS: f64 = 32.0;
|
||||
const HOTWORD_CAP: usize = 80;
|
||||
@@ -103,6 +103,13 @@ impl VolcengineCredentials {
|
||||
"volc.seedasr.sauc.duration"
|
||||
}
|
||||
|
||||
/// 未配置或仅含空白字符时使用默认 Resource ID;保留非空配置的原始值。
|
||||
pub(crate) fn resolve_resource_id(configured: Option<String>) -> String {
|
||||
configured
|
||||
.filter(|resource_id| !resource_id.trim().is_empty())
|
||||
.unwrap_or_else(|| Self::default_resource_id().to_string())
|
||||
}
|
||||
|
||||
/// 凭据是否满足当前鉴权模式的要求(统一 trim 语义,见 [`VolcengineAuthMode::auth_ok`])。
|
||||
pub fn auth_ok(&self) -> bool {
|
||||
self.auth_mode.auth_ok(&self.app_id, &self.access_token)
|
||||
@@ -945,6 +952,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_id_resolution_defaults_only_missing_or_blank_values() {
|
||||
let default_resource_id = "volc.seedasr.sauc.duration";
|
||||
let cases = [
|
||||
(None, default_resource_id),
|
||||
(Some(""), default_resource_id),
|
||||
(Some(" "), default_resource_id),
|
||||
(Some("\t\r\n"), default_resource_id),
|
||||
(
|
||||
Some("volc.bigasr.sauc.duration"),
|
||||
"volc.bigasr.sauc.duration",
|
||||
),
|
||||
(Some(" custom.resource.id "), " custom.resource.id "),
|
||||
];
|
||||
|
||||
for (configured, expected) in cases {
|
||||
assert_eq!(
|
||||
VolcengineCredentials::resolve_resource_id(configured.map(str::to_string)),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_mode_from_str_roundtrips() {
|
||||
assert_eq!(VolcengineAuthMode::from_str("api_key"), VolcengineAuthMode::ApiKey);
|
||||
|
||||
@@ -420,6 +420,12 @@ async fn validate_asr_provider(scope: &ProviderScope) -> Result<(), String> {
|
||||
if active_asr == crate::asr::xfyun::PROVIDER_ID {
|
||||
return validate_xfyun_asr_provider(scope).await;
|
||||
}
|
||||
// 火山走专属 WS 协议与 volcengine.* 凭据槽位,不能落进下面的 OpenAI 兼容
|
||||
// HTTP 兜底(那条路只认 asr.api_key —— 火山从不写入的槽位,填对也必报
|
||||
// 「API Key 为空」)。
|
||||
if active_asr == "volcengine" {
|
||||
return validate_volcengine_asr_provider(scope).await;
|
||||
}
|
||||
// StepFun 一入口双协议:`*-stream` 模型走实时 WS 验证,其余走批式
|
||||
// /audio/transcriptions(与 build 侧 resolve_effective_asr_provider 同判据)。
|
||||
if active_asr == "stepfun" || active_asr == crate::asr::stepfun_realtime::PROVIDER_ID {
|
||||
@@ -480,6 +486,82 @@ async fn validate_xfyun_asr_provider(scope: &ProviderScope) -> Result<(), String
|
||||
}
|
||||
}
|
||||
|
||||
/// 按鉴权模式检查火山凭据完整性,返回给前端映射多语言文案的哨兵串
|
||||
/// (providerErrorMessage 识别)。与 [`VolcengineAuthMode::auth_ok`] 同一
|
||||
/// trim 语义,但区分缺哪一项,让用户直接知道该补哪个输入框。
|
||||
///
|
||||
/// [`VolcengineAuthMode::auth_ok`]: crate::asr::volcengine::VolcengineAuthMode::auth_ok
|
||||
fn volcengine_missing_credential_error(
|
||||
auth_mode: &crate::asr::volcengine::VolcengineAuthMode,
|
||||
app_id: &str,
|
||||
secret: &str,
|
||||
) -> Option<&'static str> {
|
||||
use crate::asr::volcengine::VolcengineAuthMode;
|
||||
match auth_mode {
|
||||
VolcengineAuthMode::AppIdToken => {
|
||||
if app_id.trim().is_empty() {
|
||||
return Some("volcengineAppIdMissing");
|
||||
}
|
||||
if secret.trim().is_empty() {
|
||||
return Some("volcengineAccessTokenMissing");
|
||||
}
|
||||
}
|
||||
VolcengineAuthMode::ApiKey => {
|
||||
if secret.trim().is_empty() {
|
||||
return Some("volcengineApiKeyMissing");
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 火山 bigmodel 验证:真连 + 1s 静音 + 收尾。密钥槽位随鉴权模式(与
|
||||
/// `read_volc_credentials` 同规则):旧版读 volcengine.access_key,新版控制台
|
||||
/// 读 volcengine.api_key,互不污染。鉴权错误(401/403 → AuthRejected)在
|
||||
/// WebSocket 握手阶段即返回;纯静音会话服务端可能不回 final(等价「没说话」),
|
||||
/// 这类 `NoFinalResult` 不算验证失败 —— 握手成功已经证明凭据有效。
|
||||
async fn validate_volcengine_asr_provider(scope: &ProviderScope) -> Result<(), String> {
|
||||
use crate::asr::volcengine::{VolcengineAuthMode, VolcengineCredentials};
|
||||
let auth_mode = scope
|
||||
.get(CredentialAccount::VolcengineAuthMode)?
|
||||
.map(|s| VolcengineAuthMode::from_str(&s))
|
||||
.unwrap_or(VolcengineAuthMode::AppIdToken);
|
||||
let app_id = scope
|
||||
.get(CredentialAccount::VolcengineAppKey)?
|
||||
.unwrap_or_default();
|
||||
let secret = match auth_mode {
|
||||
VolcengineAuthMode::AppIdToken => scope.get(CredentialAccount::VolcengineAccessKey)?,
|
||||
VolcengineAuthMode::ApiKey => scope.get(CredentialAccount::VolcengineApiKey)?,
|
||||
}
|
||||
.unwrap_or_default();
|
||||
if let Some(message) = volcengine_missing_credential_error(&auth_mode, &app_id, &secret) {
|
||||
return Err(message.to_string());
|
||||
}
|
||||
let resource_id = VolcengineCredentials::resolve_resource_id(
|
||||
scope.get(CredentialAccount::VolcengineResourceId)?,
|
||||
);
|
||||
let asr = std::sync::Arc::new(crate::asr::VolcengineStreamingASR::new(
|
||||
VolcengineCredentials {
|
||||
auth_mode,
|
||||
app_id,
|
||||
access_token: secret,
|
||||
resource_id,
|
||||
},
|
||||
Vec::new(),
|
||||
));
|
||||
asr.open_session().await.map_err(|e| e.to_string())?;
|
||||
crate::asr::AudioConsumer::consume_pcm_chunk(
|
||||
&*asr,
|
||||
&vec![0u8; crate::asr::volcengine::TARGET_AUDIO_CHUNK_BYTES * 5],
|
||||
);
|
||||
asr.send_last_frame().await.map_err(|e| e.to_string())?;
|
||||
match asr.await_final_result().await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(crate::asr::volcengine::VolcengineASRError::NoFinalResult) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// StepFun 实时 WS 验证:真连 + session.update + 500ms 静音 + 收尾。
|
||||
/// 协议无 finish 事件,收尾走静音帧 + 宽限期(纯静音会话以空文本成功返回,
|
||||
/// 见 stepfun_realtime 模块注释),全程 ~2s。
|
||||
@@ -1181,8 +1263,8 @@ mod tests {
|
||||
use super::{
|
||||
asr_error_is_no_speech_rejection, fetch_provider_models, models_url,
|
||||
provider_llm_error_message, provider_log_context, provider_request_error_message,
|
||||
sanitized_provider_destination, send_dashscope_multimodal_validation, ProviderConfig,
|
||||
ProviderScope,
|
||||
sanitized_provider_destination, send_dashscope_multimodal_validation,
|
||||
volcengine_missing_credential_error, ProviderConfig, ProviderScope,
|
||||
};
|
||||
use crate::endpoint_security::validate_http_endpoint;
|
||||
|
||||
@@ -1216,6 +1298,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volcengine_missing_credential_error_follows_auth_mode() {
|
||||
use crate::asr::volcengine::VolcengineAuthMode;
|
||||
// 旧版:先查 APP ID 再查 Access Token;全空格视为未填(trim 语义,
|
||||
// 与 VolcengineAuthMode::auth_ok 一致)。
|
||||
assert_eq!(
|
||||
volcengine_missing_credential_error(&VolcengineAuthMode::AppIdToken, " ", "tok"),
|
||||
Some("volcengineAppIdMissing")
|
||||
);
|
||||
assert_eq!(
|
||||
volcengine_missing_credential_error(&VolcengineAuthMode::AppIdToken, "app", " "),
|
||||
Some("volcengineAccessTokenMissing")
|
||||
);
|
||||
assert_eq!(
|
||||
volcengine_missing_credential_error(&VolcengineAuthMode::AppIdToken, "app", "tok"),
|
||||
None
|
||||
);
|
||||
// 新版控制台:只查 API Key,不要求 APP ID。
|
||||
assert_eq!(
|
||||
volcengine_missing_credential_error(&VolcengineAuthMode::ApiKey, "", " "),
|
||||
Some("volcengineApiKeyMissing")
|
||||
);
|
||||
assert_eq!(
|
||||
volcengine_missing_credential_error(&VolcengineAuthMode::ApiKey, "", "key"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_probe_content_rejection_is_not_a_credential_error() {
|
||||
// StepFun 对静音探针的实测应答(2026-07):鉴权/模型都通过,只是探针
|
||||
|
||||
@@ -3103,11 +3103,11 @@ fn read_volc_credentials() -> VolcengineCredentials {
|
||||
.flatten()
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let resource_id = CredentialsVault::get(CredentialAccount::VolcengineResourceId)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| VolcengineCredentials::default_resource_id().to_string());
|
||||
let resource_id = VolcengineCredentials::resolve_resource_id(
|
||||
CredentialsVault::get(CredentialAccount::VolcengineResourceId)
|
||||
.ok()
|
||||
.flatten(),
|
||||
);
|
||||
VolcengineCredentials {
|
||||
auth_mode,
|
||||
app_id,
|
||||
|
||||
@@ -1059,6 +1059,8 @@ export const en: typeof zhCN = {
|
||||
asrMissingTextField: 'ASR response is missing the text field.',
|
||||
apiKeyMissing: 'API Key is empty.',
|
||||
endpointMissing: 'Endpoint is empty.',
|
||||
volcengineAppIdMissing: 'APP ID is empty.',
|
||||
volcengineAccessTokenMissing: 'Access Token is empty.',
|
||||
requestTimeout: 'Request timed out. Try again later.',
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -1061,6 +1061,8 @@ export const ja: typeof zhCN = {
|
||||
asrMissingTextField: 'ASR の応答に text フィールドがありません。',
|
||||
apiKeyMissing: 'API Key が空です。',
|
||||
endpointMissing: 'Endpoint が空です。',
|
||||
volcengineAppIdMissing: 'APP ID が空です。',
|
||||
volcengineAccessTokenMissing: 'Access Token が空です。',
|
||||
requestTimeout: 'リクエストがタイムアウトしました。後で再試行してください。',
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -1061,6 +1061,8 @@ export const ko: typeof zhCN = {
|
||||
asrMissingTextField: 'ASR 응답에 text 필드가 없습니다.',
|
||||
apiKeyMissing: 'API Key 가 비어 있습니다.',
|
||||
endpointMissing: 'Endpoint 가 비어 있습니다.',
|
||||
volcengineAppIdMissing: 'APP ID 가 비어 있습니다.',
|
||||
volcengineAccessTokenMissing: 'Access Token 이 비어 있습니다.',
|
||||
requestTimeout: '요청 시간이 초과되었습니다. 잠시 후 다시 시도하세요.',
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -1057,6 +1057,8 @@ export const zhCN = {
|
||||
asrMissingTextField: 'ASR 响应缺少 text 字段。',
|
||||
apiKeyMissing: 'API Key 为空。',
|
||||
endpointMissing: 'Endpoint 为空。',
|
||||
volcengineAppIdMissing: 'APP ID 为空。',
|
||||
volcengineAccessTokenMissing: 'Access Token 为空。',
|
||||
requestTimeout: '请求超时,请稍后重试。',
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -1059,6 +1059,8 @@ export const zhTW: typeof zhCN = {
|
||||
asrMissingTextField: 'ASR 響應缺少 text 字段。',
|
||||
apiKeyMissing: 'API Key 爲空。',
|
||||
endpointMissing: 'Endpoint 爲空。',
|
||||
volcengineAppIdMissing: 'APP ID 爲空。',
|
||||
volcengineAccessTokenMissing: 'Access Token 爲空。',
|
||||
requestTimeout: '請求超時,請稍後重試。',
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -865,6 +865,11 @@ function providerErrorMessage(error: unknown, t: ReturnType<typeof useTranslatio
|
||||
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 === 'volcengineAppIdMissing') return t('settings.providers.volcengineAppIdMissing');
|
||||
if (message === 'volcengineAccessTokenMissing') return t('settings.providers.volcengineAccessTokenMissing');
|
||||
if (message === 'volcengineApiKeyMissing') return t('settings.providers.apiKeyMissing');
|
||||
// 火山握手被拒/被限流的报错自带状态码与场景说明,原样透传比笼统的「操作失败」有用。
|
||||
if (message.includes('凭据被拒') || message.includes('被限流')) return message;
|
||||
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