Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion openless-all/app/src-tauri/src/asr/volcengine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down
114 changes: 112 additions & 2 deletions openless-all/app/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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。
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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):鉴权/模型都通过,只是探针
Expand Down
10 changes: 5 additions & 5 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,8 @@ export const zhCN = {
asrMissingTextField: 'ASR 响应缺少 text 字段。',
apiKeyMissing: 'API Key 为空。',
endpointMissing: 'Endpoint 为空。',
volcengineAppIdMissing: 'APP ID 为空。',
volcengineAccessTokenMissing: 'Access Token 为空。',
requestTimeout: '请求超时,请稍后重试。',
},
shortcuts: {
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/src/pages/settings/ProvidersSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading