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
550 changes: 550 additions & 0 deletions openless-all/app/src-tauri/src/asr/elevenlabs.rs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions openless-all/app/src-tauri/src/asr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

pub mod bailian;
pub mod dashscope_multimodal;
pub mod elevenlabs;
mod frame;
pub mod local;
pub mod mimo;
Expand All @@ -18,6 +19,7 @@ pub mod whisper;

pub use bailian::{BailianCredentials, BailianRealtimeASR};
pub use dashscope_multimodal::DashScopeMultimodalASR;
pub use elevenlabs::ElevenLabsBatchASR;
pub use mimo::MimoBatchASR;
pub use qwen_realtime::{Qwen3RealtimeASR, Qwen3RealtimeCredentials};
pub use volcengine::{VolcengineCredentials, VolcengineStreamingASR};
Expand Down
36 changes: 32 additions & 4 deletions openless-all/app/src-tauri/src/commands/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,15 +158,35 @@ pub(crate) async fn release_sherpa_runtime_if_inactive(
}

#[tauri::command]
pub fn set_credential(window: Window, account: String, value: String) -> Result<(), String> {
pub fn set_credential(
window: Window,
account: String,
value: String,
provider: Option<String>,
) -> Result<(), String> {
ensure_main_window(&window)?;
if account == LLM_EXTRA_HEADERS_ACCOUNT {
CredentialsVault::set_active_llm_extra_headers_json(&value).map_err(|e| e.to_string())?;
let _ = window.emit("credentials:changed", ());
return Ok(());
}
let acc = parse_account(&account)?;
if value.is_empty() {
if let Some(provider) = provider {
if !matches!(
acc,
CredentialAccount::VolcengineAppKey
| CredentialAccount::VolcengineAccessKey
| CredentialAccount::VolcengineResourceId
| CredentialAccount::AsrApiKey
| CredentialAccount::AsrEndpoint
| CredentialAccount::AsrModel
| CredentialAccount::AsrVocabularyId
) {
return Err("provider-scoped credential must be an ASR account".to_string());
}
CredentialsVault::set_for_asr_provider(&provider, acc, &value)
.map_err(|e| e.to_string())?;
} else if value.is_empty() {
CredentialsVault::remove(acc).map_err(|e| e.to_string())?;
} else {
CredentialsVault::set(acc, &value).map_err(|e| e.to_string())?;
Expand Down Expand Up @@ -246,13 +266,21 @@ pub fn set_active_llm_provider(provider: String) -> Result<(), String> {
/// 读出某个账号的实际值(用于设置页预填表单)。
/// 凭据来自系统凭据库;只允许主设置窗口读取 raw secret,避免胶囊 / QA 等辅助窗口默认暴露。
#[tauri::command]
pub fn read_credential(window: Window, account: String) -> Result<Option<String>, String> {
pub fn read_credential(
window: Window,
account: String,
provider: Option<String>,
) -> Result<Option<String>, String> {
ensure_main_window(&window)?;
if account == LLM_EXTRA_HEADERS_ACCOUNT {
return CredentialsVault::get_active_llm_extra_headers_json().map_err(|e| e.to_string());
}
let acc = parse_account(&account)?;
CredentialsVault::get(acc).map_err(|e| e.to_string())
if let Some(provider) = provider {
CredentialsVault::get_for_asr_provider(&provider, acc).map_err(|e| e.to_string())
} else {
CredentialsVault::get(acc).map_err(|e| e.to_string())
}
}

fn ensure_main_window(window: &Window) -> Result<(), String> {
Expand Down
64 changes: 53 additions & 11 deletions openless-all/app/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ pub async fn list_provider_models(kind: String) -> Result<ProviderModelsResult,
models: vec![crate::asr::dashscope_multimodal::DEFAULT_MODEL.to_string()],
});
}
if kind == "asr" && CredentialsVault::get_active_asr() == crate::asr::elevenlabs::PROVIDER_ID {
validate_elevenlabs_asr_provider().await?;
return Ok(ProviderModelsResult {
models: vec![crate::asr::elevenlabs::DEFAULT_MODEL.to_string()],
});
}
if kind == "llm" && CredentialsVault::get_active_llm() == CODEX_OAUTH_PROVIDER_ID {
return Ok(ProviderModelsResult {
models: vec![
Expand Down Expand Up @@ -142,7 +148,7 @@ fn read_openai_provider_config(kind: &str) -> Result<ProviderConfig, String> {
// 校验,拒绝指向内网/回环/link-local/CGNAT/IPv6 ULA/元数据服务的地址;localhost/
// 127.0.0.1/::1 仍放行 http(本地 Whisper 服务)。覆盖 validate_provider_credentials
// (asr/llm) 连通性测试与 list_provider_models 模型列表两条 HTTP 路径。
crate::coordinator::validate_llm_endpoint(&base_url)
crate::endpoint_security::validate_http_endpoint(&base_url)
.map_err(|_| "endpointInvalid".to_string())?;
Ok(ProviderConfig {
base_url,
Expand Down Expand Up @@ -260,6 +266,9 @@ async fn validate_asr_provider() -> Result<(), String> {
crate::coordinator::validate_dashscope_multimodal_model(&model)?;
return validate_dashscope_multimodal_asr_provider().await;
}
if active_asr == crate::asr::elevenlabs::PROVIDER_ID {
return validate_elevenlabs_asr_provider().await;
}

let config = read_openai_provider_config("asr")?;
let model = CredentialsVault::get(CredentialAccount::AsrModel)
Expand All @@ -286,6 +295,39 @@ async fn validate_mimo_asr_provider() -> Result<(), String> {
.map_err(|e| e.to_string())
}

async fn validate_elevenlabs_asr_provider() -> Result<(), String> {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.map_err(|e| e.to_string())?
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "API Key 为空".to_string())?;
let base_url = CredentialsVault::get(CredentialAccount::AsrEndpoint)
.map_err(|e| e.to_string())?
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| crate::asr::elevenlabs::DEFAULT_ENDPOINT.to_string());
crate::endpoint_security::validate_http_endpoint(&base_url)
.map_err(|_| "endpointInvalid".to_string())?;
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| crate::asr::elevenlabs::DEFAULT_MODEL.to_string());
let asr = crate::asr::ElevenLabsBatchASR::new(api_key, base_url, model);
crate::recorder::AudioConsumer::consume_pcm_chunk(
&asr,
&encode_wav_16k_mono_silence(250)[44..],
);
asr.transcribe().await.map(|_| ()).map_err(|error| {
if error.chain().any(|cause| {
cause
.downcast_ref::<reqwest::Error>()
.is_some_and(reqwest::Error::is_timeout)
}) {
"providerRequestTimeout".to_string()
} else {
error.to_string()
}
})
}

/// fun-asr-flash 官方公开示例音频,用于连通性校验。该模型对纯静音会返回
/// 400("no speech" 类错误),无法像 Whisper/Mimo 那样发静音探活;改用这段
/// 阿里官方文档在案的示例 wav(由 DashScope 侧拉取),key/endpoint/model 有效
Expand Down Expand Up @@ -361,7 +403,7 @@ async fn validate_bailian_asr_provider() -> Result<(), String> {
return Err("API Key 为空".to_string());
}
// 已知残留(issue #609 F-01 孪生 gap):Bailian endpoint 走 `wss://`,与 http/https-only 的
// validate_llm_endpoint 不兼容,无法直接复用,需单独的 ws/wss 感知 SSRF 校验器(超本次范围)。
// validate_http_endpoint 不兼容,无法直接复用,需单独的 ws/wss 感知 SSRF 校验器(超本次范围)。
let stored_endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
Expand Down Expand Up @@ -745,29 +787,29 @@ mod tests {
// API Key 发请求,read_openai_provider_config(连通性测试 + 模型列表 chokepoint)现在复用
// LLM 路径的 SSRF 校验。read_openai_provider_config 依赖凭据库无法纯单测,这里直接对它调用
// 的校验器锁定 ASR 形态 endpoint 的拒绝/放行契约。
use crate::coordinator::validate_llm_endpoint;
use crate::endpoint_security::validate_http_endpoint;

#[test]
fn asr_endpoint_rejects_metadata_cgnat_and_non_https_public() {
// 元数据 / CGNAT / 非 https 外网:拒绝,避免带 API Key 的 ASR 请求被指向高价值目标 / 明文外泄。
assert!(validate_llm_endpoint("http://169.254.169.254/v1/audio/transcriptions").is_err());
assert!(validate_llm_endpoint("http://100.64.0.1/v1/audio/transcriptions").is_err());
assert!(validate_llm_endpoint("http://api.example.com/v1/audio/transcriptions").is_err());
assert!(validate_http_endpoint("http://169.254.169.254/v1/audio/transcriptions").is_err());
assert!(validate_http_endpoint("http://100.64.0.1/v1/audio/transcriptions").is_err());
assert!(validate_http_endpoint("http://api.example.com/v1/audio/transcriptions").is_err());
}

#[test]
fn asr_endpoint_accepts_public_https_localhost_and_lan() {
// 公网 https(如自建 Whisper 网关)放行。
validate_llm_endpoint("https://api.example.com/v1/audio/transcriptions")
validate_http_endpoint("https://api.example.com/v1/audio/transcriptions")
.expect("公网 https ASR endpoint 必须通过");
// 本地 Whisper 服务:localhost / 127.0.0.1 http 放行。
validate_llm_endpoint("http://localhost:9000/v1").expect("本地 Whisper http 必须通过");
validate_llm_endpoint("http://127.0.0.1:9000/v1").expect("本地 Whisper http 必须通过");
validate_http_endpoint("http://localhost:9000/v1").expect("本地 Whisper http 必须通过");
validate_http_endpoint("http://127.0.0.1:9000/v1").expect("本地 Whisper http 必须通过");
// F-01 放宽:局域网(RFC1918)http ASR 网关放行(用户局域网自托管 Whisper)。
validate_llm_endpoint("http://192.168.1.50:9000/v1/audio/transcriptions")
validate_http_endpoint("http://192.168.1.50:9000/v1/audio/transcriptions")
.expect("局域网 http ASR endpoint 必须通过");
// Mimo 官方默认 endpoint(https)放行。
validate_llm_endpoint(crate::asr::mimo::DEFAULT_ENDPOINT)
validate_http_endpoint(crate::asr::mimo::DEFAULT_ENDPOINT)
.expect("Mimo 官方默认 endpoint 必须通过");
}
}
Loading
Loading