feat(provider): 增加腾讯云实时语音处理链路 (#1020)

* feat: add Tencent TokenHub and realtime ASR

Rebase onto Core 2.0: Hunyuan realtime ASR and TokenHub polish now live
in openless-core with the shared provider registry, credentials, and
cloud transcription session lifecycle.

* fix: harden Tencent ASR and TokenHub

---------

Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
kevinlinandChris233 authored and GitHub committed 2026-09-09 23:39:11 +08:00
1 parent 5113eaf245
commit bf1237c6b1
27 files changed
+1816 -35

No files matched your search

@@ -8,6 +8,7 @@ pub mod mimo;
pub mod pcm;
pub mod qwen_realtime;
pub mod stepfun_realtime;
pub mod tencent_cloud;
pub mod volcengine;
pub mod wav;
pub mod whisper;
@@ -20,6 +21,7 @@ pub use elevenlabs::ElevenLabsBatchASR;
pub use mimo::MimoBatchASR;
pub use qwen_realtime::{Qwen3RealtimeASR, Qwen3RealtimeCredentials};
pub use stepfun_realtime::{StepfunRealtimeASR, StepfunRealtimeCredentials};
pub use tencent_cloud::{TencentCloudCredentials, TencentCloudStreamingASR};
pub use volcengine::{VolcengineCredentials, VolcengineStreamingASR};
pub use whisper::WhisperBatchASR;
pub use xfyun::{XfyunCredentials, XfyunStreamingASR};
@@ -0,0 +1,1000 @@
//! 腾讯云实时语音识别 WebSocket 客户端。
//!
//! 官方文档:https://cloud.tencent.com/document/api/1093/48982
//!
//! 协议要点:
//! - 端点:`wss://asr.cloud.tencent.com/asr/v2/<appid>`;
//! - 鉴权:查询参数按字典序拼接后,以 SecretKey 做 HMAC-SHA1,再 Base64;
//! - 音频:16 kHz / 16-bit / 单声道 PCM,每 200ms 发送 6400 bytes;
//! - 收尾:发送文本消息 `{"type":"end"}`,等待 `final=1`;
//! - 默认模型:`Hy-ASR-3.0-preview`(腾讯云当前最新混元 ASR Preview)。
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use base64::Engine;
use futures_util::{SinkExt, StreamExt};
use hmac::{Hmac, Mac};
use parking_lot::Mutex as ParkingMutex;
use serde_json::Value;
use sha1::Sha1;
use tokio::net::TcpStream;
use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
use uuid::Uuid;
use crate::config::{TaskSpawner, TokioTaskSpawner};
use crate::ports::{TextStreamChunk, TextStreamSink};
use super::{AudioConsumer, RawTranscript};
pub const PROVIDER_ID: &str = "tencent-cloud";
pub const DEFAULT_ENDPOINT: &str = "wss://asr.cloud.tencent.com/asr/v2";
pub const DEFAULT_MODEL: &str = "Hy-ASR-3.0-preview";
pub const TARGET_AUDIO_CHUNK_BYTES: usize = 6_400;
const BYTES_PER_MS: u64 = 32;
const MAX_AUDIO_SEND_RATE: u64 = 2;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
const FRAME_SEND_TIMEOUT: Duration = Duration::from_secs(5);
const FINAL_RESULT_TIMEOUT: Duration = Duration::from_secs(15);
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
type WsSink = futures_util::stream::SplitSink<WsStream, Message>;
type SharedWriter = Arc<AsyncMutex<Option<WsSink>>>;
enum SendItem {
Audio(Vec<u8>),
End(oneshot::Sender<Result<(), TencentCloudASRError>>),
}
#[derive(Clone, Debug)]
pub struct TencentCloudCredentials {
pub app_id: String,
pub secret_id: String,
pub secret_key: String,
pub model: String,
}
impl TencentCloudCredentials {
pub fn auth_ok(&self) -> bool {
!self.app_id.trim().is_empty()
&& !self.secret_id.trim().is_empty()
&& !self.secret_key.trim().is_empty()
}
fn resolved_model(&self) -> &str {
let model = self.model.trim();
if model.is_empty() {
DEFAULT_MODEL
} else {
model
}
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum TencentCloudASRError {
#[error("credentials missing")]
CredentialsMissing,
#[error("connection failed")]
ConnectionFailed,
#[error("credentials rejected or service unavailable ({0})")]
AuthRejected(i64),
#[error("account unavailable ({0})")]
AccountUnavailable(i64),
#[error("rate limited")]
RateLimited,
#[error("service unavailable ({0})")]
ServiceUnavailable(i64),
#[error("request failed ({0})")]
TaskFailed(i64),
#[error("no final result")]
NoFinalResult,
#[error("final result timed out")]
FinalResultTimeout,
}
#[derive(Default)]
struct SyncState {
pending_audio: Vec<u8>,
bytes_sent: u64,
started: bool,
finished: bool,
final_tx: Option<oneshot::Sender<Result<RawTranscript, TencentCloudASRError>>>,
final_segments: BTreeMap<i64, String>,
partial_segments: BTreeMap<i64, String>,
last_result_text: String,
}
pub struct TencentCloudStreamingASR {
credentials: TencentCloudCredentials,
endpoint: String,
task_spawner: Arc<dyn TaskSpawner>,
state: ParkingMutex<SyncState>,
writer: SharedWriter,
final_rx: ParkingMutex<Option<oneshot::Receiver<Result<RawTranscript, TencentCloudASRError>>>>,
handshake_tx: ParkingMutex<Option<oneshot::Sender<Result<(), TencentCloudASRError>>>>,
send_tx: ParkingMutex<Option<mpsc::UnboundedSender<SendItem>>>,
partial_sink: ParkingMutex<Option<Arc<dyn TextStreamSink>>>,
}
impl TencentCloudStreamingASR {
pub fn new(credentials: TencentCloudCredentials) -> Self {
Self::with_task_spawner(credentials, Arc::new(TokioTaskSpawner))
}
pub fn with_task_spawner(
credentials: TencentCloudCredentials,
task_spawner: Arc<dyn TaskSpawner>,
) -> Self {
Self::with_endpoint(credentials, task_spawner, DEFAULT_ENDPOINT.to_string())
}
fn with_endpoint(
credentials: TencentCloudCredentials,
task_spawner: Arc<dyn TaskSpawner>,
endpoint: String,
) -> Self {
Self {
credentials,
endpoint,
task_spawner,
state: ParkingMutex::new(SyncState::default()),
writer: Arc::new(AsyncMutex::new(None)),
final_rx: ParkingMutex::new(None),
handshake_tx: ParkingMutex::new(None),
send_tx: ParkingMutex::new(None),
partial_sink: ParkingMutex::new(None),
}
}
pub fn set_partial_sink(&self, sink: Arc<dyn TextStreamSink>) {
*self.partial_sink.lock() = Some(sink);
}
pub fn connect_url(&self) -> String {
connect_url_at(
&self.endpoint,
&self.credentials,
chrono::Utc::now().timestamp(),
random_nonce(),
Uuid::new_v4().to_string(),
)
}
pub async fn open_session(self: &Arc<Self>) -> Result<(), TencentCloudASRError> {
if !self.credentials.auth_ok() {
return Err(TencentCloudASRError::CredentialsMissing);
}
let request = self
.connect_url()
.into_client_request()
.map_err(|_| TencentCloudASRError::ConnectionFailed)?;
let (ws, _) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request))
.await
.map_err(|_| TencentCloudASRError::ConnectionFailed)?
.map_err(|_| TencentCloudASRError::ConnectionFailed)?;
let (write, read) = ws.split();
*self.writer.lock().await = Some(write);
let (final_tx, final_rx) = oneshot::channel();
let (send_tx, mut send_rx) = mpsc::unbounded_channel::<SendItem>();
let (handshake_tx, handshake_rx) = oneshot::channel();
{
let mut state = self.state.lock();
*state = SyncState::default();
state.final_tx = Some(final_tx);
}
*self.final_rx.lock() = Some(final_rx);
*self.send_tx.lock() = Some(send_tx);
*self.handshake_tx.lock() = Some(handshake_tx);
let writer = Arc::clone(&self.writer);
let weak_self = Arc::downgrade(self);
let task_spawner = Arc::clone(&self.task_spawner);
task_spawner.spawn(Box::pin(async move {
let mut next_audio_send_at = None;
while let Some(item) = send_rx.recv().await {
let (result, done) = match item {
SendItem::Audio(chunk) => {
if let Some(send_at) = next_audio_send_at {
tokio::time::sleep_until(send_at).await;
}
let interval_ms =
(chunk.len() as u64 / BYTES_PER_MS / MAX_AUDIO_SEND_RATE).max(1);
let result = send_binary(&writer, chunk).await;
next_audio_send_at =
Some(tokio::time::Instant::now() + Duration::from_millis(interval_ms));
(result, None)
}
SendItem::End(done) => {
(send_text(&writer, r#"{"type":"end"}"#).await, Some(done))
}
};
if let Err(error) = &result {
log::error!("[tencent-cloud-asr] websocket send failed");
if let Some(this) = weak_self.upgrade() {
this.finish_error(error.clone());
}
}
if let Some(done) = done {
let _ = done.send(result.clone());
}
if result.is_err() {
break;
}
}
}));
let weak_self = Arc::downgrade(self);
let task_spawner = Arc::clone(&self.task_spawner);
task_spawner.spawn(Box::pin(async move {
let mut read = read;
while let Some(message) = read.next().await {
let Some(this) = weak_self.upgrade() else {
break;
};
match message {
Ok(Message::Text(text)) => {
if !this.handle_text_message(&text) {
break;
}
}
Ok(Message::Close(_)) => {
this.finish_on_close();
break;
}
Ok(_) => {}
Err(_) => {
log::error!("[tencent-cloud-asr] receive loop failed");
this.finish_with_partial_or_error(TencentCloudASRError::ConnectionFailed);
break;
}
}
}
}));
match tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake_rx).await {
Ok(Ok(Ok(()))) => Ok(()),
Ok(Ok(Err(error))) => {
self.cancel();
Err(error)
}
Ok(Err(_)) => {
self.cancel();
Err(TencentCloudASRError::ConnectionFailed)
}
Err(_) => {
self.cancel();
Err(TencentCloudASRError::ConnectionFailed)
}
}
}
pub async fn send_last_frame(&self) -> Result<(), TencentCloudASRError> {
self.flush_pending_audio();
let (done_tx, done_rx) = oneshot::channel();
let sender = self
.send_tx
.lock()
.as_ref()
.cloned()
.ok_or(TencentCloudASRError::ConnectionFailed)?;
sender
.send(SendItem::End(done_tx))
.map_err(|_| TencentCloudASRError::ConnectionFailed)?;
done_rx
.await
.map_err(|_| TencentCloudASRError::ConnectionFailed)?
}
pub async fn await_final_result(&self) -> Result<RawTranscript, TencentCloudASRError> {
self.await_final_result_with_timeout(FINAL_RESULT_TIMEOUT)
.await
}
pub async fn await_final_result_with_timeout(
&self,
timeout: Duration,
) -> Result<RawTranscript, TencentCloudASRError> {
let Some(receiver) = self.final_rx.lock().take() else {
return Err(TencentCloudASRError::NoFinalResult);
};
match tokio::time::timeout(timeout, receiver).await {
Ok(Ok(result)) => result,
Ok(Err(_)) => Err(TencentCloudASRError::NoFinalResult),
Err(_) => {
log::error!(
"[tencent-cloud-asr] final result timed out after {} ms",
timeout.as_millis()
);
self.cancel();
Err(TencentCloudASRError::FinalResultTimeout)
}
}
}
pub fn cancel(&self) {
self.state.lock().pending_audio.clear();
*self.handshake_tx.lock() = None;
*self.send_tx.lock() = None;
let writer = Arc::clone(&self.writer);
self.task_spawner.spawn(Box::pin(async move {
let mut guard = writer.lock().await;
if let Some(mut ws) = guard.take() {
let _ = ws.close().await;
}
}));
self.signal_error(TencentCloudASRError::NoFinalResult);
}
fn flush_pending_audio(&self) {
let leftover = {
let mut state = self.state.lock();
if state.pending_audio.is_empty() {
return;
}
let leftover = std::mem::take(&mut state.pending_audio);
state.bytes_sent += leftover.len() as u64;
leftover
};
self.enqueue_audio(leftover);
}
fn enqueue_audio(&self, chunk: Vec<u8>) {
let Some(sender) = self.send_tx.lock().as_ref().cloned() else {
return;
};
if sender.send(SendItem::Audio(chunk)).is_err() {
self.signal_error(TencentCloudASRError::ConnectionFailed);
}
}
fn handle_text_message(&self, text: &str) -> bool {
let value: Value = match serde_json::from_str(text) {
Ok(value) => value,
Err(error) => {
log::warn!("[tencent-cloud-asr] invalid json event: {error}");
return true;
}
};
let code = value.get("code").and_then(Value::as_i64).unwrap_or(-1);
if code != 0 {
let error = classify_server_error(code);
if let Some(sender) = self.handshake_tx.lock().take() {
let _ = sender.send(Err(error.clone()));
}
self.finish_error(error);
return false;
}
self.mark_started();
if let Some(result) = value.get("result") {
self.record_result(result);
}
if value.get("final").and_then(Value::as_i64) == Some(1) {
self.finish_success();
return false;
}
true
}
fn mark_started(&self) {
let mut state = self.state.lock();
if !state.started {
state.started = true;
}
drop(state);
if let Some(sender) = self.handshake_tx.lock().take() {
let _ = sender.send(Ok(()));
}
}
fn record_result(&self, result: &Value) {
let Some(text) = result.get("voice_text_str").and_then(Value::as_str) else {
return;
};
let text = text.trim();
if text.is_empty() {
return;
}
let index = result.get("index").and_then(Value::as_i64).unwrap_or(0);
let slice_type = result
.get("slice_type")
.and_then(Value::as_i64)
.unwrap_or(1);
let mut state = self.state.lock();
state.last_result_text = text.to_string();
let mut delta = None;
if slice_type == 2 {
state.final_segments.insert(index, text.to_string());
state.partial_segments.remove(&index);
} else {
let previous = state
.partial_segments
.get(&index)
.map(String::as_str)
.unwrap_or("");
delta = text
.strip_prefix(previous)
.filter(|suffix| !suffix.is_empty())
.map(str::to_string);
state.partial_segments.insert(index, text.to_string());
}
drop(state);
if let Some(delta) = delta {
if let Some(sink) = self.partial_sink.lock().clone() {
let _ = sink.publish(TextStreamChunk {
text: delta,
offset: 0,
});
}
}
}
fn finish_on_close(&self) {
if let Some(sender) = self.handshake_tx.lock().take() {
let _ = sender.send(Err(TencentCloudASRError::ConnectionFailed));
return;
}
self.finish_with_partial_or_error(TencentCloudASRError::NoFinalResult);
}
fn finish_with_partial_or_error(&self, error: TencentCloudASRError) {
let has_text = {
let state = self.state.lock();
!state.last_result_text.trim().is_empty()
|| !state.final_segments.is_empty()
|| !state.partial_segments.is_empty()
};
if has_text {
self.finish_success();
} else {
self.finish_error(error);
}
}
fn finish_success(&self) {
let (sender, text, duration_ms) = {
let mut state = self.state.lock();
if state.finished {
return;
}
state.finished = true;
state.pending_audio.clear();
let mut segments = state.final_segments.clone();
for (index, text) in &state.partial_segments {
segments.entry(*index).or_insert_with(|| text.clone());
}
let text = if segments.is_empty() {
state.last_result_text.clone()
} else {
super::mimo::join_transcript_chunks(
&segments.into_values().collect::<Vec<String>>(),
)
};
(state.final_tx.take(), text, state.bytes_sent / BYTES_PER_MS)
};
*self.send_tx.lock() = None;
if let Some(sender) = sender {
let _ = sender.send(Ok(RawTranscript { text, duration_ms }));
}
self.close_writer();
}
fn signal_error(&self, error: TencentCloudASRError) {
let sender = {
let mut state = self.state.lock();
if state.finished {
return;
}
state.finished = true;
state.final_tx.take()
};
*self.send_tx.lock() = None;
if let Some(sender) = sender {
let _ = sender.send(Err(error));
}
}
fn finish_error(&self, error: TencentCloudASRError) {
if let Some(sender) = self.handshake_tx.lock().take() {
let _ = sender.send(Err(error.clone()));
}
self.signal_error(error);
self.close_writer();
}
fn close_writer(&self) {
let writer = Arc::clone(&self.writer);
self.task_spawner.spawn(Box::pin(async move {
let mut guard = writer.lock().await;
if let Some(mut ws) = guard.take() {
let _ = ws.close().await;
}
}));
}
}
impl AudioConsumer for TencentCloudStreamingASR {
fn consume_pcm_chunk(&self, pcm: &[u8]) {
let chunks = {
let mut state = self.state.lock();
if !state.started || state.finished {
return;
}
state.pending_audio.extend_from_slice(pcm);
let mut chunks = Vec::new();
while state.pending_audio.len() >= TARGET_AUDIO_CHUNK_BYTES {
let chunk = state
.pending_audio
.drain(..TARGET_AUDIO_CHUNK_BYTES)
.collect::<Vec<u8>>();
state.bytes_sent += chunk.len() as u64;
chunks.push(chunk);
}
chunks
};
for chunk in chunks {
self.enqueue_audio(chunk);
}
}
}
async fn send_binary(writer: &SharedWriter, data: Vec<u8>) -> Result<(), TencentCloudASRError> {
let mut guard = writer.lock().await;
let Some(ws) = guard.as_mut() else {
return Err(TencentCloudASRError::ConnectionFailed);
};
tokio::time::timeout(FRAME_SEND_TIMEOUT, ws.send(Message::Binary(data)))
.await
.map_err(|_| TencentCloudASRError::ConnectionFailed)?
.map_err(|_| TencentCloudASRError::ConnectionFailed)
}
async fn send_text(writer: &SharedWriter, data: &str) -> Result<(), TencentCloudASRError> {
let mut guard = writer.lock().await;
let Some(ws) = guard.as_mut() else {
return Err(TencentCloudASRError::ConnectionFailed);
};
tokio::time::timeout(FRAME_SEND_TIMEOUT, ws.send(Message::Text(data.to_string())))
.await
.map_err(|_| TencentCloudASRError::ConnectionFailed)?
.map_err(|_| TencentCloudASRError::ConnectionFailed)
}
fn connect_url_at(
base_endpoint: &str,
credentials: &TencentCloudCredentials,
timestamp: i64,
nonce: u32,
voice_id: String,
) -> String {
let endpoint = format!(
"{}/{}",
base_endpoint.trim_end_matches('/'),
credentials.app_id.trim()
);
let parsed = url::Url::parse(&endpoint).expect("static Tencent Cloud endpoint parses");
let host_and_path = format!(
"{}{}",
parsed.host_str().expect("Tencent Cloud endpoint has host"),
parsed.path()
);
let params = BTreeMap::from([
(
"engine_model_type".to_string(),
credentials.resolved_model().to_string(),
),
("expired".to_string(), (timestamp + 86_400).to_string()),
("filter_dirty".to_string(), "0".to_string()),
("filter_empty_result".to_string(), "1".to_string()),
("filter_modal".to_string(), "1".to_string()),
("filter_punc".to_string(), "0".to_string()),
("needvad".to_string(), "1".to_string()),
("nonce".to_string(), nonce.to_string()),
(
"secretid".to_string(),
credentials.secret_id.trim().to_string(),
),
("timestamp".to_string(), timestamp.to_string()),
("voice_format".to_string(), "1".to_string()),
("voice_id".to_string(), voice_id),
]);
let query = query_string(&params);
let signing_text = format!("{host_and_path}?{query}");
let signature = compute_signature(&signing_text, &credentials.secret_key);
format!("{endpoint}?{query}&signature={}", url_encode(&signature))
}
fn query_string(params: &BTreeMap<String, String>) -> String {
params
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<String>>()
.join("&")
}
fn url_encode(value: &str) -> String {
url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
}
pub fn compute_signature(signing_text: &str, secret_key: &str) -> String {
let mut mac = Hmac::<Sha1>::new_from_slice(secret_key.trim().as_bytes())
.expect("HMAC accepts any key length");
mac.update(signing_text.as_bytes());
base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes())
}
fn random_nonce() -> u32 {
let bytes = Uuid::new_v4().into_bytes();
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) % 1_000_000_000 + 1
}
fn classify_server_error(code: i64) -> TencentCloudASRError {
match code {
4002 | 4003 => TencentCloudASRError::AuthRejected(code),
4004 | 4005 => TencentCloudASRError::AccountUnavailable(code),
4006 => TencentCloudASRError::RateLimited,
4009 => TencentCloudASRError::ConnectionFailed,
5000..=5002 => TencentCloudASRError::ServiceUnavailable(code),
_ => TencentCloudASRError::TaskFailed(code),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
struct DelayedFirstSpawner {
spawned: AtomicUsize,
release: Arc<tokio::sync::Semaphore>,
}
impl TaskSpawner for DelayedFirstSpawner {
fn spawn(&self, task: futures_util::future::BoxFuture<'static, ()>) {
if self.spawned.fetch_add(1, Ordering::SeqCst) == 0 {
let release = Arc::clone(&self.release);
tokio::spawn(async move {
release.acquire_owned().await.unwrap().forget();
task.await;
});
} else {
tokio::spawn(task);
}
}
}
fn credentials() -> TencentCloudCredentials {
TencentCloudCredentials {
app_id: "1259220000".into(),
secret_id: "test-secret-id".into(),
secret_key: "key".into(),
model: DEFAULT_MODEL.into(),
}
}
#[test]
fn hmac_sha1_signature_matches_known_vector() {
assert_eq!(
compute_signature("The quick brown fox jumps over the lazy dog", "key"),
"3nybhbi3iqa8ino29wqQcBydtNk="
);
}
#[test]
fn connect_url_uses_sorted_signed_query_and_encodes_signature() {
let url = connect_url_at(
DEFAULT_ENDPOINT,
&credentials(),
1_700_000_000,
42,
"voice-id".into(),
);
assert!(url.starts_with(
"wss://asr.cloud.tencent.com/asr/v2/1259220000?engine_model_type=Hy-ASR-3.0-preview&expired=1700086400"
));
assert!(url.contains("&nonce=42&secretid=test-secret-id&timestamp=1700000000"));
assert!(url.contains("&voice_format=1&voice_id=voice-id&signature="));
assert!(!url.ends_with('='));
}
#[test]
fn stable_segments_replace_partials_and_keep_order() {
let asr = TencentCloudStreamingASR::new(credentials());
asr.record_result(&serde_json::json!({
"slice_type": 1,
"index": 1,
"voice_text_str": "世界"
}));
asr.record_result(&serde_json::json!({
"slice_type": 2,
"index": 0,
"voice_text_str": "你好"
}));
let state = asr.state.lock();
assert_eq!(
state.final_segments.get(&0).map(String::as_str),
Some("你好")
);
assert_eq!(
state.partial_segments.get(&1).map(String::as_str),
Some("世界")
);
}
#[test]
fn server_errors_are_classified_for_actions() {
assert!(matches!(
classify_server_error(4002),
TencentCloudASRError::AuthRejected(_)
));
assert!(matches!(
classify_server_error(4006),
TencentCloudASRError::RateLimited
));
assert!(matches!(
classify_server_error(5000),
TencentCloudASRError::ServiceUnavailable(5000)
));
}
#[test]
fn auth_ok_requires_all_three_keys() {
assert!(credentials().auth_ok());
assert!(!TencentCloudCredentials {
app_id: "app".into(),
secret_id: "id".into(),
secret_key: String::new(),
model: DEFAULT_MODEL.into(),
}
.auth_ok());
}
#[tokio::test]
async fn websocket_session_sends_all_audio_before_end_and_returns_final_text() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("ws://{}/asr/v2", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
ws.send(Message::Text(
serde_json::json!({ "code": 0, "message": "success" }).to_string(),
))
.await
.unwrap();
let first = ws.next().await.unwrap().unwrap();
let second = ws.next().await.unwrap().unwrap();
let end = ws.next().await.unwrap().unwrap();
assert!(matches!(first, Message::Binary(bytes) if bytes.len() == 6_400));
assert!(matches!(second, Message::Binary(bytes) if bytes.len() == 100));
assert_eq!(end, Message::Text(r#"{"type":"end"}"#.to_string()));
ws.send(Message::Text(
serde_json::json!({
"code": 0,
"message": "success",
"result": { "slice_type": 2, "index": 0, "voice_text_str": "你好" }
})
.to_string(),
))
.await
.unwrap();
ws.send(Message::Text(
serde_json::json!({ "code": 0, "message": "success", "final": 1 }).to_string(),
))
.await
.unwrap();
});
let asr = Arc::new(TencentCloudStreamingASR::with_endpoint(
credentials(),
Arc::new(TokioTaskSpawner),
endpoint,
));
asr.open_session().await.unwrap();
asr.consume_pcm_chunk(&vec![1; 6_500]);
asr.send_last_frame().await.unwrap();
let transcript = asr.await_final_result().await.unwrap();
assert_eq!(transcript.text, "你好");
assert_eq!(transcript.duration_ms, 203);
server.await.unwrap();
}
#[tokio::test]
async fn end_waits_for_queued_audio_even_when_writer_start_is_delayed() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("ws://{}/asr/v2", listener.local_addr().unwrap());
let (first_tx, mut first_rx) = oneshot::channel();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
ws.send(Message::Text(
serde_json::json!({ "code": 0, "message": "success" }).to_string(),
))
.await
.unwrap();
first_tx.send(ws.next().await.unwrap().unwrap()).unwrap();
assert!(matches!(
ws.next().await.unwrap().unwrap(),
Message::Binary(bytes) if bytes.len() == 100
));
assert_eq!(
ws.next().await.unwrap().unwrap(),
Message::Text(r#"{"type":"end"}"#.to_string())
);
ws.send(Message::Text(
serde_json::json!({ "code": 0, "message": "success", "final": 1 }).to_string(),
))
.await
.unwrap();
});
let release = Arc::new(tokio::sync::Semaphore::new(0));
let asr = Arc::new(TencentCloudStreamingASR::with_endpoint(
credentials(),
Arc::new(DelayedFirstSpawner {
spawned: AtomicUsize::new(0),
release: Arc::clone(&release),
}),
endpoint,
));
asr.open_session().await.unwrap();
asr.consume_pcm_chunk(&vec![1; 6_500]);
let finishing = {
let asr = Arc::clone(&asr);
tokio::spawn(async move { asr.send_last_frame().await })
};
assert!(
tokio::time::timeout(Duration::from_millis(1_800), &mut first_rx)
.await
.is_err(),
"end must not bypass audio queued on a delayed writer"
);
release.add_permits(1);
assert!(matches!(
tokio::time::timeout(Duration::from_secs(2), &mut first_rx)
.await
.unwrap()
.unwrap(),
Message::Binary(bytes) if bytes.len() == 6_400
));
finishing.await.unwrap().unwrap();
assert!(asr.await_final_result().await.unwrap().text.is_empty());
server.await.unwrap();
}
#[tokio::test]
async fn buffered_audio_replay_stays_within_tencent_rate_limit() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("ws://{}/asr/v2", listener.local_addr().unwrap());
let (elapsed_tx, elapsed_rx) = oneshot::channel();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
ws.send(Message::Text(
serde_json::json!({ "code": 0, "message": "success" }).to_string(),
))
.await
.unwrap();
let mut received_at = Vec::new();
for _ in 0..3 {
assert!(matches!(
ws.next().await.unwrap().unwrap(),
Message::Binary(bytes) if bytes.len() == TARGET_AUDIO_CHUNK_BYTES
));
received_at.push(tokio::time::Instant::now());
}
assert_eq!(
ws.next().await.unwrap().unwrap(),
Message::Text(r#"{"type":"end"}"#.to_string())
);
elapsed_tx
.send(received_at[2].duration_since(received_at[0]))
.unwrap();
ws.send(Message::Text(
serde_json::json!({ "code": 0, "message": "success", "final": 1 }).to_string(),
))
.await
.unwrap();
});
let asr = Arc::new(TencentCloudStreamingASR::with_endpoint(
credentials(),
Arc::new(TokioTaskSpawner),
endpoint,
));
asr.open_session().await.unwrap();
asr.consume_pcm_chunk(&vec![1; TARGET_AUDIO_CHUNK_BYTES * 3]);
asr.send_last_frame().await.unwrap();
asr.await_final_result().await.unwrap();
assert!(
elapsed_rx.await.unwrap() >= Duration::from_millis(180),
"buffered audio must be paced instead of sent as one burst"
);
server.await.unwrap();
}
#[tokio::test]
async fn websocket_rejection_does_not_expose_server_message() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("ws://{}/asr/v2", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
ws.send(Message::Text(
serde_json::json!({
"code": 4002,
"message": "SENSITIVE_SERVER_RESPONSE"
})
.to_string(),
))
.await
.unwrap();
});
let asr = Arc::new(TencentCloudStreamingASR::with_endpoint(
credentials(),
Arc::new(TokioTaskSpawner),
endpoint,
));
let error = asr.open_session().await.unwrap_err();
assert!(matches!(error, TencentCloudASRError::AuthRejected(4002)));
assert!(!error.to_string().contains("SENSITIVE_SERVER_RESPONSE"));
server.await.unwrap();
}
#[tokio::test]
async fn cancel_closes_the_socket_and_unblocks_waiters() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("ws://{}/asr/v2", listener.local_addr().unwrap());
let (closed_tx, closed_rx) = oneshot::channel();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
ws.send(Message::Text(
serde_json::json!({ "code": 0, "message": "success" }).to_string(),
))
.await
.unwrap();
let closed = matches!(
ws.next().await,
Some(Ok(Message::Close(_))) | None | Some(Err(_))
);
closed_tx.send(closed).unwrap();
});
let asr = Arc::new(TencentCloudStreamingASR::with_endpoint(
credentials(),
Arc::new(TokioTaskSpawner),
endpoint,
));
asr.open_session().await.unwrap();
asr.cancel();
assert!(tokio::time::timeout(Duration::from_secs(2), closed_rx)
.await
.unwrap()
.unwrap());
assert!(matches!(
asr.send_last_frame().await,
Err(TencentCloudASRError::ConnectionFailed)
));
assert!(matches!(
asr.await_final_result().await,
Err(TencentCloudASRError::NoFinalResult)
));
server.await.unwrap();
}
}
@@ -12,11 +12,13 @@ use std::time::{Duration, Instant};
use futures_util::future::BoxFuture;
use parking_lot::{Mutex, RwLock};
use crate::asr::tencent_cloud::TencentCloudASRError;
use crate::asr::{
BailianCredentials, BailianRealtimeASR, DashScopeMultimodalASR, DictionaryHotword,
ElevenLabsBatchASR, MimoBatchASR, Qwen3RealtimeASR, Qwen3RealtimeCredentials,
StepfunRealtimeASR, StepfunRealtimeCredentials, VolcengineCredentials, VolcengineStreamingASR,
WhisperBatchASR, XfyunCredentials, XfyunStreamingASR,
StepfunRealtimeASR, StepfunRealtimeCredentials, TencentCloudCredentials,
TencentCloudStreamingASR, VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR,
XfyunCredentials, XfyunStreamingASR,
};
use crate::config::{TaskSpawner, TokioTaskSpawner};
use crate::credentials::{
@@ -24,7 +26,8 @@ use crate::credentials::{
ASR_API_KEY_ACCOUNT, ASR_ENDPOINT_ACCOUNT, ASR_MODEL_ACCOUNT, ASR_VOCABULARY_ID_ACCOUNT,
LLM_API_KEY_ACCOUNT, LLM_ENDPOINT_ACCOUNT, LLM_EXTRA_HEADERS_ACCOUNT, LLM_TEMPERATURE_ACCOUNT,
OMNI_API_KEY_ACCOUNT, OMNI_ENDPOINT_ACCOUNT, OMNI_EXTRA_HEADERS_ACCOUNT, OMNI_MODEL_ACCOUNT,
OMNI_TEMPERATURE_ACCOUNT, VOLCENGINE_ACCESS_KEY_ACCOUNT, VOLCENGINE_API_KEY_ACCOUNT,
OMNI_TEMPERATURE_ACCOUNT, TENCENT_CLOUD_APP_ID_ACCOUNT, TENCENT_CLOUD_SECRET_ID_ACCOUNT,
TENCENT_CLOUD_SECRET_KEY_ACCOUNT, VOLCENGINE_ACCESS_KEY_ACCOUNT, VOLCENGINE_API_KEY_ACCOUNT,
VOLCENGINE_APP_KEY_ACCOUNT, VOLCENGINE_AUTH_MODE_ACCOUNT, VOLCENGINE_RESOURCE_ID_ACCOUNT,
XFYUN_API_KEY_ACCOUNT, XFYUN_APP_ID_ACCOUNT,
};
@@ -60,6 +63,7 @@ pub const SHARED_CLOUD_ASR_PROVIDER_TYPES: &[&str] = &[
"openai-compatible",
"xiaomi-mimo-asr",
"iflytek",
"tencent-cloud",
];
pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[
@@ -79,6 +83,7 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[
"minimax",
"stepfun",
"opencode",
"tencentTokenHub",
"custom",
"custom_responses",
"custom_messages",
@@ -119,6 +124,7 @@ enum CloudTranscriptionSessionKind {
QwenRealtime(Arc<Qwen3RealtimeASR>),
StepfunRealtime(Arc<StepfunRealtimeASR>),
Xfyun(Arc<XfyunStreamingASR>),
TencentCloud(Arc<TencentCloudStreamingASR>),
}
struct CloudTranscriptionSession {
@@ -169,6 +175,9 @@ impl AudioConsumer for CloudTranscriptionSession {
provider.consume_pcm_chunk(pcm)
}
CloudTranscriptionSessionKind::Xfyun(provider) => provider.consume_pcm_chunk(pcm),
CloudTranscriptionSessionKind::TencentCloud(provider) => {
provider.consume_pcm_chunk(pcm)
}
}
}
}
@@ -235,6 +244,16 @@ impl TranscriptionSession for CloudTranscriptionSession {
timeout_transcription(Duration::from_secs(120), provider.await_final_result())
.await?
}
CloudTranscriptionSessionKind::TencentCloud(provider) => {
provider
.send_last_frame()
.await
.map_err(map_tencent_asr_error)?;
provider
.await_final_result()
.await
.map_err(map_tencent_asr_error)?
}
};
Ok(TranscriptOutput {
text: transcript.text,
@@ -254,6 +273,7 @@ impl TranscriptionSession for CloudTranscriptionSession {
CloudTranscriptionSessionKind::QwenRealtime(provider) => provider.cancel(),
CloudTranscriptionSessionKind::StepfunRealtime(provider) => provider.cancel(),
CloudTranscriptionSessionKind::Xfyun(provider) => provider.cancel(),
CloudTranscriptionSessionKind::TencentCloud(provider) => provider.cancel(),
}
Box::pin(async { Ok(()) })
}
@@ -632,6 +652,62 @@ async fn build_cloud_transcription_session(
provider.open_session().await.map_err(map_asr_error)?;
(CloudTranscriptionSessionKind::Xfyun(provider), None)
}
ActiveAsrProviderKind::TencentCloud => {
let app_id = read_channel_credential(
credentials,
CredentialNamespace::Asr,
channel_id,
TENCENT_CLOUD_APP_ID_ACCOUNT,
)
.await?
.unwrap_or_default();
let secret_id = read_channel_credential(
credentials,
CredentialNamespace::Asr,
channel_id,
TENCENT_CLOUD_SECRET_ID_ACCOUNT,
)
.await?
.unwrap_or_default();
let secret_key = read_channel_credential(
credentials,
CredentialNamespace::Asr,
channel_id,
TENCENT_CLOUD_SECRET_KEY_ACCOUNT,
)
.await?
.unwrap_or_default();
require_configured(&app_id, "Tencent Cloud AppID")?;
require_configured(&secret_id, "Tencent Cloud SecretID")?;
require_configured(&secret_key, "Tencent Cloud SecretKey")?;
let model = read_channel_credential(
credentials,
CredentialNamespace::Asr,
channel_id,
ASR_MODEL_ACCOUNT,
)
.await?
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| crate::asr::tencent_cloud::DEFAULT_MODEL.to_string());
let provider = Arc::new(TencentCloudStreamingASR::with_task_spawner(
TencentCloudCredentials {
app_id,
secret_id,
secret_key,
model: model.clone(),
},
Arc::clone(&task_spawner),
));
provider.set_partial_sink(Arc::clone(&partials));
provider
.open_session()
.await
.map_err(map_tencent_asr_error)?;
(
CloudTranscriptionSessionKind::TencentCloud(provider),
Some(model),
)
}
};
Ok((kind, crate::AsrCallLabel::new(effective, label_model)))
}
@@ -676,6 +752,22 @@ fn map_asr_error(error: impl std::fmt::Display) -> BackendError {
)
}
fn map_tencent_asr_error(error: TencentCloudASRError) -> BackendError {
let retryable = matches!(
&error,
TencentCloudASRError::ConnectionFailed
| TencentCloudASRError::RateLimited
| TencentCloudASRError::ServiceUnavailable(_)
| TencentCloudASRError::NoFinalResult
| TencentCloudASRError::FinalResultTimeout
);
BackendError::new(
BackendErrorCode::Provider,
format!("Tencent Cloud ASR failed: {error}"),
)
.retryable(retryable)
}
pub struct SharedCloudTextPolisher {
credentials: Arc<dyn CredentialStore>,
active: Arc<Mutex<HashMap<SessionId, Arc<ProviderCancellation>>>>,
@@ -1868,6 +1960,35 @@ mod tests {
use super::*;
use crate::{InMemoryCredentialStore, ProviderInvocation, SecretValue};
#[test]
fn tencent_cloud_errors_keep_retryability_and_stable_public_messages() {
use crate::asr::tencent_cloud::TencentCloudASRError;
for error in [
TencentCloudASRError::ConnectionFailed,
TencentCloudASRError::RateLimited,
TencentCloudASRError::NoFinalResult,
TencentCloudASRError::FinalResultTimeout,
TencentCloudASRError::ServiceUnavailable(5000),
] {
let error = map_tencent_asr_error(error);
assert_eq!(error.code, BackendErrorCode::Provider);
assert!(error.retryable, "{error:?}");
assert!(error.details.is_none());
}
for error in [
TencentCloudASRError::CredentialsMissing,
TencentCloudASRError::AuthRejected(4002),
TencentCloudASRError::AccountUnavailable(4004),
TencentCloudASRError::TaskFailed(4001),
] {
let error = map_tencent_asr_error(error);
assert_eq!(error.code, BackendErrorCode::Provider);
assert!(!error.retryable, "{error:?}");
assert!(error.details.is_none());
}
}
struct IgnoreTextStreamSink;
impl TextStreamSink for IgnoreTextStreamSink {
@@ -68,6 +68,9 @@ pub const VOLCENGINE_AUTH_MODE_ACCOUNT: &str = "volcengine.auth_mode";
pub const VOLCENGINE_API_KEY_ACCOUNT: &str = "volcengine.api_key";
pub const XFYUN_APP_ID_ACCOUNT: &str = "xfyun.app_id";
pub const XFYUN_API_KEY_ACCOUNT: &str = "xfyun.api_key";
pub const TENCENT_CLOUD_APP_ID_ACCOUNT: &str = "tencent_cloud.app_id";
pub const TENCENT_CLOUD_SECRET_ID_ACCOUNT: &str = "tencent_cloud.secret_id";
pub const TENCENT_CLOUD_SECRET_KEY_ACCOUNT: &str = "tencent_cloud.secret_key";
pub const LLM_API_KEY_ACCOUNT: &str = "ark.api_key";
pub const LLM_MODEL_ACCOUNT: &str = "ark.model_id";
pub const LLM_ENDPOINT_ACCOUNT: &str = "ark.endpoint";
@@ -99,6 +99,9 @@ struct LegacyEntry {
advanced_config: Option<String>,
xfyun_app_id: Option<String>,
xfyun_api_key: Option<String>,
tencent_cloud_app_id: Option<String>,
tencent_cloud_secret_id: Option<String>,
tencent_cloud_secret_key: Option<String>,
temperature: Option<f64>,
extra_headers: Option<BTreeMap<String, String>>,
request_format: Option<String>,
@@ -127,6 +130,9 @@ impl Default for LegacyEntry {
advanced_config: None,
xfyun_app_id: None,
xfyun_api_key: None,
tencent_cloud_app_id: None,
tencent_cloud_secret_id: None,
tencent_cloud_secret_key: None,
temperature: None,
extra_headers: None,
request_format: None,
@@ -153,6 +159,9 @@ impl LegacyEntry {
&self.advanced_config,
&self.xfyun_app_id,
&self.xfyun_api_key,
&self.tencent_cloud_app_id,
&self.tencent_cloud_secret_id,
&self.tencent_cloud_secret_key,
&self.request_format,
&self.messages_thinking,
&self.max_tokens,
@@ -379,6 +388,15 @@ fn decode_entry(
(ASR_ADVANCED_CONFIG_ACCOUNT, entry.advanced_config),
(XFYUN_APP_ID_ACCOUNT, entry.xfyun_app_id),
(XFYUN_API_KEY_ACCOUNT, entry.xfyun_api_key),
(TENCENT_CLOUD_APP_ID_ACCOUNT, entry.tencent_cloud_app_id),
(
TENCENT_CLOUD_SECRET_ID_ACCOUNT,
entry.tencent_cloud_secret_id,
),
(
TENCENT_CLOUD_SECRET_KEY_ACCOUNT,
entry.tencent_cloud_secret_key,
),
]);
}
if let Some(account) = extra_headers {
@@ -432,6 +450,9 @@ pub fn read_legacy_accounts(
ASR_ADVANCED_CONFIG_ACCOUNT,
XFYUN_APP_ID_ACCOUNT,
XFYUN_API_KEY_ACCOUNT,
TENCENT_CLOUD_APP_ID_ACCOUNT,
TENCENT_CLOUD_SECRET_ID_ACCOUNT,
TENCENT_CLOUD_SECRET_KEY_ACCOUNT,
][..],
),
(
@@ -39,7 +39,7 @@ impl LlmRequestFormat {
}
pub fn selectable(provider: &str) -> bool {
!matches!(provider, "gemini" | "codex_oauth")
!matches!(provider, "gemini" | "codex_oauth" | "tencentTokenHub")
}
pub fn parse(value: &str) -> Result<Self, BackendError> {
@@ -531,6 +531,32 @@ mod tests {
.is_err());
}
#[tokio::test]
async fn tokenhub_is_fixed_to_chat_completions() {
let store = InMemoryCredentialStore::default();
store
.write(
CredentialKey::new(
CredentialNamespace::Llm,
Some("tokenhub".into()),
REQUEST_FORMAT_ACCOUNT,
)
.unwrap(),
SecretValue::new("messages"),
)
.await
.unwrap();
assert!(!LlmRequestFormat::selectable("tencentTokenHub"));
assert_eq!(
LlmProtocolConfig::load(&store, "tokenhub", "tencentTokenHub")
.await
.unwrap()
.format,
LlmRequestFormat::ChatCompletions
);
}
#[test]
fn urls_and_auth_follow_format_without_losing_gateway_paths() {
for descriptor in crate::provider_rules::provider_descriptors(crate::ProviderKind::Llm) {
@@ -189,6 +189,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool {
| "minimax"
| "stepfun"
| "opencode"
| "tencentTokenHub"
)
}
@@ -1649,14 +1650,20 @@ pub(crate) fn apply_openai_compatible_thinking_control(
model: &str,
thinking_enabled: bool,
) {
if provider_id.trim() == "tencentTokenHub" {
apply_tokenhub_chat_thinking_control(body, model, thinking_enabled);
return;
}
// 优先按 provider_id 预设分派;custom / 未声明 provider 时回退到 base_url 兜底,
// 让用户用"自定义"preset 接入 MiniMax 也能正确下发 thinking 控制参数。
// Zen 是多模型网关,仅 DeepSeek 模型使用 DeepSeek 的思考参数。
let is_opencode = provider_id.trim() == "opencode"
|| (matches!(provider_id.trim(), "custom" | "custom_responses" | "custom_messages")
&& url::Url::parse(base_url.trim())
.ok()
.is_some_and(|url| url.host_str() == Some("opencode.ai")));
|| (matches!(
provider_id.trim(),
"custom" | "custom_responses" | "custom_messages"
) && url::Url::parse(base_url.trim())
.ok()
.is_some_and(|url| url.host_str() == Some("opencode.ai")));
let control = if is_opencode {
model
.trim()
@@ -1709,6 +1716,31 @@ pub(crate) fn apply_openai_compatible_thinking_control(
}
}
fn apply_tokenhub_chat_thinking_control(body: &mut Value, model: &str, enabled: bool) {
use crate::provider_rules::TokenHubChatModelPolicy::*;
match crate::provider_rules::tokenhub_chat_model_policy(model) {
Some(Hy3) => {
body["thinking"] = json!({ "type": if enabled { "enabled" } else { "disabled" } });
if enabled {
body["reasoning_effort"] = json!("medium");
}
}
Some(ToggleThinking) => {
body["thinking"] = json!({ "type": if enabled { "enabled" } else { "disabled" } });
}
Some(QwenThinking) => body["enable_thinking"] = json!(enabled),
Some(AdaptiveThinking) => {
body["thinking"] = json!({ "type": if enabled { "adaptive" } else { "disabled" } });
}
Some(AlwaysThinking) if enabled => {
body["thinking"] = json!({ "type": "enabled" });
}
Some(KimiK3) if enabled => body["reasoning_effort"] = json!("max"),
Some(AlwaysThinking | KimiK3 | Plain) | None => {}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ThinkingControl {
ReasoningEffort,
@@ -2143,8 +2175,12 @@ mod tests {
#[tokio::test]
async fn all_text_entrypoints_use_the_selected_protocol_over_http() {
for (format, (preset, prefix)) in LlmRequestFormat::ALL.into_iter().flat_map(|format| {
[("custom", "/gateway/v1"), ("opencode", "/zen/v1"), ("opencode", "/zen/go/v1")]
.map(|entry| (format, entry))
[
("custom", "/gateway/v1"),
("opencode", "/zen/v1"),
("opencode", "/zen/go/v1"),
]
.map(|entry| (format, entry))
}) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
@@ -2311,7 +2347,11 @@ mod tests {
("custom", "https://opencode.ai/zen/v1", true),
("custom_responses", "https://opencode.ai/zen/v1", true),
("custom_messages", "https://opencode.ai/zen/v1", true),
("custom", "https://OPENCODE.AI:443/zen/go/v1/chat/completions", true),
(
"custom",
"https://OPENCODE.AI:443/zen/go/v1/chat/completions",
true,
),
("custom", "https://opencode.ai.example/zen/v1", false),
("custom", "https://fakeopencode.ai/zen/v1", false),
("custom", "https://opencode.ai@example.com/zen/v1", false),
@@ -2323,22 +2363,37 @@ mod tests {
let provider = OpenAICompatibleLLMProvider::new(
OpenAICompatibleConfig::new(preset, "test", endpoint, "key", model)
.with_thinking_enabled(enabled)
.with_protocol(LlmProtocolConfig { format, ..Default::default() }),
.with_protocol(LlmProtocolConfig {
format,
..Default::default()
}),
);
let body = provider.chat_body(false, vec![json!({"role":"user","content":"hi"})]);
let body =
provider.chat_body(false, vec![json!({"role":"user","content":"hi"})]);
match format {
LlmRequestFormat::ChatCompletions if zen && model.starts_with("deepseek-") => {
assert_eq!(body["thinking"]["type"], if enabled { "enabled" } else { "disabled" });
LlmRequestFormat::ChatCompletions
if zen && model.starts_with("deepseek-") =>
{
assert_eq!(
body["thinking"]["type"],
if enabled { "enabled" } else { "disabled" }
);
}
LlmRequestFormat::Messages if enabled => {
assert_eq!(body["thinking"]["type"], "adaptive");
}
_ => assert!(body.get("thinking").is_none(), "{preset} {endpoint} {model} {format:?}"),
_ => assert!(
body.get("thinking").is_none(),
"{preset} {endpoint} {model} {format:?}"
),
}
assert!(body.get("reasoning_effort").is_none());
assert!(body.get("enable_thinking").is_none());
if format == LlmRequestFormat::Responses {
assert_eq!(body["reasoning"]["effort"], if enabled { "medium" } else { "low" });
assert_eq!(
body["reasoning"]["effort"],
if enabled { "medium" } else { "low" }
);
assert!(body.get("messages").is_none());
} else {
assert!(body.get("reasoning").is_none());
@@ -3224,6 +3279,88 @@ mod tests {
assert_eq!(body["thinking"]["type"], "adaptive");
}
#[test]
fn tokenhub_chat_thinking_matches_model_capabilities() {
let body = |model: &str, enabled: bool| {
OpenAICompatibleLLMProvider::new(
OpenAICompatibleConfig::new(
"tencentTokenHub",
"Tencent TokenHub",
"https://tokenhub.tencentmaas.com/v1",
"k",
model,
)
.with_thinking_enabled(enabled),
)
.chat_body(false, vec![json!({ "role": "user", "content": "hi" })])
};
for model in [
"hy3",
"hy4-preview",
"deepseek-v4-pro",
"deepseek/deepseek-v4-flash",
"glm-5.2",
"glm-5v-turbo",
"kimi-k2.6",
"kimi-k2.5",
] {
assert_eq!(body(model, true)["thinking"]["type"], "enabled", "{model}");
assert_eq!(
body(model, false)["thinking"]["type"],
"disabled",
"{model}"
);
}
assert_eq!(body("hy3", true)["reasoning_effort"], "medium");
assert_eq!(body("qwen3.5-plus", true)["enable_thinking"], true);
assert_eq!(body("qwen3.5-plus", false)["enable_thinking"], false);
assert_eq!(body("minimax-m3", true)["thinking"]["type"], "adaptive");
assert_eq!(body("minimax-m3", false)["thinking"]["type"], "disabled");
for model in ["glm-5.3", "kimi-k2.7-code", "minimax-m2.7"] {
assert!(body(model, false).get("thinking").is_none(), "{model}");
assert_eq!(body(model, true)["thinking"]["type"], "enabled");
}
let kimi_k3 = body("kimi-k3", true);
assert_eq!(kimi_k3["reasoning_effort"], "max");
assert!(kimi_k3.get("thinking").is_none());
assert!(body("kimi-k3", false).get("reasoning_effort").is_none());
for model in [
"hy-mt2-pro",
"hy-role",
"hunyuan-role-latest",
"mimo-v2.5-pro",
"future-model",
] {
let body = body(model, true);
assert!(body.get("thinking").is_none(), "{model}");
assert!(body.get("enable_thinking").is_none(), "{model}");
assert!(body.get("reasoning_effort").is_none(), "{model}");
}
}
#[test]
fn custom_tokenhub_endpoint_does_not_invent_model_policy() {
for base_url in [
"https://tokenhub.tencentmaas.com/v1/",
"https://api.lkeap.cloud.tencent.com/plan/v3",
] {
let provider = OpenAICompatibleLLMProvider::new(
OpenAICompatibleConfig::new("custom", "Custom", base_url, "k", "hy3")
.with_thinking_enabled(false),
);
let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]);
assert!(body.get("thinking").is_none());
assert!(body.get("reasoning_effort").is_none());
}
}
#[test]
fn openai_chat_body_falls_back_to_base_url_for_custom_minimax_endpoint() {
// 用 "custom" preset + 自定义 MiniMax base_url 接入时,base_url 兜底
@@ -21,6 +21,7 @@ const MIMO_PROVIDER_ID: &str = "xiaomi-mimo-asr";
const DASHSCOPE_MULTIMODAL_PROVIDER_ID: &str = "bailian-fun-asr-flash";
const ELEVENLABS_PROVIDER_ID: &str = "elevenlabs";
const XFYUN_PROVIDER_ID: &str = "iflytek";
const TENCENT_CLOUD_PROVIDER_ID: &str = "tencent-cloud";
const ASR_PROVIDER_TYPES: &[(&str, &str)] = &[
("volcengine", "asrVolcengine"),
@@ -39,6 +40,7 @@ const ASR_PROVIDER_TYPES: &[(&str, &str)] = &[
(OPENAI_COMPATIBLE_ASR_PROVIDER_ID, "asrOpenAiCompatible"),
("xiaomi-mimo-asr", "asrXiaomiMimo"),
(XFYUN_PROVIDER_ID, "asrIflytek"),
(TENCENT_CLOUD_PROVIDER_ID, "asrTencentCloud"),
("foundry-local-whisper", "asrFoundryLocalWhisper"),
("local-whisper", "asrLocalWhisper"),
("sherpa-onnx-local", "asrSherpaOnnxLocal"),
@@ -65,6 +67,7 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[
("minimax", "minimax"),
("stepfun", "stepfun"),
("opencode", "opencode"),
("tencentTokenHub", "tencentTokenHub"),
("custom", "customChatCompletions"),
("custom_responses", "customResponses"),
("custom_messages", "customMessages"),
@@ -112,6 +115,7 @@ pub enum AuthRequirement {
ApiKeyUnlessCustomEndpoint,
Volcengine,
Xfyun,
TencentCloud,
OAuth,
}
@@ -189,11 +193,14 @@ fn provider_descriptor_with_label(
match id.as_str() {
"volcengine" => AuthRequirement::Volcengine,
XFYUN_PROVIDER_ID => AuthRequirement::Xfyun,
TENCENT_CLOUD_PROVIDER_ID => AuthRequirement::TencentCloud,
OPENAI_COMPATIBLE_ASR_PROVIDER_ID => AuthRequirement::EndpointModelOptionalApiKey,
_ => AuthRequirement::ApiKey,
},
match id.as_str() {
"volcengine" | XFYUN_PROVIDER_ID => ValidationProbe::AsrSilenceAllowsNoFinal,
"volcengine" | XFYUN_PROVIDER_ID | TENCENT_CLOUD_PROVIDER_ID => {
ValidationProbe::AsrSilenceAllowsNoFinal
}
"stepfun" => ValidationProbe::StepfunNoSpeech,
DASHSCOPE_MULTIMODAL_PROVIDER_ID => ValidationProbe::AsrNonSilent,
_ => ValidationProbe::AsrSilence,
@@ -305,6 +312,7 @@ pub enum ActiveAsrProviderKind {
WhisperCompatible,
Volcengine,
Xfyun,
TencentCloud,
}
/// Non-secret facts read by a platform credential adapter. Core evaluates
@@ -321,6 +329,9 @@ pub struct CredentialConfiguration {
pub volcengine_resource_id: bool,
pub xfyun_app_id: bool,
pub xfyun_api_key: bool,
pub tencent_cloud_app_id: bool,
pub tencent_cloud_secret_id: bool,
pub tencent_cloud_secret_key: bool,
pub llm_api_key: bool,
pub llm_endpoint: bool,
pub llm_endpoint_matches_default: bool,
@@ -403,6 +414,11 @@ pub fn auth_requirement_satisfied(
}
AuthRequirement::Volcengine => volcengine_configured(configuration),
AuthRequirement::Xfyun => configuration.xfyun_app_id && configuration.xfyun_api_key,
AuthRequirement::TencentCloud => {
configuration.tencent_cloud_app_id
&& configuration.tencent_cloud_secret_id
&& configuration.tencent_cloud_secret_key
}
AuthRequirement::OAuth => configuration.codex_oauth && model,
}
}
@@ -418,7 +434,8 @@ pub fn api_key_required(
match descriptor.auth_requirement {
AuthRequirement::None
| AuthRequirement::EndpointModelOptionalApiKey
| AuthRequirement::OAuth => false,
| AuthRequirement::OAuth
| AuthRequirement::TencentCloud => false,
AuthRequirement::ApiKeyUnlessCustomEndpoint => {
let Some(endpoint) = configured_endpoint.filter(|value| !value.trim().is_empty())
else {
@@ -480,6 +497,7 @@ pub fn default_asr_model(provider_type: &str) -> Option<&'static str> {
"orcarouter" => Some(crate::asr::mimo::ORCAROUTER_DEFAULT_MODEL),
"zenmux" => Some(crate::asr::whisper::ZENMUX_DEFAULT_MODEL),
"xiaomi-mimo-asr" => Some(crate::asr::mimo::DEFAULT_MODEL),
TENCENT_CLOUD_PROVIDER_ID => Some(crate::asr::tencent_cloud::DEFAULT_MODEL),
_ => None,
}
}
@@ -501,6 +519,7 @@ pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> {
"codingPlanX" => Some("https://api.codingplanx.ai/v1"),
"minimax" => Some("https://api.minimaxi.com/v1"),
"stepfun" => Some("https://api.stepfun.com/v1"),
"tencentTokenHub" => Some("https://tokenhub.tencentmaas.com/v1"),
_ => None,
}
}
@@ -521,10 +540,77 @@ pub fn default_llm_model(provider_type: &str) -> Option<&'static str> {
"codingPlanX" => Some("gpt-5-mini"),
"minimax" => Some("MiniMax-M3"),
"stepfun" => Some("step-1o-turbo-vision"),
"tencentTokenHub" => Some("hy3"),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TokenHubChatModelPolicy {
Hy3,
ToggleThinking,
QwenThinking,
AdaptiveThinking,
AlwaysThinking,
KimiK3,
Plain,
}
pub(crate) fn tokenhub_chat_model_policy(model: &str) -> Option<TokenHubChatModelPolicy> {
use TokenHubChatModelPolicy::*;
// ponytail: /models lacks capability metadata; keep this one family classifier until it does.
let model = model.trim().to_ascii_lowercase();
let policy = if matches!(model.as_str(), "hy3" | "hy3-preview") {
Hy3
} else if model.starts_with("qwen3.5-") {
QwenThinking
} else if model == "minimax-m3" {
AdaptiveThinking
} else if model == "kimi-k3" {
KimiK3
} else if matches!(
model.as_str(),
"glm-5.3" | "glm-5.3-flash" | "minimax-m2.7" | "minimax-m2.5"
) || model.starts_with("kimi-k2.7-code")
{
AlwaysThinking
} else if model.starts_with("hy4-")
|| model.starts_with("deepseek-")
|| model.starts_with("deepseek/")
|| matches!(
model.as_str(),
"glm-5.2"
| "glm-5.1"
| "glm-5"
| "glm-5-turbo"
| "glm-5v-turbo"
| "kimi-k2.6"
| "kimi-k2.5"
)
{
ToggleThinking
} else {
let minimax_language = model
.strip_prefix("minimax-m")
.and_then(|suffix| suffix.chars().next())
.is_some_and(|character| character.is_ascii_digit());
if model.starts_with("hy-mt2-")
|| model.starts_with("hy-role")
|| model.starts_with("hunyuan-role")
|| model.starts_with("glm-")
|| model.starts_with("kimi-")
|| minimax_language
|| model.starts_with("mimo-v")
{
Plain
} else {
return None;
}
};
Some(policy)
}
pub fn default_omni_endpoint(provider_type: &str) -> Option<&'static str> {
match provider_type {
"openai" => Some("https://api.openai.com/v1"),
@@ -576,6 +662,7 @@ pub fn active_asr_provider_kind(id: &str) -> ActiveAsrProviderKind {
DASHSCOPE_MULTIMODAL_PROVIDER_ID => ActiveAsrProviderKind::DashScopeMultimodal,
ELEVENLABS_PROVIDER_ID => ActiveAsrProviderKind::ElevenLabs,
XFYUN_PROVIDER_ID => ActiveAsrProviderKind::Xfyun,
TENCENT_CLOUD_PROVIDER_ID => ActiveAsrProviderKind::TencentCloud,
value if is_whisper_compatible_provider(value) => ActiveAsrProviderKind::WhisperCompatible,
_ => ActiveAsrProviderKind::Volcengine,
}
@@ -609,6 +696,10 @@ pub fn is_xfyun_provider(id: &str) -> bool {
id == XFYUN_PROVIDER_ID
}
pub fn is_tencent_cloud_provider(id: &str) -> bool {
id == TENCENT_CLOUD_PROVIDER_ID
}
pub fn is_whisper_compatible_provider(id: &str) -> bool {
matches!(
id,
@@ -1003,12 +1094,25 @@ mod tests {
assert!(crate::cloud_providers::SHARED_CLOUD_LLM_PROVIDER_TYPES.contains(&"opencode"));
let descriptor = provider_descriptor(ProviderKind::Llm, "opencode").unwrap();
assert_eq!(descriptor.label_key, "opencode");
assert_eq!(descriptor.default_endpoint.as_deref(), Some("https://opencode.ai/zen/v1"));
assert_eq!(descriptor.default_model.as_deref(), Some("deepseek-v4-flash"));
assert_eq!(descriptor.default_request_format, Some(LlmRequestFormat::ChatCompletions));
assert_eq!(
descriptor.default_endpoint.as_deref(),
Some("https://opencode.ai/zen/v1")
);
assert_eq!(
descriptor.default_model.as_deref(),
Some("deepseek-v4-flash")
);
assert_eq!(
descriptor.default_request_format,
Some(LlmRequestFormat::ChatCompletions)
);
assert_eq!(descriptor.supported_request_formats, LlmRequestFormat::ALL);
assert_eq!(descriptor.validation_probe, ValidationProbe::LlmText);
assert!(api_key_required(ProviderKind::Llm, "opencode", Some("https://opencode.ai/zen/v1/chat/completions/")));
assert!(api_key_required(
ProviderKind::Llm,
"opencode",
Some("https://opencode.ai/zen/v1/chat/completions/")
));
let mut configuration = CredentialConfiguration {
llm_endpoint: true,
llm_endpoint_matches_default: true,
@@ -1020,6 +1124,49 @@ mod tests {
assert!(llm_configured("opencode", &configuration));
}
#[test]
fn tencent_cloud_asr_and_tokenhub_supply_defaults_and_credentials() {
assert!(crate::cloud_providers::SHARED_CLOUD_ASR_PROVIDER_TYPES.contains(&"tencent-cloud"));
assert!(
crate::cloud_providers::SHARED_CLOUD_LLM_PROVIDER_TYPES.contains(&"tencentTokenHub")
);
let asr = provider_descriptor(ProviderKind::Asr, "tencent-cloud").unwrap();
assert_eq!(asr.label_key, "asrTencentCloud");
assert_eq!(
asr.default_model.as_deref(),
Some(crate::asr::tencent_cloud::DEFAULT_MODEL)
);
assert_eq!(asr.auth_requirement, AuthRequirement::TencentCloud);
assert_eq!(
asr.validation_probe,
ValidationProbe::AsrSilenceAllowsNoFinal
);
let mut configuration = CredentialConfiguration::default();
assert!(!auth_requirement_satisfied(&asr, &configuration));
configuration.tencent_cloud_app_id = true;
configuration.tencent_cloud_secret_id = true;
configuration.tencent_cloud_secret_key = true;
assert!(auth_requirement_satisfied(&asr, &configuration));
assert_eq!(
active_asr_provider_kind("tencent-cloud"),
ActiveAsrProviderKind::TencentCloud
);
let llm = provider_descriptor(ProviderKind::Llm, "tencentTokenHub").unwrap();
assert_eq!(llm.label_key, "tencentTokenHub");
assert_eq!(
llm.default_endpoint.as_deref(),
Some("https://tokenhub.tencentmaas.com/v1")
);
assert_eq!(llm.default_model.as_deref(), Some("hy3"));
assert_eq!(
llm.auth_requirement,
AuthRequirement::ApiKeyUnlessCustomEndpoint
);
assert_eq!(llm.default_request_format, None);
assert!(llm.supported_request_formats.is_empty());
}
#[test]
fn descriptors_are_the_single_source_for_defaults_auth_and_probes() {
let compatible = provider_descriptor(ProviderKind::Asr, "openai-compatible").unwrap();
@@ -418,7 +418,7 @@ fn validate_configuration(resolved: &ResolvedProvider) -> Result<(), BackendErro
) && api_key.trim().is_empty()
&& !matches!(
descriptor.auth_requirement,
AuthRequirement::Volcengine | AuthRequirement::Xfyun
AuthRequirement::Volcengine | AuthRequirement::Xfyun | AuthRequirement::TencentCloud
)
{
let label = match resolved.kind {
@@ -436,7 +436,10 @@ fn validate_configuration(resolved: &ResolvedProvider) -> Result<(), BackendErro
if model.is_none()
&& !matches!(
descriptor.auth_requirement,
AuthRequirement::None | AuthRequirement::Volcengine | AuthRequirement::Xfyun
AuthRequirement::None
| AuthRequirement::Volcengine
| AuthRequirement::Xfyun
| AuthRequirement::TencentCloud
)
{
return Err(invalid_request("provider model is not configured"));
@@ -450,7 +453,10 @@ fn validate_configuration(resolved: &ResolvedProvider) -> Result<(), BackendErro
if endpoint.is_none()
&& !matches!(
descriptor.auth_requirement,
AuthRequirement::None | AuthRequirement::Volcengine | AuthRequirement::Xfyun
AuthRequirement::None
| AuthRequirement::Volcengine
| AuthRequirement::Xfyun
| AuthRequirement::TencentCloud
)
{
return Err(provider_error("provider endpoint is not configured"));
@@ -650,6 +656,7 @@ async fn fetch_models(
.ok_or_else(|| provider_error("provider endpoint is not configured"))?;
let url = models_url(endpoint)?;
let is_gemini = resolved.provider_type == "gemini";
let tokenhub_chat_only = resolved.provider_type == "tencentTokenHub";
let orcarouter_filter = (resolved.provider_type == "orcarouter").then(|| {
let endpoint_type = match (resolved.kind, resolved.protocol.format) {
(ProviderKind::Llm, LlmRequestFormat::Responses) => "openai-response",
@@ -709,7 +716,12 @@ async fn fetch_models(
if response.body.len() > MODEL_LIST_MAX_BYTES {
return Err(provider_error("provider model response is too large"));
}
parse_model_list(&response.body, is_gemini, orcarouter_filter)
parse_model_list(
&response.body,
is_gemini,
orcarouter_filter,
tokenhub_chat_only,
)
}
#[derive(Clone, Copy)]
@@ -722,6 +734,7 @@ fn parse_model_list(
body: &[u8],
is_gemini: bool,
orcarouter_filter: Option<OrcaRouterCatalogFilter>,
tokenhub_chat_only: bool,
) -> Result<Vec<String>, BackendError> {
let value: serde_json::Value = serde_json::from_slice(body)
.map_err(|_| provider_error("provider model response is invalid JSON"))?;
@@ -756,6 +769,10 @@ fn parse_model_list(
.and_then(serde_json::Value::as_array)
.ok_or_else(|| provider_error("provider model response is missing data"))?
.iter()
.filter(|item| {
!tokenhub_chat_only
|| item.get("status").and_then(serde_json::Value::as_str) == Some("online")
})
.filter(|item| {
orcarouter_filter.is_none_or(|filter| {
let supports_endpoint = item
@@ -781,6 +798,10 @@ fn parse_model_list(
.filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
.map(str::trim)
.filter(|name| !name.is_empty())
.filter(|name| {
!tokenhub_chat_only
|| crate::provider_rules::tokenhub_chat_model_policy(name).is_some()
})
.filter(|name| {
if !orcarouter_filter.is_some_and(|filter| filter.kind == ProviderKind::Asr) {
return true;
@@ -1338,6 +1359,7 @@ mod tests {
br#"{"data":[{"id":"gpt-z"},{"id":""},{"id":"gpt-a"},{"id":"gpt-z"}]}"#,
false,
None,
false,
)
.unwrap();
assert_eq!(models, vec!["gpt-a", "gpt-z"]);
@@ -1349,6 +1371,7 @@ mod tests {
br#"{"models":[{"name":"models/gemini-z","supportedGenerationMethods":["generateContent"]},{"name":"models/embedding","supportedGenerationMethods":["embedContent"]},{"name":"gemini-a"}]}"#,
true,
None,
false,
)
.unwrap();
assert_eq!(models, vec!["gemini-a", "gemini-z"]);
@@ -1356,11 +1379,87 @@ mod tests {
#[test]
fn invalid_model_response_is_a_provider_error_without_body() {
let error = parse_model_list(br#"{"error":"secret-key"}"#, false, None).unwrap_err();
let error = parse_model_list(br#"{"error":"secret-key"}"#, false, None, false).unwrap_err();
assert_eq!(error.code, BackendErrorCode::Provider);
assert!(!format!("{error:?}").contains("secret-key"));
}
#[tokio::test]
async fn tokenhub_catalog_lists_only_online_language_models() {
let credentials = Arc::new(InMemoryCredentialStore::default());
let channel = create_channel_with_values(
&credentials,
ChannelKind::Llm,
"tencentTokenHub",
&[(LLM_API_KEY_ACCOUNT, "fixture-key")],
)
.await;
let transport = Arc::new(FakeProviderTransport::default());
transport.push_response(
200,
br#"{"object":"list","data":[
{"id":"hy3","status":"online"},
{"id":"hy4-preview","status":"online"},
{"id":"hy-mt2-pro","status":"online"},
{"id":"hy-role","status":"online"},
{"id":"hunyuan-role-latest","status":"online"},
{"id":"deepseek/deepseek-v4-flash","status":"online"},
{"id":"glm-5.3","status":"online"},
{"id":"kimi-k3","status":"online"},
{"id":"minimax-m3","status":"online"},
{"id":"qwen3.5-flash","status":"online"},
{"id":"mimo-v2.5-pro","status":"online"},
{"id":"deepseek-v4-pro","status":"pre-offline"},
{"id":"glm-future"},
{"id":"hy-image-v3","status":"online"},
{"id":"hy-video-v1.5","status":"online"},
{"id":"HY-3D-3.1","status":"online"},
{"id":"hy-asr-3.0-preview","status":"online"},
{"id":"minimax-speech-2.8-hd","status":"online"},
{"id":"kinfra-text-embedding-4b","status":"online"}
]}"#
.to_vec(),
);
let service = ProviderService::new_with_transport(
credentials,
Arc::new(crate::TokioTaskSpawner),
transport.clone(),
);
let result = service
.list_models(ProviderRequest {
kind: ProviderKind::Llm,
channel_id: Some(channel),
thinking_enabled: false,
})
.await
.unwrap();
assert_eq!(
result.models,
vec![
"deepseek/deepseek-v4-flash",
"glm-5.3",
"hunyuan-role-latest",
"hy-mt2-pro",
"hy-role",
"hy3",
"hy4-preview",
"kimi-k3",
"mimo-v2.5-pro",
"minimax-m3",
"qwen3.5-flash",
]
);
assert_eq!(
transport.requests()[0].headers,
vec![(
"Authorization".to_string(),
"Bearer fixture-key".to_string()
)]
);
}
#[tokio::test]
async fn orcarouter_catalog_uses_core_channel_defaults_and_filters_capabilities() {
let catalog = r#"{"data":[
+17 -1
View File
@@ -5,7 +5,8 @@ use futures_util::future::BoxFuture;
use openless_core::credentials::{
ASR_API_KEY_ACCOUNT, ASR_ENDPOINT_ACCOUNT, ASR_MODEL_ACCOUNT, LLM_API_KEY_ACCOUNT,
LLM_ENDPOINT_ACCOUNT, LLM_MODEL_ACCOUNT, OMNI_API_KEY_ACCOUNT, OMNI_ENDPOINT_ACCOUNT,
OMNI_MODEL_ACCOUNT, VOLCENGINE_ACCESS_KEY_ACCOUNT, VOLCENGINE_API_KEY_ACCOUNT,
OMNI_MODEL_ACCOUNT, TENCENT_CLOUD_APP_ID_ACCOUNT, TENCENT_CLOUD_SECRET_ID_ACCOUNT,
TENCENT_CLOUD_SECRET_KEY_ACCOUNT, VOLCENGINE_ACCESS_KEY_ACCOUNT, VOLCENGINE_API_KEY_ACCOUNT,
VOLCENGINE_APP_KEY_ACCOUNT, VOLCENGINE_AUTH_MODE_ACCOUNT, VOLCENGINE_RESOURCE_ID_ACCOUNT,
XFYUN_API_KEY_ACCOUNT, XFYUN_APP_ID_ACCOUNT,
};
@@ -395,6 +396,21 @@ impl CredentialStore for LinuxCredentialStore {
&active_asr_provider,
XFYUN_API_KEY_ACCOUNT,
),
tencent_cloud_app_id: has(
CredentialNamespace::Asr,
&active_asr_provider,
TENCENT_CLOUD_APP_ID_ACCOUNT,
),
tencent_cloud_secret_id: has(
CredentialNamespace::Asr,
&active_asr_provider,
TENCENT_CLOUD_SECRET_ID_ACCOUNT,
),
tencent_cloud_secret_key: has(
CredentialNamespace::Asr,
&active_asr_provider,
TENCENT_CLOUD_SECRET_KEY_ACCOUNT,
),
llm_api_key: has(
CredentialNamespace::Llm,
&active_llm_provider,
+67
View File
@@ -74,6 +74,7 @@ mod linux_app {
model: String,
auth_mode: String,
resource_id: String,
app_id: String,
// Secret inputs are intentionally write-only. Loading an editor never
// exposes an existing key into egui state, logs or screenshots.
primary_secret: String,
@@ -1632,6 +1633,7 @@ mod linux_app {
}
openless_core::AuthRequirement::Volcengine => "火山引擎凭据",
openless_core::AuthRequirement::Xfyun => "讯飞 AppID + API Key",
openless_core::AuthRequirement::TencentCloud => "腾讯云 AppID + SecretID + SecretKey",
openless_core::AuthRequirement::OAuth => "OAuth",
}
}
@@ -1711,6 +1713,19 @@ mod linux_app {
} else {
(String::new(), String::new())
};
let app_id = if descriptor.auth_requirement == openless_core::AuthRequirement::TencentCloud
{
read_provider_value(
&backend,
kind,
&channel.id,
openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT,
)
.await?
.unwrap_or_default()
} else {
String::new()
};
Ok(ProviderEditor {
kind,
name: channel.name.clone(),
@@ -1720,6 +1735,7 @@ mod linux_app {
model,
auth_mode,
resource_id,
app_id,
primary_secret: String::new(),
secondary_secret: String::new(),
})
@@ -1777,6 +1793,18 @@ mod linux_app {
secret_edit(ui, "AppID", &mut editor.primary_secret);
secret_edit(ui, "API Key", &mut editor.secondary_secret);
}
openless_core::AuthRequirement::TencentCloud => {
ui.horizontal(|ui| {
ui.label("腾讯云 AppID");
ui.text_edit_singleline(&mut editor.app_id);
});
secret_edit(ui, "SecretID", &mut editor.primary_secret);
secret_edit(ui, "SecretKey", &mut editor.secondary_secret);
ui.horizontal(|ui| {
ui.label("Model");
ui.text_edit_singleline(&mut editor.model);
});
}
_ => {
secret_edit(ui, "API Key(留空表示不修改)", &mut editor.primary_secret);
ui.horizontal(|ui| {
@@ -1913,6 +1941,40 @@ mod linux_app {
)
.await?;
}
openless_core::AuthRequirement::TencentCloud => {
write_or_remove_provider_value(
&backend,
editor.kind,
channel_id,
openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT,
&editor.app_id,
)
.await?;
write_secret_if_entered(
&backend,
editor.kind,
channel_id,
openless_core::credentials::TENCENT_CLOUD_SECRET_ID_ACCOUNT,
&editor.primary_secret,
)
.await?;
write_secret_if_entered(
&backend,
editor.kind,
channel_id,
openless_core::credentials::TENCENT_CLOUD_SECRET_KEY_ACCOUNT,
&editor.secondary_secret,
)
.await?;
write_or_remove_provider_value(
&backend,
editor.kind,
channel_id,
model_account(editor.kind),
&editor.model,
)
.await?;
}
_ => {
write_or_remove_provider_value(
&backend,
@@ -1958,6 +2020,11 @@ mod linux_app {
openless_core::credentials::XFYUN_APP_ID_ACCOUNT,
openless_core::credentials::XFYUN_API_KEY_ACCOUNT,
],
openless_core::AuthRequirement::TencentCloud => &[
openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT,
openless_core::credentials::TENCENT_CLOUD_SECRET_ID_ACCOUNT,
openless_core::credentials::TENCENT_CLOUD_SECRET_KEY_ACCOUNT,
],
_ => &[api_key_account(editor.kind)],
};
for account in accounts {
@@ -535,6 +535,9 @@ fn credential_configuration(
volcengine_resource_id: configured(&snap.volcengine_resource_id),
xfyun_app_id: configured(&snap.xfyun_app_id),
xfyun_api_key: configured(&snap.xfyun_api_key),
tencent_cloud_app_id: configured(&snap.tencent_cloud_app_id),
tencent_cloud_secret_id: configured(&snap.tencent_cloud_secret_id),
tencent_cloud_secret_key: configured(&snap.tencent_cloud_secret_key),
llm_api_key: configured(&snap.ark_api_key),
llm_endpoint: llm_endpoint.is_some(),
llm_endpoint_matches_default: llm_endpoint.is_some_and(|endpoint| {
@@ -799,7 +802,10 @@ fn account_provider_kind(account: CredentialAccount) -> CredentialProviderKind {
| CredentialAccount::AsrVocabularyId
| CredentialAccount::AsrAdvancedConfig
| CredentialAccount::XfyunAppId
| CredentialAccount::XfyunApiKey => CredentialProviderKind::Asr,
| CredentialAccount::XfyunApiKey
| CredentialAccount::TencentCloudAppId
| CredentialAccount::TencentCloudSecretId
| CredentialAccount::TencentCloudSecretKey => CredentialProviderKind::Asr,
CredentialAccount::OmniApiKey
| CredentialAccount::OmniEndpoint
| CredentialAccount::OmniModel => CredentialProviderKind::Omni,
@@ -831,6 +837,9 @@ fn parse_account(s: &str) -> Result<CredentialAccount, String> {
"asr.advanced_config" => Ok(CredentialAccount::AsrAdvancedConfig),
"xfyun.app_id" => Ok(CredentialAccount::XfyunAppId),
"xfyun.api_key" => Ok(CredentialAccount::XfyunApiKey),
"tencent_cloud.app_id" => Ok(CredentialAccount::TencentCloudAppId),
"tencent_cloud.secret_id" => Ok(CredentialAccount::TencentCloudSecretId),
"tencent_cloud.secret_key" => Ok(CredentialAccount::TencentCloudSecretKey),
"omni.api_key" => Ok(CredentialAccount::OmniApiKey),
"omni.endpoint" => Ok(CredentialAccount::OmniEndpoint),
"omni.model" => Ok(CredentialAccount::OmniModel),
@@ -349,6 +349,15 @@ struct CredsAsrEntry {
/// 讯飞实时语音转写 APIKey(接口密钥)。
#[serde(skip_serializing_if = "Option::is_none")]
xfyunApiKey: Option<String>,
/// 腾讯云账号 AppID(实时语音识别 WebSocket 路径参数)。
#[serde(skip_serializing_if = "Option::is_none")]
tencentCloudAppId: Option<String>,
/// 腾讯云 API 密钥 SecretID。
#[serde(skip_serializing_if = "Option::is_none")]
tencentCloudSecretId: Option<String>,
/// 腾讯云 API 密钥 SecretKey。
#[serde(skip_serializing_if = "Option::is_none")]
tencentCloudSecretKey: Option<String>,
}
impl CredsAsrEntry {
@@ -378,6 +387,17 @@ impl CredsAsrEntry {
&& self.advancedConfig.as_deref().unwrap_or("").is_empty()
&& self.xfyunAppId.as_deref().unwrap_or("").is_empty()
&& self.xfyunApiKey.as_deref().unwrap_or("").is_empty()
&& self.tencentCloudAppId.as_deref().unwrap_or("").is_empty()
&& self
.tencentCloudSecretId
.as_deref()
.unwrap_or("")
.is_empty()
&& self
.tencentCloudSecretKey
.as_deref()
.unwrap_or("")
.is_empty()
}
}
@@ -1611,6 +1631,11 @@ fn lookup_account(root: &CredsRoot, account: CredentialAccount) -> Option<String
CredentialAccount::AsrAdvancedConfig => asr.and_then(|e| pick(&e.advancedConfig)),
CredentialAccount::XfyunAppId => asr.and_then(|e| pick(&e.xfyunAppId)),
CredentialAccount::XfyunApiKey => asr.and_then(|e| pick(&e.xfyunApiKey)),
CredentialAccount::TencentCloudAppId => asr.and_then(|e| pick(&e.tencentCloudAppId)),
CredentialAccount::TencentCloudSecretId => asr.and_then(|e| pick(&e.tencentCloudSecretId)),
CredentialAccount::TencentCloudSecretKey => {
asr.and_then(|e| pick(&e.tencentCloudSecretKey))
}
CredentialAccount::OmniApiKey => omni.and_then(|e| pick(&e.apiKey)),
CredentialAccount::OmniEndpoint => omni.and_then(|e| pick(&e.baseURL)),
CredentialAccount::OmniModel => omni.and_then(|e| pick(&e.model)),
@@ -1720,6 +1745,18 @@ fn write_account(root: &mut CredsRoot, account: CredentialAccount, value: Option
let entry = root.providers.asr.entry(asr_id).or_default();
entry.xfyunApiKey = normalized;
}
CredentialAccount::TencentCloudAppId => {
let entry = root.providers.asr.entry(asr_id).or_default();
entry.tencentCloudAppId = normalized;
}
CredentialAccount::TencentCloudSecretId => {
let entry = root.providers.asr.entry(asr_id).or_default();
entry.tencentCloudSecretId = normalized;
}
CredentialAccount::TencentCloudSecretKey => {
let entry = root.providers.asr.entry(asr_id).or_default();
entry.tencentCloudSecretKey = normalized;
}
CredentialAccount::OmniApiKey => {
let entry = root.omni.providers.entry(omni_id).or_default();
entry.apiKey = normalized;
@@ -1760,6 +1797,12 @@ pub enum CredentialAccount {
XfyunAppId,
/// 讯飞实时语音转写 APIKey。
XfyunApiKey,
/// 腾讯云账号 AppID。
TencentCloudAppId,
/// 腾讯云 API 密钥 SecretID。
TencentCloudSecretId,
/// 腾讯云 API 密钥 SecretKey。
TencentCloudSecretKey,
/// 多模态(Omni)模型的 API Key。仅多模态管线读取。
OmniApiKey,
/// 多模态(Omni)模型的 Base URL。
@@ -1789,6 +1832,9 @@ impl CredentialAccount {
CredentialAccount::AsrAdvancedConfig => "asr.advanced_config",
CredentialAccount::XfyunAppId => "xfyun.app_id",
CredentialAccount::XfyunApiKey => "xfyun.api_key",
CredentialAccount::TencentCloudAppId => "tencent_cloud.app_id",
CredentialAccount::TencentCloudSecretId => "tencent_cloud.secret_id",
CredentialAccount::TencentCloudSecretKey => "tencent_cloud.secret_key",
CredentialAccount::OmniApiKey => "omni.api_key",
CredentialAccount::OmniEndpoint => "omni.endpoint",
CredentialAccount::OmniModel => "omni.model",
@@ -1812,6 +1858,9 @@ impl CredentialAccount {
CredentialAccount::AsrAdvancedConfig,
CredentialAccount::XfyunAppId,
CredentialAccount::XfyunApiKey,
CredentialAccount::TencentCloudAppId,
CredentialAccount::TencentCloudSecretId,
CredentialAccount::TencentCloudSecretKey,
CredentialAccount::OmniApiKey,
CredentialAccount::OmniEndpoint,
CredentialAccount::OmniModel,
@@ -1832,6 +1881,9 @@ pub struct CredentialsSnapshot {
pub asr_model: Option<String>,
pub xfyun_app_id: Option<String>,
pub xfyun_api_key: Option<String>,
pub tencent_cloud_app_id: Option<String>,
pub tencent_cloud_secret_id: Option<String>,
pub tencent_cloud_secret_key: Option<String>,
pub ark_api_key: Option<String>,
pub ark_model_id: Option<String>,
pub ark_endpoint: Option<String>,
@@ -1853,6 +1905,9 @@ fn credentials_snapshot(root: &CredsRoot, include_omni: bool) -> CredentialsSnap
asr_model: lookup_account(root, CredentialAccount::AsrModel),
xfyun_app_id: lookup_account(root, CredentialAccount::XfyunAppId),
xfyun_api_key: lookup_account(root, CredentialAccount::XfyunApiKey),
tencent_cloud_app_id: lookup_account(root, CredentialAccount::TencentCloudAppId),
tencent_cloud_secret_id: lookup_account(root, CredentialAccount::TencentCloudSecretId),
tencent_cloud_secret_key: lookup_account(root, CredentialAccount::TencentCloudSecretKey),
ark_api_key: lookup_account(root, CredentialAccount::ArkApiKey),
ark_model_id: lookup_account(root, CredentialAccount::ArkModelId),
ark_endpoint: lookup_account(root, CredentialAccount::ArkEndpoint),
+7
View File
@@ -1012,6 +1012,7 @@ export const en: typeof zhCN = {
minimax: 'MiniMax (M3)',
stepfun: 'StepFun',
opencode: 'OpenCode Zen',
tencentTokenHub: 'Tencent Cloud TokenHub',
customChatCompletions: "Custom · Chat Completions",
customResponses: "Custom · Responses",
customMessages: "Custom · Messages",
@@ -1030,6 +1031,7 @@ export const en: typeof zhCN = {
asrOpenAiCompatible: 'Custom OpenAI-compatible',
asrXiaomiMimo: 'Xiaomi MiMo ASR',
asrIflytek: 'iFlytek Realtime ASR',
asrTencentCloud: 'Tencent Cloud Hunyuan Realtime ASR',
asrElevenLabs: 'ElevenLabs Scribe',
asrSherpaOnnxLocal: 'Local sherpa-onnx (experimental)',
asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)',
@@ -1063,6 +1065,11 @@ export const en: typeof zhCN = {
xfyunAppIdLabel: 'AppID',
xfyunApiKeyLabel: 'API Key',
xfyunNote: 'Get AppID and API Key from the iFlytek Open Platform "Realtime ASR" service page. Audio is 16 kHz / 16-bit / mono PCM; the standard API has no hotword parameter (configure personalized hotwords in the iFlytek console), and the language defaults to Mandarin Chinese.',
tencentCloudAppIdLabel: 'Tencent Cloud AppID',
tencentCloudSecretIdLabel: 'SecretID',
tencentCloudSecretKeyLabel: 'SecretKey',
tencentCloudNote: 'Uses Tencent Cloud Speech Recognition API credentials. The default Hy-ASR-3.0-preview supports Chinese, English, and 20 dialects; Preview accepts only mono 16 kHz PCM up to 60 seconds and does not yet support context or hotword boosting.',
tencentTokenHubNote: 'Only online language models are listed. Some models always use reasoning; turning reasoning off keeps that model\'s fixed behavior.',
localAsrActiveNotice: 'Local ASR ({{name}}) is currently active. Switch or disable it from the Advanced tab.',
localAsrTakeoverHint: 'Once "{{name}}" is enabled, the ASR provider will be taken over.',
asrProviderTakenOver: 'A local engine is active. Pick another provider in the dropdown above to switch (the local engine stops automatically); manage local models under Services → Local models.',
+7
View File
@@ -1014,6 +1014,7 @@ export const ja: typeof zhCN = {
minimax: 'MiniMax(M3)',
stepfun: 'StepFun(階躍星辰)',
opencode: 'OpenCode Zen',
tencentTokenHub: 'Tencent Cloud TokenHub',
customChatCompletions: "カスタム · Chat Completions",
customResponses: "カスタム · Responses",
customMessages: "カスタム · Messages",
@@ -1032,6 +1033,7 @@ export const ja: typeof zhCN = {
asrOpenAiCompatible: 'カスタム OpenAI 互換',
asrXiaomiMimo: 'Xiaomi MiMo ASR',
asrIflytek: 'iFlytek リアルタイム音声認識',
asrTencentCloud: 'Tencent Cloud Hunyuan リアルタイム ASR',
asrElevenLabs: 'ElevenLabs Scribe',
asrSherpaOnnxLocal: 'ローカル sherpa-onnx(実験的)',
asrFoundryLocalWhisper: 'ローカル Whisper(Foundry Local)',
@@ -1065,6 +1067,11 @@ export const ja: typeof zhCN = {
xfyunAppIdLabel: 'AppID',
xfyunApiKeyLabel: 'API Key',
xfyunNote: 'iFlytek オープンプラットフォームの「リアルタイム音声認識」サービスページで AppID と API Key を取得します。音声は 16kHz / 16bit / モノラル PCM。標準版 API にホットワード引数はありません(iFlytek コンソールで個別ホットワードを設定)。言語はデフォルトで中国語(普通話)です。',
tencentCloudAppIdLabel: 'Tencent Cloud AppID',
tencentCloudSecretIdLabel: 'SecretID',
tencentCloudSecretKeyLabel: 'SecretKey',
tencentCloudNote: 'Tencent Cloud 音声認識 API の認証情報を使用します。既定の Hy-ASR-3.0-preview は中国語・英語・20 方言に対応します。Preview は 60 秒以内の 16kHz モノラル PCM のみ対応し、コンテキストとホットワード強化は未対応です。',
tencentTokenHubNote: '現在オンラインの言語モデルのみを表示します。一部のモデルは常に思考を使用し、思考をオフにしてもモデル固有の動作を維持します。',
localAsrActiveNotice: '現在「{{name}}」を使用中。「詳細設定」タブから切り替えまたは無効化できます。',
localAsrTakeoverHint: '「{{name}}」を有効化すると ASR プロバイダーが引き継がれます。',
asrProviderTakenOver: 'ローカルエンジンを使用中です。上のドロップダウンで別のプロバイダーを選ぶと切り替えられます(ローカルエンジンは自動的に停止します)。ローカルモデルは「サービス → ローカルモデル」で管理します。',
+7
View File
@@ -1014,6 +1014,7 @@ export const ko: typeof zhCN = {
minimax: 'MiniMax (M3)',
stepfun: 'StepFun',
opencode: 'OpenCode Zen',
tencentTokenHub: 'Tencent Cloud TokenHub',
customChatCompletions: "사용자 지정 · Chat Completions",
customResponses: "사용자 지정 · Responses",
customMessages: "사용자 지정 · Messages",
@@ -1032,6 +1033,7 @@ export const ko: typeof zhCN = {
asrOpenAiCompatible: '커스텀 OpenAI 호환',
asrXiaomiMimo: 'Xiaomi MiMo ASR',
asrIflytek: 'iFlytek 실시간 음성 인식',
asrTencentCloud: 'Tencent Cloud Hunyuan 실시간 ASR',
asrElevenLabs: 'ElevenLabs Scribe',
asrSherpaOnnxLocal: '로컬 sherpa-onnx(실험적)',
asrFoundryLocalWhisper: '로컬 Whisper(Foundry Local)',
@@ -1065,6 +1067,11 @@ export const ko: typeof zhCN = {
xfyunAppIdLabel: 'AppID',
xfyunApiKeyLabel: 'API Key',
xfyunNote: 'iFlytek 오픈 플랫폼 "실시간 음성 인식" 서비스 페이지에서 AppID와 API Key를 가져옵니다. 오디오는 16kHz/16bit/모노 PCM입니다. 표준 API에는 핫워드 매개변수가 없으며(iFlytek 콘솔에서 개별 핫워드 설정), 언어는 기본적으로 중국어(보통화)입니다.',
tencentCloudAppIdLabel: 'Tencent Cloud AppID',
tencentCloudSecretIdLabel: 'SecretID',
tencentCloudSecretKeyLabel: 'SecretKey',
tencentCloudNote: 'Tencent Cloud 음성 인식 API 자격 증명을 사용합니다. 기본 Hy-ASR-3.0-preview는 중국어·영어·20개 방언을 지원합니다. Preview는 60초 이내의 16kHz 모노 PCM만 지원하며, 컨텍스트와 핫워드 강화는 아직 지원하지 않습니다.',
tencentTokenHubNote: '현재 온라인인 언어 모델만 표시합니다. 일부 모델은 항상 추론을 사용하며, 추론을 꺼도 해당 모델의 고정 동작을 유지합니다.',
localAsrActiveNotice: '현재 "{{name}}" 사용 중. "고급" 탭에서 전환 또는 비활성화할 수 있습니다.',
localAsrTakeoverHint: '"{{name}}" 활성화 시 ASR 프로바이더가 인수됩니다.',
asrProviderTakenOver: '현재 로컬 엔진을 사용 중입니다. 위의 드롭다운에서 다른 공급자를 선택하면 전환됩니다(로컬 엔진은 자동으로 중지됨). 로컬 모델은 「서비스 → 로컬 모델」에서 관리합니다.',
+7
View File
@@ -1010,6 +1010,7 @@ export const zhCN = {
minimax: 'MiniMax(M3)',
stepfun: 'StepFun(阶跃星辰)',
opencode: 'OpenCode Zen',
tencentTokenHub: '腾讯云 TokenHub',
customChatCompletions: "自定义 · Chat Completions",
customResponses: "自定义 · Responses",
customMessages: "自定义 · Messages",
@@ -1028,6 +1029,7 @@ export const zhCN = {
asrOpenAiCompatible: '自定义 OpenAI 兼容',
asrXiaomiMimo: '小米 MiMo ASR',
asrIflytek: '讯飞实时语音转写',
asrTencentCloud: '腾讯云混元实时 ASR',
asrElevenLabs: 'ElevenLabs Scribe',
asrSherpaOnnxLocal: '本地 sherpa-onnx(实验性)',
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
@@ -1061,6 +1063,11 @@ export const zhCN = {
xfyunAppIdLabel: 'AppID',
xfyunApiKeyLabel: 'API Key',
xfyunNote: '在讯飞开放平台「实时语音转写」服务页获取 AppID 与 API Key。音频为 16kHz/16bit/单声道 PCM;标准版接口暂不支持热词参数(可在讯飞控制台配置个性化热词),语种默认中文普通话。',
tencentCloudAppIdLabel: '腾讯云 AppID',
tencentCloudSecretIdLabel: 'SecretID',
tencentCloudSecretKeyLabel: 'SecretKey',
tencentCloudNote: '使用腾讯云「语音识别」服务的 API 密钥。默认 Hy-ASR-3.0-preview 支持中英与 20 种方言;Preview 仅支持 60 秒以内的 16kHz 单声道 PCM,暂不支持上下文或热词增强。',
tencentTokenHubNote: '仅显示当前在线的语言模型。部分模型始终启用思考;关闭思考开关时将沿用该模型的固定行为。',
localAsrActiveNotice: '当前已启用「{{name}}」,可在「高级」中切换或禁用。',
localAsrTakeoverHint: '启动「{{name}}」后,ASR 提供商将被接管。',
asrProviderTakenOver: '当前用的是本地引擎,在上方下拉直接选其它供应商即可切换(本地引擎会自动停用);本地模型在「服务 → 本地模型」里管理。',
+7
View File
@@ -1012,6 +1012,7 @@ export const zhTW: typeof zhCN = {
minimax: 'MiniMax(M3)',
stepfun: 'StepFun(階躍星辰)',
opencode: 'OpenCode Zen',
tencentTokenHub: '騰訊雲 TokenHub',
customChatCompletions: "自訂 · Chat Completions",
customResponses: "自訂 · Responses",
customMessages: "自訂 · Messages",
@@ -1030,6 +1031,7 @@ export const zhTW: typeof zhCN = {
asrOpenAiCompatible: '自訂 OpenAI 相容',
asrXiaomiMimo: '小米 MiMo ASR',
asrIflytek: '訊飛即時語音轉寫',
asrTencentCloud: '騰訊雲混元即時 ASR',
asrElevenLabs: 'ElevenLabs Scribe',
asrSherpaOnnxLocal: '本地 sherpa-onnx(實驗性)',
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
@@ -1063,6 +1065,11 @@ export const zhTW: typeof zhCN = {
xfyunAppIdLabel: 'AppID',
xfyunApiKeyLabel: 'API Key',
xfyunNote: '在訊飛開放平台「即時語音轉寫」服務頁取得 AppID 與 API Key。音訊為 16kHz/16bit/單聲道 PCM;標準版介面暫不支援熱詞參數(可在訊飛控制台設定個人化熱詞),語種預設中文普通話。',
tencentCloudAppIdLabel: '騰訊雲 AppID',
tencentCloudSecretIdLabel: 'SecretID',
tencentCloudSecretKeyLabel: 'SecretKey',
tencentCloudNote: '使用騰訊雲「語音辨識」服務的 API 金鑰。預設 Hy-ASR-3.0-preview 支援中英與 20 種方言;Preview 僅支援 60 秒內的 16kHz 單聲道 PCM,暫不支援上下文或熱詞增強。',
tencentTokenHubNote: '僅顯示目前在線的語言模型。部分模型始終啟用思考;關閉思考開關時將沿用該模型的固定行為。',
localAsrActiveNotice: '當前已啓用「{{name}}」,可在「高級」中切換或停用。',
localAsrTakeoverHint: '啓動「{{name}}」後,ASR 提供商將被接管。',
asrProviderTakenOver: '目前使用的是本地引擎,在上方下拉直接選其他供應商即可切換(本地引擎會自動停用);本地模型在「服務 → 本地模型」裡管理。',
@@ -15,6 +15,7 @@
{"kind":"llm","providerType":"minimax","labelKey":"minimax","defaultEndpoint":"https://api.minimaxi.com/v1","defaultModel":"MiniMax-M3","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]},
{"kind":"llm","providerType":"stepfun","labelKey":"stepfun","defaultEndpoint":"https://api.stepfun.com/v1","defaultModel":"step-1o-turbo-vision","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]},
{"kind":"llm","providerType":"opencode","labelKey":"opencode","defaultEndpoint":"https://opencode.ai/zen/v1","defaultModel":"deepseek-v4-flash","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]},
{"kind":"llm","providerType":"tencentTokenHub","labelKey":"tencentTokenHub","defaultEndpoint":"https://tokenhub.tencentmaas.com/v1","defaultModel":"hy3","authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]},
{"kind":"llm","providerType":"custom","labelKey":"customChatCompletions","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"chat_completions","supportedRequestFormats":["chat_completions","responses","messages"]},
{"kind":"llm","providerType":"custom_responses","labelKey":"customResponses","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"responses","supportedRequestFormats":["chat_completions","responses","messages"]},
{"kind":"llm","providerType":"custom_messages","labelKey":"customMessages","defaultEndpoint":null,"defaultModel":null,"authRequirement":"api_key_unless_custom_endpoint","validationProbe":"llm_text","staticModels":[],"defaultRequestFormat":"messages","supportedRequestFormats":["chat_completions","responses","messages"]},
@@ -34,6 +35,7 @@
{"kind":"asr","providerType":"openai-compatible","labelKey":"asrOpenAiCompatible","defaultEndpoint":null,"defaultModel":null,"authRequirement":"endpoint_model_optional_api_key","validationProbe":"asr_silence","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]},
{"kind":"asr","providerType":"xiaomi-mimo-asr","labelKey":"asrXiaomiMimo","defaultEndpoint":"https://api.xiaomimimo.com/v1","defaultModel":"mimo-v2.5-asr","authRequirement":"api_key","validationProbe":"asr_silence","staticModels":["mimo-v2.5-asr"],"defaultRequestFormat":null,"supportedRequestFormats":[]},
{"kind":"asr","providerType":"iflytek","labelKey":"asrIflytek","defaultEndpoint":null,"defaultModel":null,"authRequirement":"xfyun","validationProbe":"asr_silence_allows_no_final","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]},
{"kind":"asr","providerType":"tencent-cloud","labelKey":"asrTencentCloud","defaultEndpoint":null,"defaultModel":"Hy-ASR-3.0-preview","authRequirement":"tencent_cloud","validationProbe":"asr_silence_allows_no_final","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]},
{"kind":"asr","providerType":"foundry-local-whisper","labelKey":"asrFoundryLocalWhisper","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]},
{"kind":"asr","providerType":"local-whisper","labelKey":"asrLocalWhisper","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]},
{"kind":"asr","providerType":"sherpa-onnx-local","labelKey":"asrSherpaOnnxLocal","defaultEndpoint":null,"defaultModel":null,"authRequirement":"none","validationProbe":"unsupported","staticModels":[],"defaultRequestFormat":null,"supportedRequestFormats":[]},
@@ -11,6 +11,7 @@ export type AuthRequirement =
| 'api_key_unless_custom_endpoint'
| 'volcengine'
| 'xfyun'
| 'tencent_cloud'
| 'o_auth'
export interface ProviderDescriptor {
+1
View File
@@ -54,6 +54,7 @@ const LLM_NAME_KEY_BY_ID: Record<string, string> = {
alibabaCoding: 'alibabaCoding',
codingPlanX: 'codingPlanX',
opencode: 'opencode',
tencentTokenHub: 'tencentTokenHub',
custom: 'custom',
};
@@ -23,6 +23,10 @@ const opencode = presets.find(p => p.id === 'opencode');
assert(opencode?.defaultRequestFormat === 'chat_completions'
&& opencode.defaultEndpoint === 'https://opencode.ai/zen/v1'
&& opencode.defaultModel === 'deepseek-v4-flash', 'OpenCode browser preset must retain Core defaults');
const tokenhub = presets.find(p => p.id === 'tencentTokenHub');
assert(tokenhub?.defaultRequestFormat === null
&& tokenhub.supportedRequestFormats?.length === 0,
'TokenHub browser preset must stay fixed to Chat Completions');
assert(presets.find(p => p.id === 'custom_messages')?.defaultRequestFormat === 'messages', 'Picker must retain Core protocol defaults');
for (const preset of presets) {
assert(preset.supportedRequestFormats?.length === (preset.defaultRequestFormat ? 3 : 0), 'Picker must retain Core protocol capabilities');
@@ -7,6 +7,12 @@ const atlascloudPreset = LLM_LABELS.find(p => p.id === 'atlascloud');
if (LLM_LABELS.find(p => p.id === 'opencode')?.nameKey !== 'opencode') {
throw new Error('OpenCode LLM label is missing');
}
if (LLM_LABELS.find(p => p.id === 'tencentTokenHub')?.nameKey !== 'tencentTokenHub') {
throw new Error('Tencent Cloud TokenHub LLM label is missing');
}
if (ASR_LABELS.find(p => p.id === 'tencent-cloud')?.nameKey !== 'asrTencentCloud') {
throw new Error('Tencent Cloud ASR label is missing');
}
if (!atlascloudPreset) {
throw new Error('Atlas Cloud LLM preset is missing');
@@ -94,7 +94,7 @@ export const LLM_LABELS = [
['codex_oauth', 'codexOAuth'], ['mimo', 'mimo'], ['cometapi', 'cometapi'],
['openrouterFree', 'openrouterFree'], ['orcarouter', 'orcarouter'], ['alibabaCoding', 'alibabaCoding'],
['codingPlanX', 'codingPlanX'], ['minimax', 'minimax'], ['stepfun', 'stepfun'],
['opencode', 'opencode'],
['opencode', 'opencode'], ['tencentTokenHub', 'tencentTokenHub'],
['custom', 'customChatCompletions'], ['custom_responses', 'customResponses'], ['custom_messages', 'customMessages'],
].map(([id, nameKey]) => ({ id, nameKey })) as readonly { id: string; nameKey: string }[];
@@ -280,6 +280,11 @@ export function ChannelCredentialFields({
<ProviderTools key={`${configRevision}:${form?.revision}`} disabled={Object.values(blockedFields).some(Boolean) || (!!descriptor.supportedRequestFormats?.length && blockedFields.protocol === undefined)} kind="llm" provider={channelId}
onTested={onTested}
onUserMutation={onUserMutation} />
{providerType === 'tencentTokenHub' && (
<div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
{t('settings.providers.tencentTokenHubNote')}
</div>
)}
</>
);
}
@@ -365,6 +370,29 @@ export function ChannelCredentialFields({
);
}
if (descriptor?.authRequirement === 'tencent_cloud') {
return (
<>
<CredentialField key={`${channelId}:app_id`} label={t('settings.providers.tencentCloudAppIdLabel')}
account="tencent_cloud.app_id" provider={channelId} mono onUserMutation={onUserMutation} />
<CredentialField key={`${channelId}:secret_id`} label={t('settings.providers.tencentCloudSecretIdLabel')}
account="tencent_cloud.secret_id" provider={channelId} mono mask onUserMutation={onUserMutation} />
<CredentialField key={`${channelId}:secret_key`} label={t('settings.providers.tencentCloudSecretKeyLabel')}
account="tencent_cloud.secret_key" provider={channelId} mono mask onUserMutation={onUserMutation} />
<CredentialField key={`${channelId}:model`} label={t('settings.providers.modelLabel')}
account="asr.model" provider={channelId} mono
placeholder={descriptor?.defaultModel || 'Hy-ASR-3.0-preview'}
defaultValue={descriptor?.defaultModel || 'Hy-ASR-3.0-preview'} onUserMutation={onUserMutation} />
<div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
{t('settings.providers.tencentCloudNote')}
</div>
<ProviderTools key={form?.revision} disabled={Object.values(form?.blocked ?? {}).some(Boolean)} kind="asr" provider={channelId}
onTested={onTested}
onUserMutation={onUserMutation} />
</>
);
}
// 本地引擎(qwen3 / sherpa / foundry / Apple 语音)没有 key 与地址;模型的下载与
// 切换仍由「高级 → 本地模型」里的 <LocalAsr embedded /> 负责,这里只说明一句。
if (descriptor?.authRequirement === 'none') {
@@ -228,6 +228,7 @@ export const ASR_LABELS = [
{ id: 'openai-compatible', nameKey: 'asrOpenAiCompatible' },
{ id: 'xiaomi-mimo-asr', nameKey: 'asrXiaomiMimo' },
{ id: 'iflytek', nameKey: 'asrIflytek' },
{ id: 'tencent-cloud', nameKey: 'asrTencentCloud' },
{ id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper' },
{ id: 'local-whisper', nameKey: 'asrLocalWhisper' },
{ id: 'sherpa-onnx-local', nameKey: 'asrSherpaOnnxLocal' },