Skip to content
Open
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
86 changes: 79 additions & 7 deletions openless-all/app/crates/openless-core/src/asr/whisper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ pub struct WhisperBatchASR {
/// 一等 `hotwords` 参数(JSON 数组字符串)。StepFun 等厂商不认 `prompt`
/// (静默忽略),但提供专门的热词字段——用它词典才真正生效。空 = 不发。
hotwords: Vec<String>,
/// 自定义端点路径(默认 `/audio/transcriptions`;MiniMax 等厂商为 `/speech_to_text`)。
endpoint_path: Option<String>,
buffer: Mutex<Vec<u8>>,
}

Expand All @@ -80,10 +82,17 @@ impl WhisperBatchASR {
language: None,
enable_itn: true,
hotwords: Vec::new(),
endpoint_path: None,
buffer: Mutex::new(Vec::new()),
}
}

/// 设置自定义端点路径(例如 `"/speech_to_text"`)。
pub fn with_endpoint_path(mut self, path: impl Into<String>) -> Self {
self.endpoint_path = Some(path.into());
self
}

/// 设置请求体编码方式(默认 `Multipart`)。OpenRouter 需 `OpenRouterJson`。
/// 用 builder 而非给 `new()` 加参数,避免改动既有 4 处构造点的签名。
pub fn with_request_format(mut self, request_format: AsrRequestFormat) -> Self {
Expand Down Expand Up @@ -166,7 +175,7 @@ impl WhisperBatchASR {
.map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]))
.collect();
let wav = encode_wav_16k_mono(&samples);
let url = transcription_url(&self.base_url)?;
let url = transcription_url(&self.base_url, self.endpoint_path.as_deref())?;
let client = crate::net::http();

let request = match self.request_format {
Expand Down Expand Up @@ -275,6 +284,19 @@ impl WhisperBatchASR {
}

let json: serde_json::Value = resp.json().await.context("parse Whisper response")?;
if let Some(base_resp) = json.get("base_resp") {
let status_code = base_resp
.get("status_code")
.and_then(|c| c.as_i64())
.unwrap_or(0);
if status_code != 0 {
let msg = base_resp
.get("status_msg")
.and_then(|m| m.as_str())
.unwrap_or("unknown error");
anyhow::bail!("MiniMax API error {}: {}", status_code, msg);
}
}
if self.verbose_json {
// verbose_json:セグメントのメタデータで幻聴を除いた本文を組む。
// segments が無い応答では内部で従来どおり text にフォールバック。
Expand Down Expand Up @@ -402,16 +424,36 @@ pub fn split_pcm_by_duration(pcm: &[u8], max_chunk_duration_ms: Option<u64>) ->
pcm.chunks(bytes_per_chunk).collect()
}

fn transcription_url(base_url: &str) -> Result<String> {
fn transcription_url(base_url: &str, endpoint_path: Option<&str>) -> Result<String> {
let parsed = reqwest::Url::parse(base_url.trim()).context("parse Whisper base URL")?;
let mut url = parsed.clone();
let path = parsed.path().trim_end_matches('/');
let next_path = if path.ends_with("/audio/transcriptions") {
let next_path = if let Some(target) = endpoint_path {
let target = target.trim();
if path.ends_with(target) {
path.to_string()
} else {
format!(
"{path}{}",
if target.starts_with('/') {
target.to_string()
} else {
format!("/{target}")
}
)
}
} else if path.ends_with("/audio/transcriptions") || path.ends_with("/speech_to_text") {
path.to_string()
} else if path.ends_with("/audio") {
format!("{path}/transcriptions")
} else if let Some(prefix) = path.strip_suffix("/chat/completions") {
format!("{prefix}/audio/transcriptions")
} else if parsed
.host_str()
.map_or(false, |h| h.ends_with("minimaxi.com") || h.ends_with("minimax.chat") || h.contains("minimax"))
|| path.contains("minimax")
{
format!("{path}/speech_to_text")
} else {
format!("{path}/audio/transcriptions")
};
Expand Down Expand Up @@ -745,24 +787,54 @@ mod tests {
#[test]
fn transcription_url_accepts_base_audio_or_full_endpoint() {
assert_eq!(
transcription_url("https://open.bigmodel.cn/api/paas/v4").unwrap(),
transcription_url("https://open.bigmodel.cn/api/paas/v4", None).unwrap(),
"https://open.bigmodel.cn/api/paas/v4/audio/transcriptions"
);
assert_eq!(
transcription_url("https://open.bigmodel.cn/api/paas/v4/audio").unwrap(),
transcription_url("https://open.bigmodel.cn/api/paas/v4/audio", None).unwrap(),
"https://open.bigmodel.cn/api/paas/v4/audio/transcriptions"
);
assert_eq!(
transcription_url("https://open.bigmodel.cn/api/paas/v4/audio/transcriptions").unwrap(),
transcription_url(
"https://open.bigmodel.cn/api/paas/v4/audio/transcriptions",
None
)
.unwrap(),
"https://open.bigmodel.cn/api/paas/v4/audio/transcriptions"
);
assert_eq!(
transcription_url(
"https://open.bigmodel.cn/api/paas/v4/audio/transcriptions?api-version=2026-01-01"
"https://open.bigmodel.cn/api/paas/v4/audio/transcriptions?api-version=2026-01-01",
None
)
.unwrap(),
"https://open.bigmodel.cn/api/paas/v4/audio/transcriptions?api-version=2026-01-01"
);
// MiniMax & speech_to_text tests
assert_eq!(
transcription_url("https://api.minimaxi.com/v1", None).unwrap(),
"https://api.minimaxi.com/v1/speech_to_text"
);
assert_eq!(
transcription_url("https://api.minimax.chat/v1", None).unwrap(),
"https://api.minimax.chat/v1/speech_to_text"
);
assert_eq!(
transcription_url("https://custom-proxy.com/v1/speech_to_text", None).unwrap(),
"https://custom-proxy.com/v1/speech_to_text"
);
assert_eq!(
transcription_url("https://custom-proxy.com/v1", Some("/speech_to_text")).unwrap(),
"https://custom-proxy.com/v1/speech_to_text"
);
assert_eq!(
transcription_url(
"https://custom-proxy.com/v1/speech_to_text",
Some("/speech_to_text")
)
.unwrap(),
"https://custom-proxy.com/v1/speech_to_text"
);
}

#[test]
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/crates/openless-core/src/cloud_providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub const SHARED_CLOUD_ASR_PROVIDER_TYPES: &[&str] = &[
"openrouter",
"orcarouter",
"zenmux",
"minimax",
"openai-compatible",
"xiaomi-mimo-asr",
"iflytek",
Expand Down Expand Up @@ -539,6 +540,9 @@ async fn build_cloud_transcription_session(
),
)
.with_request_format(crate::provider_rules::whisper_request_format(provider_type));
if provider_type == "minimax" {
provider = provider.with_endpoint_path("/speech_to_text");
}
if crate::provider_rules::whisper_uses_hotwords(provider_type) {
provider = provider.with_hotwords(context.polish.hotwords.clone());
}
Expand Down
26 changes: 25 additions & 1 deletion openless-all/app/crates/openless-core/src/provider_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const ASR_PROVIDER_TYPES: &[(&str, &str)] = &[
("siliconflow", "asrSiliconflow"),
("stepfun", "asrStepfun"),
("zhipu", "asrZhipu"),
("minimax", "asrMinimax"),
("groq", "asrGroq"),
("whisper", "asrWhisper"),
("openrouter", "asrOpenrouter"),
Expand Down Expand Up @@ -353,6 +354,7 @@ fn static_models(kind: ProviderKind, provider_type: &str) -> &'static [&'static
(ProviderKind::Asr, "xiaomi-mimo-asr") => &[crate::asr::mimo::DEFAULT_MODEL],
(ProviderKind::Asr, "bailian-fun-asr-flash") => DASHSCOPE_MODELS,
(ProviderKind::Asr, "elevenlabs") => &[crate::asr::elevenlabs::DEFAULT_MODEL],
(ProviderKind::Asr, "minimax") => &["asr-1.0"],
(ProviderKind::Llm, crate::polish::CODEX_OAUTH_PROVIDER_ID) => &[
crate::polish::CODEX_DEFAULT_MODEL,
"gpt-5.3-codex",
Expand Down Expand Up @@ -555,6 +557,7 @@ pub fn default_asr_endpoint(provider_type: &str) -> Option<&'static str> {
"siliconflow" => Some("https://api.siliconflow.cn/v1"),
"stepfun" => Some("https://api.stepfun.com/v1"),
"zhipu" => Some("https://open.bigmodel.cn/api/paas/v4"),
"minimax" => Some("https://api.minimaxi.com/v1"),
"groq" => Some("https://api.groq.com/openai/v1"),
"whisper" => Some("https://api.openai.com/v1"),
"openrouter" => Some("https://openrouter.ai/api/v1"),
Expand All @@ -574,6 +577,7 @@ pub fn default_asr_model(provider_type: &str) -> Option<&'static str> {
"siliconflow" => Some("FunAudioLLM/SenseVoiceSmall"),
"stepfun" => Some("stepaudio-2.5-asr"),
"zhipu" => Some("glm-asr-2512"),
"minimax" => Some("asr-1.0"),
"groq" => Some("whisper-large-v3-turbo"),
"whisper" => Some("whisper-1"),
"openrouter" => Some("openai/whisper-large-v3-turbo"),
Expand Down Expand Up @@ -787,7 +791,7 @@ pub fn is_tencent_cloud_provider(id: &str) -> bool {
pub fn is_whisper_compatible_provider(id: &str) -> bool {
matches!(
id,
"whisper" | "siliconflow" | "zhipu" | "groq" | "openrouter" | "stepfun" | "zenmux"
"whisper" | "siliconflow" | "zhipu" | "groq" | "openrouter" | "stepfun" | "zenmux" | "minimax"
) || id == OPENAI_COMPATIBLE_ASR_PROVIDER_ID
}

Expand Down Expand Up @@ -1259,6 +1263,26 @@ mod tests {
assert!(llm.supported_request_formats.is_empty());
}

#[test]
fn minimax_asr_supplies_defaults_and_whisper_compatibility() {
assert!(crate::cloud_providers::SHARED_CLOUD_ASR_PROVIDER_TYPES.contains(&"minimax"));
let asr = provider_descriptor(ProviderKind::Asr, "minimax").unwrap();
assert_eq!(asr.label_key, "asrMinimax");
assert_eq!(
asr.default_endpoint.as_deref(),
Some("https://api.minimaxi.com/v1")
);
assert_eq!(asr.default_model.as_deref(), Some("asr-1.0"));
assert_eq!(asr.static_models, vec!["asr-1.0"]);
assert_eq!(asr.auth_requirement, AuthRequirement::ApiKey);
assert_eq!(asr.validation_probe, ValidationProbe::AsrSilence);
assert!(is_whisper_compatible_provider("minimax"));
assert_eq!(
active_asr_provider_kind("minimax"),
ActiveAsrProviderKind::WhisperCompatible
);
}

#[test]
fn descriptors_are_the_single_source_for_defaults_auth_and_probes() {
let compatible = provider_descriptor(ProviderKind::Asr, "openai-compatible").unwrap();
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1331,6 +1331,7 @@ export const de: typeof zhCN = {
asrSiliconflow: 'SiliconFlow SenseVoice',
asrStepfun: 'StepFun StepAudio ASR',
asrZhipu: 'Zhipu GLM-ASR',
asrMinimax: 'MiniMax ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper (kompatibel)',
asrOpenrouter: 'OpenRouter Whisper',
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1297,6 +1297,7 @@ export const en: typeof zhCN = {
asrSiliconflow: 'SiliconFlow SenseVoice',
asrStepfun: 'StepFun StepAudio ASR',
asrZhipu: 'Zhipu GLM-ASR',
asrMinimax: 'MiniMax ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper (compatible)',
asrOpenrouter: 'OpenRouter Whisper',
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,7 @@ export const es: typeof zhCN = {
asrSiliconflow: 'SiliconFlow SenseVoice',
asrStepfun: 'StepFun StepAudio ASR',
asrZhipu: 'Zhipu GLM-ASR',
asrMinimax: 'MiniMax ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper (compatible)',
asrOpenrouter: 'OpenRouter Whisper',
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1340,6 +1340,7 @@ export const fr: typeof zhCN = {
asrSiliconflow: 'SiliconFlow SenseVoice',
asrStepfun: 'StepFun StepAudio ASR',
asrZhipu: 'Zhipu GLM-ASR',
asrMinimax: 'MiniMax ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper (compatible)',
asrOpenrouter: 'OpenRouter Whisper',
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1284,6 +1284,7 @@ export const ja: typeof zhCN = {
asrSiliconflow: 'SiliconFlow SenseVoice',
asrStepfun: 'StepFun StepAudio ASR',
asrZhipu: 'Zhipu GLM-ASR',
asrMinimax: 'MiniMax ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(互換)',
asrOpenrouter: 'OpenRouter Whisper',
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1276,6 +1276,7 @@ export const ko: typeof zhCN = {
asrSiliconflow: 'SiliconFlow SenseVoice',
asrStepfun: 'StepFun StepAudio ASR',
asrZhipu: 'Zhipu GLM-ASR',
asrMinimax: 'MiniMax ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(호환)',
asrOpenrouter: 'OpenRouter Whisper',
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1235,6 +1235,7 @@ export const zhCN = {
asrSiliconflow: '硅基流动 SenseVoice',
asrStepfun: '阶跃星辰 StepAudio',
asrZhipu: '智谱 GLM-ASR',
asrMinimax: 'MiniMax ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(兼容)',
asrOpenrouter: 'OpenRouter Whisper',
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,7 @@ export const zhTW: typeof zhCN = {
asrSiliconflow: '硅基流動 SenseVoice',
asrStepfun: '階躍星辰 StepAudio',
asrZhipu: '智譜 GLM-ASR',
asrMinimax: 'MiniMax ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(兼容)',
asrOpenrouter: 'OpenRouter Whisper',
Expand Down
Loading