feat(asr): support additional Bailian speech models (#866)

* feat(asr): support more Bailian speech models

* test(asr): cover final 8k downsample phase

* fix(asr): address review findings on Bailian model routing (#866)

- route paraformer-8k-realtime / sensevoice-8k-realtime to classic WebSocket,
  matching the 8k downsample naming space in bailian.rs
- downsample 16k->8k with pairwise averaging (first-order low-pass) instead of
  pure decimation to reduce aliasing; drop the lone tail sample on flush
- cap async provider validation at 120s (60s poll) instead of 660s so the
  validate button cannot block for nearly 11 minutes
- join async transcript segments with a space so latin-script words do not
  get glued together

* fix(asr): drop qwen3-asr-flash-filetrans and harden async polling (#866)

- qwen3-asr-flash-filetrans only accepts public URLs per DashScope docs; it
  is incompatible with the local-recording temporary OSS + oss:// path, so
  reject it in protocol_for_model / routing / picker / docs instead of
  shipping a model that would silently fail on real recordings
- simplify async_transcription_body / extract_async_result_url to the
  single fun-asr results shape
- add bounded retry (exponential backoff, deadline-capped) to async task
  polling and result download so one transient network blip does not
  discard an entire recording

* docs(asr): list qwen-audio-3.0-asr-flash as a synchronous model (#866)

Merge with beta #876 brought qwen-audio-3.0-asr-flash into the model
picker and protocol routing; keep the model reference doc in sync and
note that -streaming variants are not supported.

---------

Co-authored-by: LauraGPT <lauragpt@users.noreply.github.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-08-02 18:55:03 +08:00
1 parent 10ff058626
commit 8f49971874
13 files changed
+1182 -121

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
# Alibaba Bailian ASR models
OpenLess uses one Alibaba Bailian provider and selects the DashScope protocol from the model name. Configure the API key, region or workspace endpoint, and model in Settings > Providers.
| Mode | Model examples | Behavior |
| --- | --- | --- |
| Realtime WebSocket | `fun-asr-realtime`, `fun-asr-flash-8k-realtime`, `paraformer-realtime-v2`, `sensevoice-realtime-v1` | Streams text while recording. OpenLess downsamples 16 kHz recorder audio for 8 kHz models. |
| Qwen realtime | `qwen3-asr-flash-realtime`, versioned snapshots | Streams text through the Qwen Realtime WebSocket API. |
| Synchronous recording | `fun-asr-flash-*`, `qwen3-asr-flash`, `qwen-audio-3.0-asr-flash` | Sends the recording after capture and waits for one synchronous response. These models are intended for short recordings (`qwen-audio-*-streaming` variants are not supported). |
| Asynchronous file transcription | `fun-asr`, `fun-asr-mtl`, versioned snapshots, `paraformer-v2` | Uploads the recording, starts an asynchronous task, polls it, then downloads the transcript. |
Future dated snapshots that retain one of these model prefixes are routed to the same protocol, so they can be entered manually before they are added to the model picker.
## Endpoints
The default endpoint is `dashscope.aliyuncs.com`. A workspace or regional host may also be entered, for example `https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com`. OpenLess preserves the host and derives the protocol-specific path and scheme automatically.
API keys are region-specific. The endpoint and API key must belong to the same region.
## Temporary uploads
Asynchronous models require a URL. OpenLess obtains a temporary upload policy from DashScope, uploads the local WAV to the returned OSS host, and submits the resulting `oss://` URL with `X-DashScope-OssResourceResolve: enable`.
Alibaba documents this temporary storage as a development and low-concurrency facility. It is rate limited, and uploaded URLs expire after 48 hours. OpenLess uploads one file per completed recording; it does not retain or reuse the temporary URL.
For deployment or high-concurrency use, use stable Alibaba Cloud OSS storage and the official DashScope API directly.
## References
- [Fun-ASR non-real-time HTTP API](https://help.aliyun.com/en/model-studio/fun-asr-recorded-speech-recognition-http-api)
- [Qwen-ASR API reference](https://help.aliyun.com/en/model-studio/qwen-asr-api-reference)
- [DashScope temporary file upload](https://help.aliyun.com/en/model-studio/get-temporary-file-url)
+137 -7
View File
@@ -29,6 +29,7 @@ pub const DEFAULT_MODEL: &str = "fun-asr-realtime";
/// 100 ms of 16 kHz / 16-bit / mono PCM.
pub const TARGET_AUDIO_CHUNK_BYTES: usize = 3_200;
const TARGET_AUDIO_CHUNK_BYTES_8K: usize = 1_600;
const BYTES_PER_MS: u64 = 32;
const FINAL_RESULT_TIMEOUT: Duration = Duration::from_secs(12);
/// WebSocket 建连(TCP + TLS + HTTP upgrade)本身的上限。没有它 `connect_async` 会无限
@@ -102,6 +103,7 @@ struct SyncState {
task_id: String,
pending_audio: Vec<u8>,
audio_scratch: Vec<u8>,
downsample_remainder: Vec<u8>,
bytes_received: u64,
task_started: bool,
task_finished: bool,
@@ -256,6 +258,9 @@ impl BailianRealtimeASR {
}
let (send_tx, tail_chunks) = {
let mut st = self.state.lock();
if model_is_8k(&self.credentials.normalized_model()) {
clear_downsample_tail(&mut st.downsample_remainder);
}
let send_tx = st.send_tx.clone();
if !st.pending_audio.is_empty() {
let pending = std::mem::take(&mut st.pending_audio);
@@ -298,6 +303,7 @@ impl BailianRealtimeASR {
let mut st = self.state.lock();
st.pending_audio.clear();
st.audio_scratch.clear();
st.downsample_remainder.clear();
st.send_tx.take();
st.final_tx.take();
st.task_finished = true;
@@ -367,7 +373,10 @@ impl BailianRealtimeASR {
st.audio_scratch.extend_from_slice(&pending);
}
let send_tx = st.send_tx.clone();
let chunks = drain_audio_chunks(&mut st.audio_scratch);
let chunks = drain_audio_chunks_for_model(
&mut st.audio_scratch,
&self.credentials.normalized_model(),
);
(send_tx, chunks)
};
if let Some(tx) = send_tx {
@@ -515,15 +524,25 @@ impl AudioConsumer for BailianRealtimeASR {
if pcm.is_empty() {
return;
}
let model = self.credentials.normalized_model();
let (send_tx, chunks) = {
let mut st = self.state.lock();
st.bytes_received = st.bytes_received.saturating_add(pcm.len() as u64);
let audio = if model_is_8k(&model) {
st.downsample_remainder.extend_from_slice(pcm);
let complete_len = st.downsample_remainder.len() / 4 * 4;
let audio = downsample_pcm_16k_to_8k(&st.downsample_remainder[..complete_len]);
st.downsample_remainder.drain(..complete_len);
audio
} else {
pcm.to_vec()
};
if !st.task_started {
st.pending_audio.extend_from_slice(pcm);
st.pending_audio.extend_from_slice(&audio);
return;
}
st.audio_scratch.extend_from_slice(pcm);
let chunks = drain_audio_chunks(&mut st.audio_scratch);
st.audio_scratch.extend_from_slice(&audio);
let chunks = drain_audio_chunks_for_model(&mut st.audio_scratch, &model);
(st.send_tx.clone(), chunks)
};
if let Some(tx) = send_tx {
@@ -535,13 +554,50 @@ impl AudioConsumer for BailianRealtimeASR {
}
fn drain_audio_chunks(buffer: &mut Vec<u8>) -> Vec<Vec<u8>> {
drain_audio_chunks_with_target(buffer, TARGET_AUDIO_CHUNK_BYTES)
}
fn drain_audio_chunks_for_model(buffer: &mut Vec<u8>, model: &str) -> Vec<Vec<u8>> {
let target = if model_is_8k(model) {
TARGET_AUDIO_CHUNK_BYTES_8K
} else {
TARGET_AUDIO_CHUNK_BYTES
};
drain_audio_chunks_with_target(buffer, target)
}
fn drain_audio_chunks_with_target(buffer: &mut Vec<u8>, target: usize) -> Vec<Vec<u8>> {
let mut chunks = Vec::new();
while buffer.len() >= TARGET_AUDIO_CHUNK_BYTES {
chunks.push(buffer.drain(..TARGET_AUDIO_CHUNK_BYTES).collect());
while buffer.len() >= target {
chunks.push(buffer.drain(..target).collect());
}
chunks
}
fn model_is_8k(model: &str) -> bool {
model.contains("-8k-") || model.starts_with("paraformer-8k-")
}
fn downsample_pcm_16k_to_8k(pcm: &[u8]) -> Vec<u8> {
// 16 kHz → 8 kHz:相邻两个样本取平均(一阶低通)。相比纯抽取(每隔一个
// 直接丢弃),平均能压低 4–8 kHz 频段折叠进 0–4 kHz 的混叠,识别更稳。
// 用 i32 求和避免 i16 溢出;输出样本数减半。
let mut out = Vec::with_capacity(pcm.len() / 2);
for pair in pcm.chunks_exact(4) {
let left = i16::from_le_bytes([pair[0], pair[1]]) as i32;
let right = i16::from_le_bytes([pair[2], pair[3]]) as i32;
let sample = ((left + right) / 2) as i16;
out.extend_from_slice(&sample.to_le_bytes());
}
out
}
fn clear_downsample_tail(remainder: &mut Vec<u8>) {
// 平均降采样需要成对样本:收尾时不足一对的残余(≤1 个 16k 样本 ≈ 0.0625ms)
// 无法配对取平均,直接丢弃,不破坏后续分块对齐。
remainder.clear();
}
/// 带重叠检测的文本段拼接:如果后一段的开头与前一段的末尾存在重叠,
/// 只追加不重叠的尾部,避免因 API 重放或重复事件导致的累积文本重复。
///
@@ -572,7 +628,7 @@ fn merge_segments(segments: &[String]) -> String {
fn run_task_message(task_id: &str, model: &str, vocabulary_id: Option<&str>) -> String {
let mut parameters = json!({
"sample_rate": 16000,
"sample_rate": if model_is_8k(model) { 8000 } else { 16000 },
"format": "pcm"
});
if let Some(vocabulary_id) = vocabulary_id.map(str::trim).filter(|id| !id.is_empty()) {
@@ -941,6 +997,80 @@ mod tests {
assert!(value["payload"]["parameters"]["vocabulary_id"].is_null());
}
#[test]
fn run_task_message_uses_pcm_8k_for_8k_model() {
let value: Value =
serde_json::from_str(&run_task_message("abc", "fun-asr-flash-8k-realtime", None))
.unwrap();
assert_eq!(value["payload"]["parameters"]["sample_rate"], 8000);
}
#[test]
fn downsample_16k_pcm_to_8k_averages_each_sample_pair() {
let pcm = [
2_i16.to_le_bytes(),
4_i16.to_le_bytes(),
6_i16.to_le_bytes(),
8_i16.to_le_bytes(),
]
.concat();
let downsampled = downsample_pcm_16k_to_8k(&pcm);
let samples = downsampled
.chunks_exact(2)
.map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]))
.collect::<Vec<_>>();
assert_eq!(samples, vec![3, 7]);
}
#[test]
fn downsample_flush_drops_lone_tail_sample() {
// 3 个 16k 样本:完整一对 (1,2) 取平均;残余的第 3 个样本无法配对,
// 收尾时直接丢弃(最多损失 0.0625ms,无感知)。
let mut remainder = [
1_i16.to_le_bytes(),
2_i16.to_le_bytes(),
3_i16.to_le_bytes(),
]
.concat();
let complete_len = remainder.len() / 4 * 4;
let mut downsampled = downsample_pcm_16k_to_8k(&remainder[..complete_len]);
remainder.drain(..complete_len);
clear_downsample_tail(&mut remainder);
let samples = downsampled
.chunks_exact(2)
.map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]))
.collect::<Vec<_>>();
assert_eq!(samples, vec![(1 + 2) / 2]);
assert!(remainder.is_empty());
}
#[test]
fn downsample_keeps_phase_across_recorder_chunks() {
let asr = BailianRealtimeASR::new(BailianCredentials {
api_key: "sk-test".to_string(),
endpoint: String::new(),
model: "fun-asr-flash-8k-realtime".to_string(),
vocabulary_id: None,
});
let first = [
1_i16.to_le_bytes(),
2_i16.to_le_bytes(),
3_i16.to_le_bytes(),
]
.concat();
asr.consume_pcm_chunk(&first);
asr.consume_pcm_chunk(&4_i16.to_le_bytes());
let state = asr.state.lock();
let samples = state
.pending_audio
.chunks_exact(2)
.map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]))
.collect::<Vec<_>>();
assert_eq!(samples, vec![1, 3]);
}
#[test]
fn run_task_message_includes_optional_vocabulary_id() {
let value: Value =
@@ -15,6 +15,7 @@ use anyhow::{Context, Result};
use base64::Engine;
use parking_lot::Mutex;
use serde_json::Value;
use std::time::{Duration, Instant};
use crate::asr::mimo::{join_transcript_chunks, split_pcm_by_duration};
use crate::asr::wav::encode_wav_16k_mono;
@@ -24,13 +25,66 @@ use crate::asr::RawTranscript;
// 体积。沿用 mimo 验证过的 180s 预算(16k/16-bit/mono WAV base64 后约 7.7MB),
// 稳稳落在时长和常见网关体积上限之内。超长录音按此切分后逐段识别再拼接。
const DASHSCOPE_MAX_CHUNK_DURATION_MS: u64 = 180_000;
const ASYNC_TASK_POLL_TIMEOUT_SECS: u64 = 600;
const ASYNC_WORKFLOW_OVERHEAD_SECS: u64 = 60;
const ASYNC_UPLOAD_BYTES_PER_SEC: u64 = 64 * 1024;
pub const PROVIDER_ID: &str = "bailian-fun-asr-flash";
pub const DEFAULT_ENDPOINT: &str =
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation";
pub const ASYNC_DEFAULT_ENDPOINT: &str =
"https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription";
pub const DEFAULT_MODEL: &str = "fun-asr-flash-2026-06-15";
pub const QWEN_AUDIO_MODEL: &str = "qwen-audio-3.0-asr-flash";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DashScopeBatchProtocol {
Multimodal,
AsyncTranscription,
}
fn is_realtime_model(model: &str) -> bool {
model.contains("realtime")
}
fn is_qwen_filetrans_model(model: &str) -> bool {
model.starts_with("qwen3-asr-flash-filetrans")
}
fn is_qwen_sync_model(model: &str) -> bool {
model.starts_with("qwen3-asr-flash")
&& !is_qwen_filetrans_model(model)
&& !is_realtime_model(model)
}
fn is_qwen_audio_model(model: &str) -> bool {
// 同步录音文件模型;`-streaming` 流式变体不在批量协议支持范围内。
model.starts_with("qwen-audio") && !model.contains("streaming")
}
pub fn protocol_for_model(model: &str) -> Option<DashScopeBatchProtocol> {
let model = model.trim();
if model.is_empty() || is_realtime_model(model) {
return None;
}
// qwen3-asr-flash-filetrans 官方仅接受公网音频 URL,与本地录音的临时 OSS
// 上传 + oss:// 链路不兼容,暂不纳入支持:显式拒绝,避免被误路由到异步协议
// 造成「验证通过但真实录音必然失败」。
if is_qwen_filetrans_model(model) {
return None;
}
if model.starts_with("fun-asr-flash")
|| is_qwen_sync_model(model)
|| is_qwen_audio_model(model)
{
return Some(DashScopeBatchProtocol::Multimodal);
}
if model == "fun-asr" || model.starts_with("fun-asr-") || model.starts_with("paraformer") {
return Some(DashScopeBatchProtocol::AsyncTranscription);
}
None
}
pub struct DashScopeMultimodalASR {
api_key: String,
base_url: String,
@@ -52,6 +106,20 @@ impl DashScopeMultimodalASR {
crate::asr::pcm::pcm_duration_ms(&self.buffer.lock())
}
pub fn transcribe_timeout(&self, audio_secs: f64) -> Duration {
if protocol_for_model(&self.model) == Some(DashScopeBatchProtocol::AsyncTranscription) {
let pcm_bytes = (audio_secs.max(0.0) * 32_000.0).ceil() as u64;
return async_upload_timeout(pcm_bytes.saturating_add(44))
+ Duration::from_secs(
ASYNC_TASK_POLL_TIMEOUT_SECS + ASYNC_WORKFLOW_OVERHEAD_SECS,
);
}
let secs = ((audio_secs * 0.5).ceil() as u64)
.saturating_add(20)
.max(30);
Duration::from_secs(secs)
}
pub async fn transcribe(&self) -> Result<RawTranscript> {
let pcm = self.buffer.lock().clone();
if pcm.is_empty() {
@@ -74,6 +142,15 @@ impl DashScopeMultimodalASR {
}
let duration_ms = crate::asr::pcm::pcm_duration_ms(pcm);
if protocol_for_model(&self.model) == Some(DashScopeBatchProtocol::AsyncTranscription) {
let samples: Vec<i16> = pcm
.chunks_exact(2)
.map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]))
.collect();
let wav = encode_wav_16k_mono(&samples);
let text = self.transcribe_async(&wav).await?;
return Ok(RawTranscript { text, duration_ms });
}
let chunks = split_pcm_by_duration(pcm, DASHSCOPE_MAX_CHUNK_DURATION_MS);
let mut texts = Vec::with_capacity(chunks.len());
for chunk in chunks {
@@ -94,14 +171,15 @@ impl DashScopeMultimodalASR {
let wav = encode_wav_16k_mono(&samples);
let body = dashscope_multimodal_body(&self.model, &wav);
let url = generation_url(&self.base_url)?;
let client = reqwest::Client::new();
let resp = client
let request_timeout = self.transcribe_timeout(crate::asr::pcm::pcm_duration_ms(pcm) as f64 / 1000.0);
let resp = crate::net::credential_http()
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key.trim()))
.header("Content-Type", "application/json")
// multimodal-generation 默认可 SSE 流式;显式关掉走一次性 JSON 响应。
.header("X-DashScope-SSE", "disable")
.json(&body)
.timeout(request_timeout)
.send()
.await
.context("DashScope ASR HTTP request failed")?;
@@ -116,14 +194,278 @@ impl DashScopeMultimodalASR {
Ok(extract_dashscope_text(&json).trim().to_string())
}
async fn transcribe_async(&self, wav: &[u8]) -> Result<String> {
let file_url = self.upload_temporary_wav(wav).await?;
self.transcribe_async_url(&file_url).await
}
async fn upload_temporary_wav(&self, wav: &[u8]) -> Result<String> {
let mut policy_url = api_url(&self.base_url, "/api/v1/uploads")?;
policy_url
.query_pairs_mut()
.append_pair("action", "getPolicy")
.append_pair("model", self.model.trim());
let response = crate::net::credential_http()
.get(policy_url)
.header("Authorization", format!("Bearer {}", self.api_key.trim()))
.header("Content-Type", "application/json")
.timeout(Duration::from_secs(30))
.send()
.await
.context("request DashScope temporary upload policy")?;
let policy_json = response_json(response, "DashScope upload policy").await?;
let data = policy_json
.get("data")
.context("DashScope upload policy missing data")?;
let field = |name: &str| -> Result<String> {
data.get(name)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.with_context(|| format!("DashScope upload policy missing {name}"))
};
let upload_dir = field("upload_dir")?;
let object_key = format!("{}/audio.wav", upload_dir.trim_end_matches('/'));
let form = reqwest::multipart::Form::new()
.text("OSSAccessKeyId", field("oss_access_key_id")?)
.text("policy", field("policy")?)
.text("Signature", field("signature")?)
.text("key", object_key.clone())
.text("x-oss-object-acl", field("x_oss_object_acl")?)
.text("x-oss-forbid-overwrite", field("x_oss_forbid_overwrite")?)
.text("success_action_status", "200")
.part(
"file",
reqwest::multipart::Part::bytes(wav.to_vec())
.file_name("audio.wav")
.mime_str("audio/wav")?,
);
let upload_url = dashscope_transfer_url(&field("upload_host")?)?;
let upload = crate::net::anonymous_no_redirect_http()
.post(upload_url)
.multipart(form)
.timeout(async_upload_timeout(wav.len() as u64))
.send()
.await
.context("upload audio to DashScope temporary storage")?;
ensure_success(upload, "DashScope temporary upload").await?;
Ok(format!("oss://{object_key}"))
}
pub async fn transcribe_async_url(&self, file_url: &str) -> Result<String> {
self.transcribe_async_url_with_timeout(
file_url,
Duration::from_secs(ASYNC_TASK_POLL_TIMEOUT_SECS),
)
.await
}
/// 提交异步任务并轮询至完成。`poll_timeout` 是任务轮询阶段的硬截止时间:
/// 真实转写用长轮询(默认 600s),连通性验证用短轮询以便快速返回,避免
/// 「验证」按钮在最坏情况下阻塞近 11 分钟。
pub async fn transcribe_async_url_with_timeout(
&self,
file_url: &str,
poll_timeout: Duration,
) -> Result<String> {
let submit_url = async_transcription_url(&self.base_url)?;
let response = crate::net::credential_http()
.post(submit_url)
.header("Authorization", format!("Bearer {}", self.api_key.trim()))
.header("Content-Type", "application/json")
.header("X-DashScope-Async", "enable")
.header("X-DashScope-OssResourceResolve", "enable")
.json(&async_transcription_body(&self.model, file_url))
.timeout(Duration::from_secs(30))
.send()
.await
.context("submit DashScope async ASR task")?;
let submitted = response_json(response, "DashScope async ASR submission").await?;
let task_id = submitted
.pointer("/output/task_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
.context("DashScope async ASR response missing task_id")?;
let task_url = api_url(&self.base_url, &format!("/api/v1/tasks/{task_id}"))?;
let deadline = Instant::now() + poll_timeout;
let completed = loop {
// 轮询窗口最长可达 600s、每秒一次:对瞬态网络失败做有界重试,
// 避免 10 分钟内单次连接抖动/5xx 直接废弃整段转写。
let task = get_json_with_retry(
crate::net::credential_http(),
task_url.clone(),
Some(self.api_key.trim()),
deadline,
"poll DashScope async ASR task",
)
.await?;
match task
.pointer("/output/task_status")
.and_then(Value::as_str)
.unwrap_or_default()
{
"SUCCEEDED" => break task,
"FAILED" | "CANCELED" | "UNKNOWN" => {
let message = task
.get("message")
.or_else(|| task.pointer("/output/message"))
.and_then(Value::as_str)
.unwrap_or("task failed");
anyhow::bail!("DashScope async ASR task failed: {message}");
}
_ if Instant::now() >= deadline => {
anyhow::bail!("DashScope async ASR task timed out");
}
_ => tokio::time::sleep(Duration::from_secs(1)).await,
}
};
let result = download_async_result(&extract_async_result_url(&completed)?).await?;
extract_async_transcript_text(&result)
}
pub fn cancel(&self) {
self.buffer.lock().clear();
}
}
pub fn is_supported_model(model: &str) -> bool {
let model = model.trim();
model == DEFAULT_MODEL || model == QWEN_AUDIO_MODEL
fn api_url(base_url: &str, path: &str) -> Result<reqwest::Url> {
let mut url = reqwest::Url::parse(base_url.trim()).context("parse DashScope base URL")?;
url.set_path(path);
url.set_query(None);
url.set_fragment(None);
Ok(url)
}
fn dashscope_transfer_url(raw: &str) -> Result<reqwest::Url> {
let mut url = reqwest::Url::parse(raw.trim()).context("parse DashScope transfer URL")?;
if !url.username().is_empty() || url.password().is_some() {
anyhow::bail!("DashScope transfer URL must not contain credentials");
}
let host = url
.host_str()
.context("DashScope transfer URL missing host")?
.to_ascii_lowercase();
#[cfg(test)]
if host == "localhost"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
{
return Ok(url);
}
if host != "aliyuncs.com" && !host.ends_with(".aliyuncs.com") {
anyhow::bail!("DashScope transfer URL must use an Alibaba Cloud OSS host");
}
match url.scheme() {
"https" => {}
"http" => url
.set_scheme("https")
.map_err(|_| anyhow::anyhow!("upgrade DashScope transfer URL to HTTPS"))?,
_ => anyhow::bail!("DashScope transfer URL must use HTTPS"),
}
Ok(url)
}
fn async_upload_timeout(bytes: u64) -> Duration {
let transfer_secs = bytes
.saturating_add(ASYNC_UPLOAD_BYTES_PER_SEC - 1)
/ ASYNC_UPLOAD_BYTES_PER_SEC;
Duration::from_secs(transfer_secs.saturating_add(30).max(60))
}
async fn download_async_result(raw_url: &str) -> Result<Value> {
let result_url = dashscope_transfer_url(raw_url)?;
let deadline = Instant::now() + Duration::from_secs(60);
get_json_with_retry(
crate::net::anonymous_no_redirect_http(),
result_url,
None,
deadline,
"download DashScope async ASR result",
)
.await
}
/// GET JSON 请求的瞬态失败重试上限(指数退避 500ms / 1s / 2s / 4s)。
const ASYNC_HTTP_RETRY_ATTEMPTS: u32 = 3;
fn retry_backoff(attempts: u32) -> Duration {
Duration::from_millis((500u64 * 2u64.pow(attempts.min(3))).min(4000))
}
/// 带瞬态重试的 GET JSON。
///
/// 连接失败 / 超时 / 请求阶段错误 / 5xx / 429 视为瞬态:指数退避重试,最多
/// `ASYNC_HTTP_RETRY_ATTEMPTS` 次且不晚于 `deadline`(GET 幂等,重试安全)。
/// 4xx 与确定性错误立即返回;`api_key` 为 Some 时附带 Bearer 头。
async fn get_json_with_retry(
client: &'static reqwest::Client,
url: reqwest::Url,
api_key: Option<&str>,
deadline: Instant,
operation: &'static str,
) -> Result<Value> {
let mut attempts: u32 = 0;
loop {
let mut request = client.get(url.clone()).timeout(Duration::from_secs(30));
if let Some(key) = api_key {
request = request.header("Authorization", format!("Bearer {key}"));
}
match request.send().await {
Ok(response) if response.status().is_success() => {
return response
.json()
.await
.with_context(|| format!("parse {operation} response"));
}
Ok(response) => {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let transient = status.is_server_error()
|| status == reqwest::StatusCode::TOO_MANY_REQUESTS;
if !transient || attempts >= ASYNC_HTTP_RETRY_ATTEMPTS || Instant::now() >= deadline
{
anyhow::bail!("{operation} error {status}: {body}");
}
attempts += 1;
tokio::time::sleep(retry_backoff(attempts)).await;
}
Err(err) => {
let transient = err.is_timeout() || err.is_connect() || err.is_request();
if !transient || attempts >= ASYNC_HTTP_RETRY_ATTEMPTS || Instant::now() >= deadline
{
return Err(err).with_context(|| format!("{operation} request failed"));
}
attempts += 1;
tokio::time::sleep(retry_backoff(attempts)).await;
}
}
}
}
async fn ensure_success(response: reqwest::Response, operation: &str) -> Result<()> {
if response.status().is_success() {
return Ok(());
}
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("{operation} error {status}: {body}")
}
async fn response_json(response: reqwest::Response, operation: &str) -> Result<Value> {
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("{operation} error {status}: {body}");
}
response
.json()
.await
.with_context(|| format!("parse {operation} response"))
}
impl crate::recorder::AudioConsumer for DashScopeMultimodalASR {
@@ -156,11 +498,30 @@ pub fn generation_url(base_url: &str) -> Result<String> {
Ok(url.to_string())
}
pub fn async_transcription_url(base_url: &str) -> Result<String> {
Ok(api_url(base_url, "/api/v1/services/audio/asr/transcription")?.to_string())
}
pub fn dashscope_multimodal_body(model: &str, wav: &[u8]) -> Value {
let audio_data = format!(
"data:audio/wav;base64,{}",
base64::engine::general_purpose::STANDARD.encode(wav)
);
dashscope_multimodal_body_from_uri(model, &audio_data)
}
pub fn dashscope_multimodal_body_from_uri(model: &str, audio_uri: &str) -> Value {
if is_qwen_sync_model(model) {
return serde_json::json!({
"model": model,
"input": {
"messages": [{
"role": "user",
"content": [{ "audio": audio_uri }],
}],
},
});
}
// qwen-audio-3.0-asr-flash 还支持 vocabulary 与 language_hints;当前批量客户端
// 尚未将这两项设置映射到请求体,暂时保持自动语言检测且不传热词。
serde_json::json!({
@@ -170,7 +531,7 @@ pub fn dashscope_multimodal_body(model: &str, wav: &[u8]) -> Value {
"role": "user",
"content": [{
"type": "input_audio",
"input_audio": { "data": audio_data },
"input_audio": { "data": audio_uri },
}],
}],
},
@@ -181,6 +542,63 @@ pub fn dashscope_multimodal_body(model: &str, wav: &[u8]) -> Value {
})
}
pub fn async_transcription_body(model: &str, file_url: &str) -> Value {
serde_json::json!({
"model": model,
"input": { "file_urls": [file_url] },
"parameters": {},
})
}
pub fn extract_async_result_url(json: &Value) -> Result<String> {
let url = json
.pointer("/output/results/0/transcription_url")
.and_then(Value::as_str);
url.map(str::trim)
.filter(|url| !url.is_empty())
.map(ToOwned::to_owned)
.context("DashScope async ASR response missing transcription_url")
}
pub fn extract_async_transcript_text(json: &Value) -> Result<String> {
let transcripts = json
.get("transcripts")
.context("DashScope async ASR result missing transcripts")?
.as_array()
.context("DashScope async ASR transcripts must be an array")?;
let mut texts = Vec::new();
for transcript in transcripts {
if let Some(value) = transcript.get("text") {
let text = value
.as_str()
.context("DashScope async ASR transcript text must be a string")?
.trim();
if !text.is_empty() {
texts.push(text.to_string());
}
continue;
}
let sentences = transcript
.get("sentences")
.context("DashScope async ASR transcript missing text or sentences")?
.as_array()
.context("DashScope async ASR sentences must be an array")?;
for sentence in sentences {
let text = sentence
.get("text")
.and_then(Value::as_str)
.context("DashScope async ASR sentence missing text")?
.trim();
if !text.is_empty() {
texts.push(text.to_string());
}
}
}
// 段间用空格分隔:中文识别结果几乎不含空格,连成整句无感知;而拉丁语言
// (英文等)的词汇若直接拼接会粘在一起,空格分隔对两种场景都更安全。
Ok(texts.join(" "))
}
/// fun-asr-flash 的响应信封与标准多模态接口不同,且不同模型版本字段路径略有
/// 差异(`output.text` / `output.output.sentence.text` / 标准 `choices`)。
/// 这里按已知路径逐一兜底提取,取到第一个非空文本即返回,避免因单一路径假设
@@ -249,6 +667,10 @@ mod tests {
use crate::recorder::AudioConsumer;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::thread;
use std::time::{Duration, Instant};
@@ -286,6 +708,159 @@ mod tests {
}
}
#[test]
fn qwen_flash_body_uses_documented_audio_shape() {
let body = dashscope_multimodal_body("qwen3-asr-flash", b"wav");
assert_eq!(body["model"], "qwen3-asr-flash");
let audio = &body["input"]["messages"][0]["content"][0];
assert!(audio["audio"]
.as_str()
.unwrap()
.starts_with("data:audio/wav;base64,"));
assert!(audio.get("input_audio").is_none());
}
#[test]
fn classifies_supported_batch_model_protocols() {
assert_eq!(
protocol_for_model("fun-asr-flash-2026-06-15"),
Some(DashScopeBatchProtocol::Multimodal)
);
assert_eq!(
protocol_for_model("qwen3-asr-flash-2026-02-10"),
Some(DashScopeBatchProtocol::Multimodal)
);
// beta 合并:#876 引入的 qwen-audio-3.0-asr-flash 走同步 multimodal。
assert_eq!(
protocol_for_model("qwen-audio-3.0-asr-flash"),
Some(DashScopeBatchProtocol::Multimodal)
);
for model in ["fun-asr", "fun-asr-mtl-2025-08-25", "paraformer-v2"] {
assert_eq!(
protocol_for_model(model),
Some(DashScopeBatchProtocol::AsyncTranscription),
"unexpected protocol for {model}"
);
}
// qwen3-asr-flash-filetrans 仅接受公网 URL,与本地录音的临时 OSS 链路
// 不兼容:显式拒绝,不得路由到异步协议。
assert_eq!(
protocol_for_model("qwen3-asr-flash-filetrans-2025-11-17"),
None
);
assert_eq!(protocol_for_model("unknown-asr"), None);
}
#[test]
fn async_models_get_a_task_polling_timeout() {
let asr = DashScopeMultimodalASR::new(
"sk-test".to_string(),
ASYNC_DEFAULT_ENDPOINT.to_string(),
"fun-asr".to_string(),
);
assert!(asr.transcribe_timeout(1.0) >= Duration::from_secs(660));
assert!(asr.transcribe_timeout(1_800.0) >= Duration::from_secs(1_500));
assert!(asr.transcribe_timeout(1_800.0) > asr.transcribe_timeout(1.0));
assert!(async_upload_timeout(58_000_000) >= Duration::from_secs(900));
}
#[test]
fn async_body_uses_file_urls_input_shape() {
let funasr = async_transcription_body("fun-asr", "oss://bucket/test.wav");
assert_eq!(funasr["input"]["file_urls"][0], "oss://bucket/test.wav");
assert!(funasr["input"].get("file_url").is_none());
assert_eq!(funasr["parameters"], serde_json::json!({}));
}
#[test]
fn extracts_async_result_url_from_results_array() {
let json = serde_json::json!({
"output": {"results": [{
"subtask_status": "SUCCEEDED",
"transcription_url": "https://result.example/funasr.json"
}]}
});
assert_eq!(
extract_async_result_url(&json).unwrap(),
"https://result.example/funasr.json"
);
}
#[test]
fn extracts_text_from_async_result_documents() {
let funasr = serde_json::json!({
"transcripts": [{"sentences": [{"text": "第一句"}, {"text": "第二句"}]}]
});
assert_eq!(extract_async_transcript_text(&funasr).unwrap(), "第一句 第二句");
let qwen = serde_json::json!({
"transcripts": [{"text": "Qwen 转写结果"}]
});
assert_eq!(extract_async_transcript_text(&qwen).unwrap(), "Qwen 转写结果");
}
#[test]
fn rejects_malformed_async_result_documents() {
assert!(extract_async_transcript_text(&serde_json::json!({})).is_err());
assert!(extract_async_transcript_text(&serde_json::json!({
"transcripts": [{"unexpected": "shape"}]
}))
.is_err());
}
#[test]
fn validates_dashscope_transfer_urls() {
let upgraded = dashscope_transfer_url(
"http://dashscope-file.oss-cn-beijing.aliyuncs.com/result.json",
)
.unwrap();
assert_eq!(upgraded.scheme(), "https");
assert!(dashscope_transfer_url("http://169.254.169.254/latest/meta-data").is_err());
assert!(dashscope_transfer_url("https://aliyuncs.com.evil.example/result.json").is_err());
}
#[tokio::test]
async fn get_json_retries_transient_5xx_then_succeeds() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let hits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let server_hits = Arc::clone(&hits);
let server = tokio::spawn(async move {
for expected_status in [503_u16, 200] {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request).await.unwrap();
server_hits.fetch_add(1, Ordering::SeqCst);
let (status_text, body) = if expected_status == 503 {
("Service Unavailable", "retry me")
} else {
("OK", "{\"ok\":true}")
};
let response = format!(
"HTTP/1.1 {expected_status} {status_text}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).await.unwrap();
}
});
let value = get_json_with_retry(
crate::net::credential_http(),
format!("http://{addr}/poll").parse().unwrap(),
None,
Instant::now() + Duration::from_secs(10),
"test poll",
)
.await
.unwrap();
assert_eq!(value["ok"], true);
assert_eq!(hits.load(Ordering::SeqCst), 2);
server.await.unwrap();
}
#[test]
fn extract_text_prefers_output_text() {
let json = serde_json::json!({ "output": { "text": " 你好世界 " } });
@@ -377,6 +952,198 @@ mod tests {
server.join().unwrap();
}
#[tokio::test]
async fn uploads_and_polls_async_transcription() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = thread::spawn(move || {
for step in 0..5 {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let request = read_http_request(&mut stream);
let request_text = String::from_utf8_lossy(&request);
let lower = request_text.to_ascii_lowercase();
if matches!(step, 0 | 2 | 3) {
assert!(lower.contains("authorization: bearer sk-test"));
} else {
assert!(!lower.contains("authorization:"));
}
match step {
0 => {
assert!(request_text.starts_with(
"GET /api/v1/uploads?action=getPolicy&model=fun-asr HTTP/1.1"
));
write_json_response(
&mut stream,
&format!(
r#"{{"data":{{"policy":"policy","signature":"signature","upload_dir":"dashscope-instant/test","upload_host":"http://{addr}","oss_access_key_id":"key-id","x_oss_object_acl":"private","x_oss_forbid_overwrite":"true"}}}}"#
),
);
}
1 => {
assert!(request_text.starts_with("POST / HTTP/1.1"));
assert!(lower.contains("content-type: multipart/form-data"));
assert!(request_text.contains("OSSAccessKeyId"));
assert!(request_text.contains("success_action_status"));
assert!(request_text.contains("audio.wav"));
write_json_response(&mut stream, "{}");
}
2 => {
assert!(request_text
.starts_with("POST /api/v1/services/audio/asr/transcription HTTP/1.1"));
assert!(lower.contains("x-dashscope-async: enable"));
assert!(lower.contains("x-dashscope-ossresourceresolve: enable"));
assert!(request_text
.contains(r#""file_urls":["oss://dashscope-instant/test/audio.wav"]"#));
write_json_response(&mut stream, r#"{"output":{"task_id":"task-1"}}"#);
}
3 => {
assert!(request_text.starts_with("GET /api/v1/tasks/task-1 HTTP/1.1"));
write_json_response(
&mut stream,
&format!(
r#"{{"output":{{"task_status":"SUCCEEDED","results":[{{"subtask_status":"SUCCEEDED","transcription_url":"http://{addr}/result.json"}}]}}}}"#
),
);
}
4 => {
assert!(request_text.starts_with("GET /result.json HTTP/1.1"));
write_json_response(
&mut stream,
r#"{"transcripts":[{"sentences":[{"text":"异步"},{"text":"转写"}]}]}"#,
);
}
_ => unreachable!(),
}
}
});
let asr = DashScopeMultimodalASR::new(
"sk-test".to_string(),
format!("http://{addr}/api/v1/services/audio/asr/transcription"),
"fun-asr".to_string(),
);
asr.consume_pcm_chunk(&vec![0u8; 32_000]);
let transcript = asr.transcribe().await.unwrap();
assert_eq!(transcript.text, "异步 转写");
assert_eq!(transcript.duration_ms, 1_000);
server.join().unwrap();
}
#[tokio::test]
async fn async_policy_request_does_not_follow_redirects_with_credentials() {
let redirect_listener = TcpListener::bind("127.0.0.1:0").unwrap();
let redirect_addr = redirect_listener.local_addr().unwrap();
let target_listener = TcpListener::bind("127.0.0.1:0").unwrap();
target_listener.set_nonblocking(true).unwrap();
let target_addr = target_listener.local_addr().unwrap();
let followed = Arc::new(AtomicBool::new(false));
let target_followed = Arc::clone(&followed);
let redirect_server = thread::spawn(move || {
let (mut stream, _) = redirect_listener.accept().unwrap();
let request = read_http_request(&mut stream);
assert!(String::from_utf8_lossy(&request)
.to_ascii_lowercase()
.contains("authorization: bearer sk-test"));
let response = format!(
"HTTP/1.1 302 Found\r\nlocation: http://{target_addr}/api/v1/uploads\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"
);
stream.write_all(response.as_bytes()).unwrap();
stream.flush().unwrap();
});
let target_server = thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
match target_listener.accept() {
Ok((mut stream, _)) => {
target_followed.store(true, Ordering::SeqCst);
let _request = read_http_request(&mut stream);
let body = r#"{"message":"redirect followed"}"#;
let response = format!(
"HTTP/1.1 400 Bad Request\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).unwrap();
stream.flush().unwrap();
break;
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
if Instant::now() >= deadline {
break;
}
thread::sleep(Duration::from_millis(10));
}
Err(err) => panic!("accept redirect target request failed: {err}"),
}
}
});
let asr = DashScopeMultimodalASR::new(
"sk-test".to_string(),
format!("http://{redirect_addr}/api/v1/services/audio/asr/transcription"),
"fun-asr".to_string(),
);
asr.consume_pcm_chunk(&vec![0u8; 32_000]);
let error = asr.transcribe().await.unwrap_err().to_string();
assert!(error.contains("302 Found"), "unexpected error: {error}");
redirect_server.join().unwrap();
target_server.join().unwrap();
assert!(!followed.load(Ordering::SeqCst));
}
#[tokio::test]
async fn async_result_download_does_not_follow_redirects() {
let redirect_listener = TcpListener::bind("127.0.0.1:0").unwrap();
let redirect_addr = redirect_listener.local_addr().unwrap();
let target_listener = TcpListener::bind("127.0.0.1:0").unwrap();
target_listener.set_nonblocking(true).unwrap();
let target_addr = target_listener.local_addr().unwrap();
let followed = Arc::new(AtomicBool::new(false));
let target_followed = Arc::clone(&followed);
let redirect_server = thread::spawn(move || {
let (mut stream, _) = redirect_listener.accept().unwrap();
let request = read_http_request(&mut stream);
assert!(!String::from_utf8_lossy(&request)
.to_ascii_lowercase()
.contains("authorization:"));
let response = format!(
"HTTP/1.1 302 Found\r\nlocation: http://{target_addr}/result.json\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"
);
stream.write_all(response.as_bytes()).unwrap();
stream.flush().unwrap();
});
let target_server = thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline {
match target_listener.accept() {
Ok((_stream, _)) => {
target_followed.store(true, Ordering::SeqCst);
break;
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(err) => panic!("accept result redirect target failed: {err}"),
}
}
});
let error = download_async_result(&format!("http://{redirect_addr}/result.json"))
.await
.unwrap_err()
.to_string();
assert!(error.contains("302 Found"), "unexpected error: {error}");
redirect_server.join().unwrap();
target_server.join().unwrap();
assert!(!followed.load(Ordering::SeqCst));
}
fn read_http_request(stream: &mut std::net::TcpStream) -> Vec<u8> {
let mut request = Vec::new();
let mut buf = [0u8; 4096];
@@ -400,14 +1167,17 @@ mod tests {
fn parse_expected_request_len(request: &[u8]) -> Option<usize> {
let header_end = request.windows(4).position(|w| w == b"\r\n\r\n")? + 4;
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_len = headers.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
value.trim().parse::<usize>().ok()
} else {
None
}
})?;
let content_len = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
value.trim().parse::<usize>().ok()
} else {
None
}
})
.unwrap_or(0);
Some(header_end + content_len)
}
@@ -27,10 +27,7 @@ pub async fn validate_provider_credentials(kind: String) -> Result<ProviderCheck
#[tauri::command]
pub async fn list_provider_models(kind: String) -> Result<ProviderModelsResult, String> {
if kind == "asr"
&& CredentialsVault::get_active_asr() == crate::asr::bailian::PROVIDER_ID
&& !cfg!(mobile)
{
if kind == "asr" && CredentialsVault::get_active_asr() == crate::asr::bailian::PROVIDER_ID {
// 统一「阿里云百炼」入口:三条协议(实时 fun-asr-realtime / 实时 qwen3 /
// 录音文件 fun-asr-flash)收成一个 provider。百炼各网关都没有模型列表 HTTP
// 接口,列表是静态的;但先跑一次与「验证」相同的、按当前所选模型对应协议的
@@ -42,23 +39,22 @@ pub async fn list_provider_models(kind: String) -> Result<ProviderModelsResult,
return Ok(ProviderModelsResult {
models: vec![
crate::asr::bailian::DEFAULT_MODEL.to_string(),
"fun-asr-flash-8k-realtime".to_string(),
crate::asr::qwen_realtime::DEFAULT_MODEL.to_string(),
"qwen3-asr-flash-realtime-2026-02-10".to_string(),
"qwen3-asr-flash-realtime-2025-10-27".to_string(),
crate::asr::dashscope_multimodal::QWEN_AUDIO_MODEL.to_string(),
crate::asr::dashscope_multimodal::DEFAULT_MODEL.to_string(),
"qwen3-asr-flash".to_string(),
"fun-asr".to_string(),
"fun-asr-2025-11-07".to_string(),
"fun-asr-2025-08-25".to_string(),
"fun-asr-mtl".to_string(),
"fun-asr-mtl-2025-08-25".to_string(),
"paraformer-v2".to_string(),
],
});
}
if kind == "asr"
&& CredentialsVault::get_active_asr() == crate::asr::bailian::PROVIDER_ID
&& cfg!(mobile)
{
validate_bailian_asr_provider().await?;
return Ok(ProviderModelsResult {
models: vec![crate::asr::bailian::DEFAULT_MODEL.to_string()],
});
}
if kind == "asr" && CredentialsVault::get_active_asr() == crate::asr::qwen_realtime::PROVIDER_ID
{
// 与 bailian 同理:Realtime 网关无模型列表接口,先做真实连通性检查,
@@ -399,10 +395,22 @@ async fn validate_elevenlabs_asr_provider() -> Result<(), String> {
/// 即返回 200。
const DASHSCOPE_ASR_VALIDATE_SAMPLE_URL: &str =
"https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav";
// 异步验证只需确认「提交 → 轮询 → 下载」链路可用:示例音频很短,任务通常在
// 数十秒内完成。外层 120s(30s 提交 + 60s 轮询 + 30s 下载)封顶,避免验证按钮
// 在最坏情况下阻塞近 11 分钟(真实转写仍用长轮询,不受影响)。
const DASHSCOPE_ASR_VALIDATE_TIMEOUT_SECS: u64 = 120;
const DASHSCOPE_ASR_VALIDATE_POLL_SECS: u64 = 60;
async fn validate_dashscope_multimodal_asr_provider() -> Result<(), String> {
// 统一百炼复用配置中的区域/工作空间主机,并推导 multimodal 的 https 路径。
// 隐藏别名仍按原有完整 endpoint 读取。
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::dashscope_multimodal::DEFAULT_MODEL.to_string());
crate::coordinator::validate_dashscope_multimodal_model(&model)?;
let protocol = crate::asr::dashscope_multimodal::protocol_for_model(&model)
.unwrap_or(crate::asr::dashscope_multimodal::DashScopeBatchProtocol::Multimodal);
let (api_key, base_url) = if crate::coordinator::unified_bailian_is_active() {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.map_err(|e| e.to_string())?
@@ -411,39 +419,56 @@ async fn validate_dashscope_multimodal_asr_provider() -> Result<(), String> {
let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint)
.map_err(|e| e.to_string())?
.unwrap_or_default();
let endpoint = crate::coordinator::derive_bailian_endpoint(
&endpoint,
crate::coordinator::BailianEndpointProtocol::Multimodal,
)?;
let endpoint_protocol = match protocol {
crate::asr::dashscope_multimodal::DashScopeBatchProtocol::Multimodal => {
crate::coordinator::BailianEndpointProtocol::Multimodal
}
crate::asr::dashscope_multimodal::DashScopeBatchProtocol::AsyncTranscription => {
crate::coordinator::BailianEndpointProtocol::AsyncTranscription
}
};
let endpoint = crate::coordinator::derive_bailian_endpoint(&endpoint, endpoint_protocol)?;
(api_key, endpoint)
} else {
let config = read_openai_provider_config("asr")?;
(config.api_key, config.base_url)
};
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.map_err(|e| e.to_string())?
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::dashscope_multimodal::DEFAULT_MODEL.to_string());
crate::coordinator::validate_dashscope_multimodal_model(&model)?;
if protocol == crate::asr::dashscope_multimodal::DashScopeBatchProtocol::AsyncTranscription {
let asr = crate::asr::DashScopeMultimodalASR::new(api_key, base_url, model);
return match tokio::time::timeout(
std::time::Duration::from_secs(DASHSCOPE_ASR_VALIDATE_TIMEOUT_SECS),
asr.transcribe_async_url_with_timeout(
DASHSCOPE_ASR_VALIDATE_SAMPLE_URL,
std::time::Duration::from_secs(DASHSCOPE_ASR_VALIDATE_POLL_SECS),
),
)
.await
{
Ok(result) => result.map(|_| ()).map_err(|error| error.to_string()),
Err(_) => Err("providerRequestTimeout".to_string()),
};
}
let url = crate::asr::dashscope_multimodal::generation_url(&base_url)
.map_err(|_| "endpointInvalid".to_string())?;
let body = serde_json::json!({
"model": model,
"input": { "messages": [{ "role": "user", "content": [{
"type": "input_audio",
"input_audio": { "data": DASHSCOPE_ASR_VALIDATE_SAMPLE_URL },
}]}]},
"parameters": { "format": "wav", "sample_rate": "16000" },
});
let client = http_client_builder(&url, 20)
.build()
.map_err(|_| "providerClientInitFailed".to_string())?;
let response = client
.post(&url)
let body = crate::asr::dashscope_multimodal::dashscope_multimodal_body_from_uri(
&model,
DASHSCOPE_ASR_VALIDATE_SAMPLE_URL,
);
send_dashscope_multimodal_validation(&api_key, url.as_str(), &body).await
}
async fn send_dashscope_multimodal_validation(
api_key: &str,
url: &str,
body: &Value,
) -> Result<(), String> {
let response = crate::net::credential_http()
.post(url)
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.header("X-DashScope-SSE", "disable")
.json(&body)
.timeout(std::time::Duration::from_secs(20))
.send()
.await
.map_err(|e| {
@@ -919,7 +944,7 @@ 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, ProviderConfig,
sanitized_provider_destination, send_dashscope_multimodal_validation, ProviderConfig,
};
use crate::endpoint_security::validate_http_endpoint;
@@ -1112,6 +1137,45 @@ mod tests {
);
}
#[tokio::test]
async fn dashscope_validation_does_not_follow_redirects_with_credentials() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let redirect_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let redirect_addr = redirect_listener.local_addr().unwrap();
let target_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let target_addr = target_listener.local_addr().unwrap();
let redirect_server = tokio::spawn(async move {
let (mut stream, _) = redirect_listener.accept().await.unwrap();
let mut request = [0_u8; 2048];
let count = stream.read(&mut request).await.unwrap();
let request = String::from_utf8_lossy(&request[..count]).to_ascii_lowercase();
assert!(request.contains("authorization: bearer sk-test"));
let response = format!(
"HTTP/1.1 302 Found\r\nLocation: http://{target_addr}/stolen\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
stream.write_all(response.as_bytes()).await.unwrap();
});
let target_server = tokio::spawn(async move {
tokio::time::timeout(std::time::Duration::from_millis(500), target_listener.accept())
.await
.is_ok()
});
let error = send_dashscope_multimodal_validation(
"sk-test",
&format!("http://{redirect_addr}/validate"),
&serde_json::json!({"model": "fun-asr-flash"}),
)
.await
.unwrap_err();
redirect_server.await.unwrap();
assert_eq!(error, "providerHttpStatus:302");
assert!(!target_server.await.unwrap(), "validation followed redirect");
}
#[test]
fn asr_endpoint_rejects_metadata_cgnat_and_non_https_public() {
// 元数据 / CGNAT / 非 https 外网:拒绝,避免带 API Key 的 ASR 请求被指向高价值目标 / 明文外泄。
+66 -29
View File
@@ -340,30 +340,27 @@ pub(crate) fn resolve_effective_asr_provider(
return Ok(active_asr.to_string());
}
// Android/iOS 继续使用原来的 Bailian WebSocket 配置。统一入口的模型路由
// 只在桌面端启用,避免共享设置页意外改变移动端行为。
if cfg!(mobile) {
return Ok(crate::asr::bailian::PROVIDER_ID.to_string());
}
let model = model.trim();
if model.is_empty() || is_classic_bailian_realtime_model(model) {
Ok(crate::asr::bailian::PROVIDER_ID.to_string())
} else if model.starts_with("qwen3-asr-flash-realtime") {
Ok(crate::asr::qwen_realtime::PROVIDER_ID.to_string())
} else if crate::asr::dashscope_multimodal::is_supported_model(model) {
} else if crate::asr::dashscope_multimodal::protocol_for_model(model).is_some() {
Ok(crate::asr::dashscope_multimodal::PROVIDER_ID.to_string())
} else {
Err(format!(
"不支持的百炼 ASR 模型:{model}。支持 fun-asr-realtime、paraformer-realtime、sensevoice-realtime、qwen3-asr-flash-realtime、qwen-audio-3.0-asr-flash 和 fun-asr-flash-2026-06-15"
"不支持的百炼 ASR 模型:{model}。支持 Fun-ASR、Paraformer、SenseVoice、qwen-audio-3.0-asr-flash 和 Qwen3-ASR 的实时、同步及录音文件模型"
))
}
}
fn is_classic_bailian_realtime_model(model: &str) -> bool {
model.starts_with("fun-asr-realtime")
|| model.starts_with("fun-asr-flash-8k-realtime")
|| model.starts_with("paraformer-realtime")
|| model.starts_with("paraformer-8k-realtime")
|| model.starts_with("sensevoice-realtime")
|| model.starts_with("sensevoice-8k-realtime")
}
/// StepFun 的流式模型命名恒以 `-stream` 结尾(stepaudio-2.5-asr-stream /
@@ -374,12 +371,10 @@ pub(crate) fn stepfun_model_is_stream(model: &str) -> bool {
pub(crate) fn validate_dashscope_multimodal_model(model: &str) -> Result<(), String> {
let model = model.trim();
if model.is_empty() || crate::asr::dashscope_multimodal::is_supported_model(model) {
if model.is_empty() || crate::asr::dashscope_multimodal::protocol_for_model(model).is_some() {
return Ok(());
}
Err(format!(
"不支持的 DashScope 录音文件 ASR 模型:{model}。该协议支持 qwen-audio-3.0-asr-flash 和 fun-asr-flash-2026-06-15;fun-asr-flash-8k-realtime 系列属于 8 kHz 实时 WebSocket 模型,当前尚未支持"
))
Err(format!("不支持的 DashScope 录音文件 ASR 模型:{model}"))
}
#[derive(Clone, Copy)]
@@ -387,6 +382,7 @@ pub(crate) enum BailianEndpointProtocol {
ClassicRealtime,
QwenRealtime,
Multimodal,
AsyncTranscription,
}
/// 统一百炼配置只需要表达区域/工作空间主机;具体协议的 scheme 与 path 由模型路由决定。
@@ -399,6 +395,9 @@ pub(crate) fn derive_bailian_endpoint(
BailianEndpointProtocol::ClassicRealtime => crate::asr::bailian::DEFAULT_ENDPOINT,
BailianEndpointProtocol::QwenRealtime => crate::asr::qwen_realtime::DEFAULT_ENDPOINT,
BailianEndpointProtocol::Multimodal => crate::asr::dashscope_multimodal::DEFAULT_ENDPOINT,
BailianEndpointProtocol::AsyncTranscription => {
crate::asr::dashscope_multimodal::ASYNC_DEFAULT_ENDPOINT
}
};
let source = if endpoint.trim().is_empty() {
default_endpoint
@@ -416,6 +415,9 @@ pub(crate) fn derive_bailian_endpoint(
"https",
"/api/v1/services/aigc/multimodal-generation/generation",
),
BailianEndpointProtocol::AsyncTranscription => {
("https", "/api/v1/services/audio/asr/transcription")
}
};
url.set_scheme(scheme)
.map_err(|_| "endpointInvalid".to_string())?;
@@ -1904,9 +1906,7 @@ impl Coordinator {
let consumer = start.recorder_consumer();
consumer.consume_pcm_chunk(&pcm);
let timeout = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
let dashscope_timeout = whisper_transcribe_timeout(
crate::asr::pcm::pcm_duration_ms(&pcm) as f64 / 1000.0,
);
let audio_secs = crate::asr::pcm::pcm_duration_ms(&pcm) as f64 / 1000.0;
let elevenlabs_timeout = crate::asr::elevenlabs::transcribe_timeout(
crate::asr::pcm::pcm_duration_ms(&pcm) as f64 / 1000.0,
);
@@ -1948,7 +1948,7 @@ impl Coordinator {
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?,
ActiveAsr::DashScopeMultimodal(m) => {
tokio::time::timeout(dashscope_timeout, m.transcribe())
tokio::time::timeout(m.transcribe_timeout(audio_secs), m.transcribe())
.await
.map_err(|_| "重新转录超时".to_string())?
.map_err(|e| e.to_string())?
@@ -2386,9 +2386,20 @@ fn read_dashscope_multimodal_credentials() -> (String, String, String) {
.ok()
.flatten()
.unwrap_or_default();
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::dashscope_multimodal::DEFAULT_MODEL.to_string());
let base_url = if unified_bailian_is_active() {
let endpoint = read_asr_endpoint(crate::asr::bailian::DEFAULT_ENDPOINT);
derive_bailian_endpoint(&endpoint, BailianEndpointProtocol::Multimodal).unwrap_or(endpoint)
let protocol = match crate::asr::dashscope_multimodal::protocol_for_model(&model) {
Some(crate::asr::dashscope_multimodal::DashScopeBatchProtocol::AsyncTranscription) => {
BailianEndpointProtocol::AsyncTranscription
}
_ => BailianEndpointProtocol::Multimodal,
};
derive_bailian_endpoint(&endpoint, protocol).unwrap_or(endpoint)
} else {
CredentialsVault::get(CredentialAccount::AsrEndpoint)
.ok()
@@ -2396,11 +2407,6 @@ fn read_dashscope_multimodal_credentials() -> (String, String, String) {
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::dashscope_multimodal::DEFAULT_ENDPOINT.to_string())
};
let model = CredentialsVault::get(CredentialAccount::AsrModel)
.ok()
.flatten()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| crate::asr::dashscope_multimodal::DEFAULT_MODEL.to_string());
(api_key, base_url, model)
}
@@ -3090,6 +3096,24 @@ mod tests {
resolve_effective_asr_provider(bailian, "fun-asr-flash-2026-06-15").unwrap(),
crate::asr::dashscope_multimodal::PROVIDER_ID
);
for model in [
"fun-asr-flash-2026-09-01",
"qwen3-asr-flash",
"qwen3-asr-flash-2026-02-10",
"fun-asr",
"fun-asr-mtl-2025-08-25",
"paraformer-v2",
] {
assert_eq!(
resolve_effective_asr_provider(bailian, model).unwrap(),
crate::asr::dashscope_multimodal::PROVIDER_ID,
"unexpected route for {model}"
);
}
assert_eq!(
resolve_effective_asr_provider(bailian, "fun-asr-flash-8k-realtime").unwrap(),
crate::asr::bailian::PROVIDER_ID
);
assert_eq!(
resolve_effective_asr_provider(bailian, "qwen-audio-3.0-asr-flash").unwrap(),
crate::asr::dashscope_multimodal::PROVIDER_ID
@@ -3098,6 +3122,16 @@ mod tests {
resolve_effective_asr_provider(bailian, "paraformer-realtime-v2").unwrap(),
crate::asr::bailian::PROVIDER_ID
);
// 8k 实时变体同样走经典 WebSocket(降采样 + sample_rate=8000),
// 与 bailian.rs::model_is_8k 的命名空间保持一致。
assert_eq!(
resolve_effective_asr_provider(bailian, "paraformer-8k-realtime-v2").unwrap(),
crate::asr::bailian::PROVIDER_ID
);
assert_eq!(
resolve_effective_asr_provider(bailian, "sensevoice-8k-realtime-v1").unwrap(),
crate::asr::bailian::PROVIDER_ID
);
// 空模型 → 经典实时(百炼默认);未知模型应被拒绝。
assert_eq!(
resolve_effective_asr_provider(bailian, "").unwrap(),
@@ -3117,15 +3151,13 @@ mod tests {
#[test]
fn resolve_effective_asr_provider_rejects_unsupported_bailian_model() {
let error = resolve_effective_asr_provider(crate::asr::bailian::PROVIDER_ID, "paraformer-v2")
let error = resolve_effective_asr_provider(crate::asr::bailian::PROVIDER_ID, "unknown-asr")
.unwrap_err();
assert!(error.contains("不支持的百炼 ASR 模型"));
let error = resolve_effective_asr_provider(
crate::asr::bailian::PROVIDER_ID,
"fun-asr-flash-8k-realtime",
)
.unwrap_err();
// qwen3-asr-flash-filetrans 仅接受公网 URL,与本地录音链路不兼容,同样拒绝。
let error =
resolve_effective_asr_provider(crate::asr::bailian::PROVIDER_ID, "qwen3-asr-flash-filetrans")
.unwrap_err();
assert!(error.contains("不支持的百炼 ASR 模型"));
}
@@ -3156,6 +3188,11 @@ mod tests {
derive_bailian_endpoint(endpoint, BailianEndpointProtocol::Multimodal).unwrap(),
"https://workspace.ap-southeast-1.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
);
assert_eq!(
derive_bailian_endpoint(endpoint, BailianEndpointProtocol::AsyncTranscription)
.unwrap(),
"https://workspace.ap-southeast-1.maas.aliyuncs.com/api/v1/services/audio/asr/transcription"
);
}
#[test]
@@ -2935,7 +2935,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
ActiveAsr::DashScopeMultimodal(m) => {
debug_assert!(uses_global_timeout);
let audio_secs = m.buffer_duration_ms() as f64 / 1000.0;
let timeout_duration = whisper_transcribe_timeout(audio_secs);
let timeout_duration = m.transcribe_timeout(audio_secs);
log::info!(
"[coord] DashScope Fun-ASR-Flash dynamic timeout: {}s (audio {:.2}s)",
timeout_duration.as_secs(),
@@ -480,11 +480,17 @@ pub(super) async fn transcribe_overlay_dictation_asr(
ActiveAsr::DashScopeMultimodal(asr) => {
debug_assert!(uses_global_timeout);
let audio_secs = asr.buffer_duration_ms() as f64 / 1000.0;
let timeout_duration = whisper_transcribe_timeout(audio_secs);
match tokio::time::timeout(timeout_duration, asr.transcribe()).await {
Ok(Ok(raw)) => Ok(raw),
Ok(Err(error)) => Err(error.to_string()),
Err(_) => Err("dashscope multimodal global timeout".to_string()),
let timeout_duration = asr.transcribe_timeout(audio_secs);
tokio::select! {
result = tokio::time::timeout(timeout_duration, asr.transcribe()) => match result {
Ok(Ok(raw)) => Ok(raw),
Ok(Err(error)) => Err(error.to_string()),
Err(_) => Err("dashscope multimodal global timeout".to_string()),
},
_ = wait_for_overlay_dictation_cancel(_inner, _current_session_id) => {
asr.cancel();
return OverlayDictationTranscribeOutcome::Cancelled;
}
}
}
ActiveAsr::ElevenLabs(asr) => {
@@ -1145,22 +1151,29 @@ pub(super) async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
ActiveAsr::DashScopeMultimodal(m) => {
debug_assert!(uses_global_timeout);
let audio_secs = m.buffer_duration_ms() as f64 / 1000.0;
let timeout_duration = whisper_transcribe_timeout(audio_secs);
match tokio::time::timeout(timeout_duration, m.transcribe()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA: DashScope Fun-ASR-Flash transcribe failed: {e}");
finish_qa_with_error_if_current(inner, session_id, format!("识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA: DashScope Fun-ASR-Flash dynamic timeout {}s (audio {:.2}s)",
timeout_duration.as_secs(),
audio_secs
);
finish_qa_with_error_if_current(inner, session_id, "识别超时".to_string());
return Err("dashscope multimodal global timeout".to_string());
let timeout_duration = m.transcribe_timeout(audio_secs);
tokio::select! {
result = tokio::time::timeout(timeout_duration, m.transcribe()) => match result {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] QA: DashScope Fun-ASR-Flash transcribe failed: {e}");
finish_qa_with_error_if_current(inner, session_id, format!("识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA: DashScope Fun-ASR-Flash dynamic timeout {}s (audio {:.2}s)",
timeout_duration.as_secs(),
audio_secs
);
finish_qa_with_error_if_current(inner, session_id, "识别超时".to_string());
return Err("dashscope multimodal global timeout".to_string());
}
},
_ = wait_for_qa_processing_cancel(inner, session_id) => {
m.cancel();
finish_qa_idle_silently_if_current(inner, session_id);
return Ok(());
}
}
}
+2 -1
View File
@@ -842,7 +842,8 @@ export const en: typeof zhCN = {
bailianVocabularyIdLabel: 'Hotword Vocabulary ID (optional)',
bailianVocabularyIdNote: 'If you have created a DashScope hotword vocabulary, enter its vocab-... ID. Leave blank to skip hotwords.',
bailianModelRealtimeHint: 'Realtime model · transcribes as you speak.',
bailianModelRecordedFileHint: 'Recorded-file model · transcribes after you finish (single clip ≤ 5 min).',
bailianModelSyncFileHint: 'Synchronous recording model · transcribes after you finish (single clip ≤ 5 min).',
bailianModelAsyncFileHint: 'Asynchronous file model · uploads the recording and waits for the transcription task.',
appIdLabel: 'App ID',
accessKeyLabel: 'Access Key',
resourceIdLabel: 'Resource ID',
+2 -1
View File
@@ -844,7 +844,8 @@ export const ja: typeof zhCN = {
bailianVocabularyIdLabel: 'ホットワード Vocabulary ID(任意)',
bailianVocabularyIdNote: 'DashScope でホットワード辞書を作成済みの場合は vocab-... ID を入力します。空欄なら送信しません。',
bailianModelRealtimeHint: 'リアルタイムモデル · 話しながら文字起こし。',
bailianModelRecordedFileHint: '録音ファイルモデル · 話し終えてから一括で文字起こし(1 本 ≤ 5 分)。',
bailianModelSyncFileHint: '同期録音モデル · 話し終えてから一括で文字起こし(1 本 ≤ 5 分)。',
bailianModelAsyncFileHint: '非同期ファイルモデル · 録音をアップロードし、文字起こしタスクの完了を待ちます。',
appIdLabel: 'App ID(アプリケーション ID)',
accessKeyLabel: 'Access Key',
resourceIdLabel: 'Resource ID',
+2 -1
View File
@@ -844,7 +844,8 @@ export const ko: typeof zhCN = {
bailianVocabularyIdLabel: '핫워드 Vocabulary ID(선택)',
bailianVocabularyIdNote: 'DashScope에서 핫워드 사전을 만들었다면 vocab-... ID를 입력하세요. 비워 두면 핫워드를 전송하지 않습니다.',
bailianModelRealtimeHint: '실시간 모델 · 말하는 동안 바로 전사.',
bailianModelRecordedFileHint: '녹음 파일 모델 · 말을 마친 뒤 전체 전사(한 클립 ≤ 5분).',
bailianModelSyncFileHint: '동기 녹음 모델 · 말을 마친 뒤 전체 전사(한 클립 ≤ 5분).',
bailianModelAsyncFileHint: '비동기 파일 모델 · 녹음을 업로드한 뒤 전사 작업 완료를 기다립니다.',
appIdLabel: 'App ID(애플리케이션 ID)',
accessKeyLabel: 'Access Key',
resourceIdLabel: 'Resource ID',
+2 -1
View File
@@ -840,7 +840,8 @@ export const zhCN = {
bailianVocabularyIdLabel: '热词 Vocabulary ID(可选)',
bailianVocabularyIdNote: '如已在百炼创建热词表,可填写 vocab-...;留空则不下发热词。',
bailianModelRealtimeHint: '实时模型 · 边说边出字。',
bailianModelRecordedFileHint: '录音文件模型 · 说完后整段转写(单条 ≤ 5 分钟)。',
bailianModelSyncFileHint: '同步录音模型 · 说完后整段转写(单条 ≤ 5 分钟)。',
bailianModelAsyncFileHint: '异步文件模型 · 录音上传后等待转写任务完成。',
appIdLabel: 'App ID(应用 ID)',
accessKeyLabel: 'Access Key',
resourceIdLabel: '资源 ID',
+2 -1
View File
@@ -842,7 +842,8 @@ export const zhTW: typeof zhCN = {
bailianVocabularyIdLabel: '熱詞 Vocabulary ID(可選)',
bailianVocabularyIdNote: '如已在百煉建立熱詞表,可填寫 vocab-...;留空則不下發熱詞。',
bailianModelRealtimeHint: '即時模型 · 邊說邊出字。',
bailianModelRecordedFileHint: '錄音檔模型 · 說完後整段轉寫(單條 ≤ 5 分鐘)。',
bailianModelSyncFileHint: '同步錄音模型 · 說完後整段轉寫(單條 ≤ 5 分鐘)。',
bailianModelAsyncFileHint: '非同步檔案模型 · 錄音上傳後等待轉寫工作完成。',
appIdLabel: 'App ID(應用 ID)',
accessKeyLabel: 'Access Key',
resourceIdLabel: '資源 ID',
@@ -194,7 +194,7 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
const [llmModelRevision, setLlmModelRevision] = useState(0);
const [asrModelRevision, setAsrModelRevision] = useState(0);
const os = detectOS();
const unifiedBailian = committedAsrProvider === 'bailian' && os !== 'android';
const unifiedBailian = committedAsrProvider === 'bailian';
const [bailianModel, setBailianModel] = useState('');
const [volcengineAuthMode, setVolcengineAuthMode] = useState<'app_id_token' | 'api_key'>('app_id_token');
@@ -607,11 +607,18 @@ export function ProvidersSection({ kind = 'all' }: ProvidersSectionProps = {}) {
// 统一「阿里云百炼」下,按模型名判断走哪种协议(与后端
// coordinator::resolve_effective_asr_provider 保持一致):qwen3-asr-flash-realtime* 与
// fun-asr-realtime* 与 fun-asr-flash-8k-realtime* 都是实时模型;当前支持的
// fun-asr-flash-2026-06-15 / qwen-audio-3.0-asr-flash 是「录音文件·说完转写」。
function bailianModelIsRecordedFile(model: string): boolean {
// fun-asr-realtime* 与 fun-asr-flash-8k-realtime* 都是实时模型;fun-asr-flash-2026-06-15
// 与 qwen-audio-3.0-asr-flash 是「录音文件·说完转写」(同步)。
function bailianModelProtocol(model: string): 'realtime' | 'sync' | 'async' {
const m = model.trim();
return m === 'fun-asr-flash-2026-06-15' || m === 'qwen-audio-3.0-asr-flash';
if (!m || m.includes('realtime')) return 'realtime';
// qwen3-asr-flash-filetrans 仅接受公网 URL,暂不支持(后端 protocol_for_model
// 显式拒绝),前端不再归为 async 提示。
if (m === 'fun-asr'
|| m.startsWith('fun-asr-') && !m.startsWith('fun-asr-flash')
|| m.startsWith('paraformer')) return 'async';
// 其余(fun-asr-flash-*、qwen3-asr-flash、qwen-audio-3.0-asr-flash)为同步录音模型。
return 'sync';
}
// qwen-audio-3.0-asr-flash 官方支持热词,但批量协议尚未把该设置写入请求体;
@@ -642,9 +649,12 @@ function BailianProtocolHint({ currentModel }: { currentModel: string }) {
setModel(currentModel || 'fun-asr-realtime');
}, [currentModel]);
const hint = bailianModelIsRecordedFile(model)
? t('settings.providers.bailianModelRecordedFileHint')
: t('settings.providers.bailianModelRealtimeHint');
const protocol = bailianModelProtocol(model);
const hint = protocol === 'realtime'
? t('settings.providers.bailianModelRealtimeHint')
: protocol === 'async'
? t('settings.providers.bailianModelAsyncFileHint')
: t('settings.providers.bailianModelSyncFileHint');
return (
<div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>