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:
authored and GitHub committed 2026-08-12 12:50:12 +08:00
1 parent c84bfb98f3
commit 40cd1365e2
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,
+2
View File
@@ -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: {
+2
View File
@@ -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: {
+2
View File
@@ -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: {
+2
View File
@@ -1057,6 +1057,8 @@ export const zhCN = {
asrMissingTextField: 'ASR 响应缺少 text 字段。',
apiKeyMissing: 'API Key 为空。',
endpointMissing: 'Endpoint 为空。',
volcengineAppIdMissing: 'APP ID 为空。',
volcengineAccessTokenMissing: 'Access Token 为空。',
requestTimeout: '请求超时,请稍后重试。',
},
shortcuts: {
+2
View File
@@ -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');