Skip to content
Merged
22 changes: 17 additions & 5 deletions docs/volcengine-setup.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
# 火山引擎(volcengine)ASR 配置
# 火山引擎(volcengine)配置

状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07
状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-12

## 1. 代码中的定义

- Provider:`volcengine`(labelKey `asrVolcengine`),定义于 Core `provider_rules.rs`;`authRequirement = Volcengine`(专用鉴权形态),无内置默认端点/模型(`defaultEndpoint` / `defaultModel` 为空,按通道配置)。
- 验证探针:`asr_silence_allows_no_final`(静音段允许无 final 帧,验证以可取消的静音探测完成)。
- 凭据字段(`provider_rules.rs:300-302`):`volcengine_auth_mode`(鉴权模式,如 ApiKey/官方端点模式)、`volcengine_app_key`、`volcengine_access_key`(布尔项 + 模式选择;具体取值在设置界面录入,凭据走系统安全存储,不落明文)。
- 凭据状态字段(`provider_rules.rs`):`volcengine_service`(服务选择)、`volcengine_auth_mode`(普通服务的鉴权模式)、`volcengine_app_key`、`volcengine_access_key`、`volcengine_api_key`(凭据是否已配置;具体取值在设置界面录入,凭据走系统安全存储,不落明文)。

## 2. 在应用内配置

设置 → AI 服务 → 语音识别 → 添加渠道,选择火山引擎;按界面提示填入鉴权字段,保存后执行“验证”得到真实验证结果(成功/失败与时间会记录在渠道列表)。
设置 → AI 服务与模型 → 语音识别 → 添加渠道,选择火山引擎;按界面提示填入鉴权字段,保存后执行“验证”得到真实验证结果(成功/失败与时间会记录在渠道列表)。

- 服务选择普通服务或 Agent Plan,按渠道保存为 `volcengine.service`(`standard` / `agent_plan`);旧配置默认普通服务。Agent Plan 使用专属 API Key,不需要 APP ID;切回普通服务保留原鉴权模式及已保存密钥,建议不同服务分别创建渠道。
- Resource ID 留空时使用 `volc.seedasr.sauc.duration`。Coding Plan 不提供 ASR 选项;验证成功后仍需通过实际录音检查转写及插入。

## 3. 端点与排错

- ApiKey 模式使用火山官方实时 ASR 端点(历史修复 #931 后的行为,以 `crates/openless-core/src/asr/volcengine.rs` 当前实现为准)。
- 普通服务的两种鉴权模式均使用 `wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async`(历史修复 #931 后的行为,以 `crates/openless-core/src/asr/volcengine.rs` 当前实现为准)。
- Agent Plan 使用专属端点 `wss://openspeech.bytedance.com/api/v3/plan/sauc/bigmodel_async` 和 API Key,见[官方接入文档](https://docs.volcengine.com/docs/82379/2516286?lang=zh)。验证与听写共用服务解析,未知服务值报错,不回退到普通端点;连接日志记录端点和追踪 ID,不记录鉴权头。
- 弱网行为:连接超时与重试在 Host/Core 实现,失败信息展示在渠道验证结果中。
- 开通服务、创建应用与获取密钥属火山控制台操作,以[火山官方文档](https://www.volcengine.com/docs)为准;本仓库只维护代码行为。

## 4. 火山方舟语言模型套餐

设置 → AI 服务与模型 → 语言模型 → 添加渠道,选择火山方舟;服务可选普通火山方舟、Agent Plan 或 Coding Plan。

- Agent Plan:`https://ark.cn-beijing.volces.com/api/plan/v3`;Coding Plan:`https://ark.cn-beijing.volces.com/api/coding/v3`。使用各自套餐的专属 API Key,按所选请求格式适配协议路径。
- 选择套餐后,通过“查看支持的模型”打开对应的 [Agent Plan 控制台](https://console.volcengine.com/ark/subscription/agent-plan)或 [Coding Plan 控制台](https://console.volcengine.com/ark/subscription/coding-plan),复制支持的文本模型 ID,手动填写后执行“验证”。该按钮不拉取在线模型列表,也不验证密钥。
- 预设地址只读,已有自定义地址保持可编辑;新建自定义接口使用自定义供应商入口。普通火山方舟及其他供应商保留原有模型列表获取行为。
99 changes: 85 additions & 14 deletions openless-all/app/crates/openless-core/src/asr/volcengine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use tokio::net::TcpStream;
use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex, Notify};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::header::HeaderValue;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::{handshake::client::Request as WebSocketRequest, Message};
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
use uuid::Uuid;

Expand All @@ -31,6 +31,9 @@ use crate::ports::{TextStreamChunk, TextStreamSink};
/// 新旧两种鉴权模式共享同一端点,仅握手鉴权头不同。
const ENDPOINT_APP_ID_TOKEN: &str = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async";
const ENDPOINT_API_KEY: &str = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async";
/// Agent Plan uses a dedicated subscription endpoint with API-key authentication.
/// https://docs.volcengine.com/docs/82379/2516286
const ENDPOINT_AGENT_PLAN: &str = "wss://openspeech.bytedance.com/api/v3/plan/sauc/bigmodel_async";
/// 200 ms of 16 kHz / 16-bit / mono PCM.
pub const TARGET_AUDIO_CHUNK_BYTES: usize = 6_400;
/// 16 kHz · 16-bit · mono = 32 000 bytes/sec → 32 bytes/ms.
Expand All @@ -50,9 +53,10 @@ const CONNECT_RETRY_BACKOFF: Duration = Duration::from_millis(250);
/// Volcengine ASR 鉴权模式。
///
/// - `AppIdToken`:旧版语音控制台应用,使用 `X-Api-App-Key` + `X-Api-Access-Key` 双表头鉴权。
/// - `ApiKey`:新版方舟(Ark)语音模型,使用单个 `X-Api-Key` 表头鉴权。
/// - `ApiKey`:普通服务 API Key 或 Agent Plan 专属 API Key,使用单个 `X-Api-Key` 表头鉴权。
///
/// 两种模式共享完全相同的 WebSocket 端点与二进制帧协议,仅握手鉴权头不同。
/// 普通服务下,两种模式共享 WebSocket 端点与二进制帧协议,仅握手鉴权头不同。
/// Agent Plan 按服务选择专属端点,并固定使用 ApiKey 鉴权。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VolcengineAuthMode {
AppIdToken,
Expand All @@ -77,7 +81,7 @@ impl VolcengineAuthMode {
/// 当前模式下所需凭据是否齐备(统一 trim 语义)。
///
/// `secret` 的语义随模式:AppIdToken = Access Token(旧版语音控制台),
/// ApiKey = 方舟语音模型 API Key。`app_id` 仅在 AppIdToken 模式要求非空。
/// ApiKey = 普通服务或 Agent Plan 的 ASR API Key。`app_id` 仅在 AppIdToken 模式要求非空。
///
/// 所有按模式判定凭据完整性的入口(`open_session`、`volcengine_configured`、
/// `ensure_asr_credentials`)都应复用此方法,避免三处规则漂移。
Expand All @@ -90,8 +94,34 @@ impl VolcengineAuthMode {
}
}

/// Service selection is separate from the standard service's authentication mode.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum VolcengineService {
#[default]
Standard,
AgentPlan,
}

impl VolcengineService {
pub fn parse(value: &str) -> Result<Self, &'static str> {
match value.trim() {
"" | "standard" => Ok(Self::Standard),
"agent_plan" => Ok(Self::AgentPlan),
_ => Err("volcengineServiceInvalid"),
}
}

pub fn auth_mode(self, configured: VolcengineAuthMode) -> VolcengineAuthMode {
match self {
Self::Standard => configured,
Self::AgentPlan => VolcengineAuthMode::ApiKey,
}
}
}

#[derive(Clone, Debug)]
pub struct VolcengineCredentials {
pub service: VolcengineService,
pub auth_mode: VolcengineAuthMode,
/// App ID(AppIdToken 模式使用;ApiKey 模式下为空)。
pub app_id: String,
Expand All @@ -114,7 +144,9 @@ impl VolcengineCredentials {

/// 凭据是否满足当前鉴权模式的要求(统一 trim 语义,见 [`VolcengineAuthMode::auth_ok`])。
pub fn auth_ok(&self) -> bool {
self.auth_mode.auth_ok(&self.app_id, &self.access_token)
self.service
.auth_mode(self.auth_mode.clone())
.auth_ok(&self.app_id, &self.access_token)
}
}

Expand Down Expand Up @@ -336,11 +368,15 @@ impl VolcengineStreamingASR {
&self,
connect_id: &str,
request_id: &str,
) -> Result<tokio_tungstenite::tungstenite::handshake::client::Request, VolcengineASRError>
{
let endpoint = match &self.credentials.auth_mode {
VolcengineAuthMode::AppIdToken => ENDPOINT_APP_ID_TOKEN,
VolcengineAuthMode::ApiKey => ENDPOINT_API_KEY,
) -> Result<WebSocketRequest, VolcengineASRError> {
let auth_mode = self
.credentials
.service
.auth_mode(self.credentials.auth_mode.clone());
let endpoint = match (self.credentials.service, &auth_mode) {
(VolcengineService::AgentPlan, _) => ENDPOINT_AGENT_PLAN,
(_, VolcengineAuthMode::AppIdToken) => ENDPOINT_APP_ID_TOKEN,
(_, VolcengineAuthMode::ApiKey) => ENDPOINT_API_KEY,
};
let mut request = endpoint
.into_client_request()
Expand All @@ -349,8 +385,8 @@ impl VolcengineStreamingASR {

// 根据鉴权模式选择表头:
// - AppIdToken:X-Api-App-Key + X-Api-Access-Key(旧版语音控制台)
// - ApiKey:X-Api-Key(新版方舟语音模型,单头即可)
match &self.credentials.auth_mode {
// - ApiKey:X-Api-Key(普通服务或 Agent Plan 的 ASR API Key,单头即可)
match auth_mode {
VolcengineAuthMode::AppIdToken => {
headers.insert(
"X-Api-App-Key",
Expand Down Expand Up @@ -404,8 +440,25 @@ impl VolcengineStreamingASR {
attempt += 1;
let request_id = Uuid::new_v4().to_string();
let request = self.build_connect_request(connect_id, &request_id)?;
log::info!(
"[asr] Volcengine connect endpoint={} connect_id={} request_id={}",
request.uri(),
connect_id,
request_id
);
match tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request)).await {
Ok(Ok((ws, _resp))) => return Ok(ws),
Ok(Ok((ws, response))) => {
log::info!(
"[asr] Volcengine connected connect_id={} log_id={}",
connect_id,
response
.headers()
.get("X-Tt-Logid")
.and_then(|value| value.to_str().ok())
.unwrap_or("-")
);
return Ok(ws);
}
Ok(Err(e)) => {
let classified = classify_connect_error(e);
if is_non_retryable(&classified) || attempt >= CONNECT_MAX_ATTEMPTS {
Expand Down Expand Up @@ -1047,21 +1100,38 @@ mod tests {
fn build_connect_request_selects_endpoint_and_headers_per_mode() {
let cases = [
(
VolcengineService::Standard,
VolcengineAuthMode::AppIdToken,
ENDPOINT_APP_ID_TOKEN,
true, // 双表头(X-Api-App-Key / X-Api-Access-Key)
false, // 不应带 X-Api-Key
),
(
VolcengineService::Standard,
VolcengineAuthMode::ApiKey,
ENDPOINT_API_KEY,
false, // 不应带双表头
true, // 单表头 X-Api-Key
),
(
VolcengineService::AgentPlan,
VolcengineAuthMode::AppIdToken,
ENDPOINT_AGENT_PLAN,
false,
true,
),
(
VolcengineService::AgentPlan,
VolcengineAuthMode::ApiKey,
ENDPOINT_AGENT_PLAN,
false,
true,
),
];
for (mode, endpoint, expects_app_headers, expects_api_key) in cases {
for (service, mode, endpoint, expects_app_headers, expects_api_key) in cases {
let asr = VolcengineStreamingASR::new(
VolcengineCredentials {
service,
auth_mode: mode.clone(),
app_id: "app".into(),
access_token: "secret".into(),
Expand Down Expand Up @@ -1182,6 +1252,7 @@ mod tests {
async fn await_final_result_returns_error_when_final_frame_never_arrives() {
let asr = VolcengineStreamingASR::new(
VolcengineCredentials {
service: VolcengineService::Standard,
auth_mode: VolcengineAuthMode::AppIdToken,
app_id: "app".into(),
access_token: "token".into(),
Expand Down
83 changes: 83 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 @@ -559,6 +559,17 @@ async fn build_cloud_transcription_session(
)
}
ActiveAsrProviderKind::Volcengine => {
let service = read_channel_credential(
credentials,
CredentialNamespace::Asr,
channel_id,
crate::credentials::VOLCENGINE_SERVICE_ACCOUNT,
)
.await?;
let service = crate::asr::volcengine::VolcengineService::parse(
service.as_deref().unwrap_or_default(),
)
.map_err(|message| BackendError::new(BackendErrorCode::InvalidArgument, message))?;
let auth_mode = read_channel_credential(
credentials,
CredentialNamespace::Asr,
Expand All @@ -568,6 +579,7 @@ async fn build_cloud_transcription_session(
.await?
.map(|value| VolcengineAuthMode::parse(&value))
.unwrap_or(VolcengineAuthMode::AppIdToken);
let auth_mode = service.auth_mode(auth_mode);
let app_id = read_channel_credential(
credentials,
CredentialNamespace::Asr,
Expand Down Expand Up @@ -599,6 +611,7 @@ async fn build_cloud_transcription_session(
)
.await?;
let credentials = VolcengineCredentials {
service,
auth_mode,
app_id,
access_token,
Expand Down Expand Up @@ -2479,6 +2492,76 @@ mod tests {
.unwrap();
}

#[tokio::test]
async fn ark_polisher_builder_requires_keys_only_for_official_endpoints() {
for endpoint in [
"https://ark.cn-beijing.volces.com/api/v3",
"https://ark.cn-beijing.volces.com/api/plan/v3",
"https://ark.cn-beijing.volces.com/api/coding/v3",
"http://127.0.0.1:8080/v1",
] {
// Key rejection is independent of protocol; exercise each key state once,
// then retain successful construction coverage for all three protocols.
for (format, key) in [
("chat_completions", None),
("chat_completions", Some("")),
("chat_completions", Some(" \t\n")),
("chat_completions", Some("fixture-key")),
("responses", Some("fixture-key")),
("messages", Some("fixture-key")),
] {
let store = InMemoryCredentialStore::default();
write_channel_secret(
&store,
CredentialNamespace::Llm,
"ark-channel",
LLM_ENDPOINT_ACCOUNT,
endpoint,
)
.await;
write_channel_secret(
&store,
CredentialNamespace::Llm,
"ark-channel",
crate::llm_protocol::REQUEST_FORMAT_ACCOUNT,
format,
)
.await;
if let Some(key) = key {
write_channel_secret(
&store,
CredentialNamespace::Llm,
"ark-channel",
LLM_API_KEY_ACCOUNT,
key,
)
.await;
}
let mut llm = ProviderInvocation::new("ark-channel", "ark");
llm.model = Some("fixture-model".to_string());
let context = DictationContext {
llm,
..DictationContext::default()
};
let result = build_cloud_polisher_provider(&store, &context).await;
if !endpoint.starts_with("http://127.0.0.1")
&& key.is_none_or(|value| value.trim().is_empty())
{
let error = match result {
Err(error) => error,
Ok(_) => {
panic!("official endpoint must require an API key: {endpoint}")
}
};
assert_eq!(error.code, BackendErrorCode::Provider);
assert_eq!(error.message, "LLM API key is not configured");
} else {
assert!(result.is_ok(), "{endpoint}");
}
}
}
}

#[tokio::test]
async fn cloud_asr_rejects_unknown_protocol_instead_of_falling_back_to_volcengine() {
let credentials: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::default());
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/crates/openless-core/src/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ pub const ASR_ADVANCED_CONFIG_ACCOUNT: &str = "asr.advanced_config";
pub const VOLCENGINE_APP_KEY_ACCOUNT: &str = "volcengine.app_key";
pub const VOLCENGINE_ACCESS_KEY_ACCOUNT: &str = "volcengine.access_key";
pub const VOLCENGINE_RESOURCE_ID_ACCOUNT: &str = "volcengine.resource_id";
pub const VOLCENGINE_SERVICE_ACCOUNT: &str = "volcengine.service";
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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ struct LegacyEntry {
app_key: Option<String>,
access_key: Option<String>,
resource_id: Option<String>,
volcengine_service: Option<String>,
auth_mode: Option<String>,
volcengine_api_key: Option<String>,
vocabulary_id: Option<String>,
Expand Down Expand Up @@ -124,6 +125,7 @@ impl Default for LegacyEntry {
app_key: None,
access_key: None,
resource_id: None,
volcengine_service: None,
auth_mode: None,
volcengine_api_key: None,
vocabulary_id: None,
Expand Down Expand Up @@ -153,6 +155,7 @@ impl LegacyEntry {
&self.app_key,
&self.access_key,
&self.resource_id,
&self.volcengine_service,
&self.auth_mode,
&self.volcengine_api_key,
&self.vocabulary_id,
Expand Down Expand Up @@ -382,6 +385,7 @@ fn decode_entry(
(VOLCENGINE_APP_KEY_ACCOUNT, entry.app_key),
(VOLCENGINE_ACCESS_KEY_ACCOUNT, entry.access_key),
(VOLCENGINE_RESOURCE_ID_ACCOUNT, entry.resource_id),
(VOLCENGINE_SERVICE_ACCOUNT, entry.volcengine_service),
(VOLCENGINE_AUTH_MODE_ACCOUNT, entry.auth_mode),
(VOLCENGINE_API_KEY_ACCOUNT, entry.volcengine_api_key),
(ASR_VOCABULARY_ID_ACCOUNT, entry.vocabulary_id),
Expand Down
Loading
Loading