diff --git a/docs/volcengine-setup.md b/docs/volcengine-setup.md index 842a703f9..9d0f203e5 100644 --- a/docs/volcengine-setup.md +++ b/docs/volcengine-setup.md @@ -1,19 +1,33 @@ -# 火山引擎(volcengine)ASR 配置 +# 火山引擎(Volcengine)配置 -状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。 +## ASR 服务与鉴权 -## 1. 代码中的定义 +设置 → AI 服务 → 语音识别 → 添加渠道,选择火山引擎。服务选择与普通服务的鉴权模式独立,配置和密钥按渠道保存至系统凭据存储。 -- 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`(布尔项 + 模式选择;具体取值在设置界面录入,凭据走系统安全存储,不落明文)。 +| 服务 | 鉴权 | WebSocket 端点 | +| --- | --- | --- | +| 普通服务 | APP ID + Access Token,或普通语音控制台 API Key | `wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async` | +| Agent Plan | Agent Plan 专属 API Key,不需要 APP ID | `wss://openspeech.bytedance.com/api/v3/plan/sauc/bigmodel_async` | -## 2. 在应用内配置 +未包含服务字段的旧配置继续使用普通服务。Agent Plan 自动使用 API Key 鉴权,切回普通服务后保留原有鉴权模式;切换服务不会删除已保存的密钥。不同服务使用不同密钥,建议分别创建渠道。Coding Plan 没有 ASR 服务选项。 -设置 → AI 服务 → 语音识别 → 添加渠道,选择火山引擎;按界面提示填入鉴权字段,保存后执行“验证”得到真实验证结果(成功/失败与时间会记录在渠道列表)。 +Resource ID 留空时使用 `volc.seedasr.sauc.duration`;Agent Plan 豆包流式 ASR 使用此资源。服务选择保存在 `volcengine.service`(`standard` / `agent_plan`),由 Core 的同一配置解析和连接路径用于验证、听写及其他 ASR 入口。未知服务值报错,不回退到普通计费端点。 -## 3. 端点与排错 +“验证”发送可取消的静音探针,允许没有最终识别结果;连接验证成功后,还应通过实际录音检查转写及插入。连接日志包含端点、连接/请求 ID 和服务端 Log ID,不包含鉴权头。 -- ApiKey 模式使用火山官方实时 ASR 端点(历史修复 #931 后的行为,以 `crates/openless-core/src/asr/volcengine.rs` 当前实现为准)。 -- 弱网行为:连接超时与重试在 Host/Core 实现,失败信息展示在渠道验证结果中。 -- 开通服务、创建应用与获取密钥属火山控制台操作,以[火山官方文档](https://www.volcengine.com/docs)为准;本仓库只维护代码行为。 +官方依据:[Agent Plan 接入语音模型](https://docs.volcengine.com/docs/82379/2516286?lang=zh)、[普通流式语音识别](https://www.volcengine.com/docs/6561/1354869?lang=zh)。 + +## Ark 语言模型套餐 + +设置 → AI 服务 → 文本润色 → 添加火山方舟渠道,使用套餐专属 API Key 和对应 Endpoint: + +- Agent Plan:`https://ark.cn-beijing.volces.com/api/plan/v3` +- Coding Plan:`https://ark.cn-beijing.volces.com/api/coding/v3` + +填写控制台显示的**文本生成模型名称**,或使用 `ark-code-latest` 并在控制台选择其对应文本模型,再执行“验证”。不要将图片、视频或向量化模型用于文本润色;列表中的 ID 不代表该套餐均可调用,实际能力以控制台及连接验证为准。 + +模型列表与推理验证相互独立。目录请求返回 404 时,应用提示当前 Endpoint 无法提供列表,保留已有模型和手填入口,不将目录缺失判定为 API Key 无效,也不会替换 Endpoint。Coding Plan 的现有 `/models` 路径继续使用。 + +Agent Plan 的官方目录接口 [ListArkAgentPlanModel](https://api.volcengine.com/api-docs/view?action=ListArkAgentPlanModel&version=2024-01-01&serviceCode=ark) 使用独立签名鉴权,应用没有接入这一管控接口;它与推理 Key 的 `/models` 请求不同。手填模型配置见[官方快速开始](https://docs.volcengine.com/docs/82379/2373738?lang=zh)。 + +润色请求保留现有可选温度规则:发送配置值的十进制表示(例如默认 `0.3`),避免将 `f32` 扩展为长小数。自定义渠道未配置温度时仍省略该字段,OpenAI GPT-5 和各协议的既有省略规则保持不变。 diff --git a/openless-all/app/crates/openless-core/src/asr/volcengine.rs b/openless-all/app/crates/openless-core/src/asr/volcengine.rs index 12f35c771..4306b430e 100644 --- a/openless-all/app/crates/openless-core/src/asr/volcengine.rs +++ b/openless-all/app/crates/openless-core/src/asr/volcengine.rs @@ -9,14 +9,17 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; -use futures_util::{SinkExt, StreamExt}; +use futures_util::{future::BoxFuture, SinkExt, StreamExt}; use parking_lot::Mutex as ParkingMutex; use serde_json::{json, Value}; 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, Response as WebSocketResponse}, + Error as WebSocketError, Message, +}; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; use uuid::Uuid; @@ -31,6 +34,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. @@ -90,8 +96,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 { + 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, @@ -114,7 +146,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) } } @@ -144,8 +178,28 @@ pub enum VolcengineASRError { DecodeFailed(String), } -type WsStream = WebSocketStream>; -type WsSink = futures_util::stream::SplitSink; +pub type VolcengineWebSocket = WebSocketStream>; + +/// External connection boundary; the provider still owns the URL and auth headers. +pub trait VolcengineConnector: Send + Sync { + fn connect( + &self, + request: WebSocketRequest, + ) -> BoxFuture<'static, Result<(VolcengineWebSocket, WebSocketResponse), WebSocketError>>; +} + +struct DefaultVolcengineConnector; + +impl VolcengineConnector for DefaultVolcengineConnector { + fn connect( + &self, + request: WebSocketRequest, + ) -> BoxFuture<'static, Result<(VolcengineWebSocket, WebSocketResponse), WebSocketError>> { + Box::pin(connect_async(request)) + } +} + +type WsSink = futures_util::stream::SplitSink; type SharedWriter = Arc>>; type AudioFrameSender = mpsc::UnboundedSender<(i32, Vec)>; @@ -167,6 +221,7 @@ struct SyncState { } pub struct VolcengineStreamingASR { + connector: Arc, credentials: VolcengineCredentials, task_spawner: Arc, hotwords: Vec, @@ -200,6 +255,7 @@ impl VolcengineStreamingASR { task_spawner: Arc, ) -> Self { Self { + connector: Arc::new(DefaultVolcengineConnector), credentials, task_spawner, hotwords, @@ -217,6 +273,11 @@ impl VolcengineStreamingASR { *self.partial_sink.lock() = Some(sink); } + pub fn with_connector(mut self, connector: Arc) -> Self { + self.connector = connector; + self + } + pub async fn open_session(self: &Arc) -> Result<(), VolcengineASRError> { let creds = &self.credentials; // 统一走 VolcengineCredentials::auth_ok(trim 语义),与概览页凭据状态检测、 @@ -336,11 +397,15 @@ impl VolcengineStreamingASR { &self, connect_id: &str, request_id: &str, - ) -> Result - { - let endpoint = match &self.credentials.auth_mode { - VolcengineAuthMode::AppIdToken => ENDPOINT_APP_ID_TOKEN, - VolcengineAuthMode::ApiKey => ENDPOINT_API_KEY, + ) -> Result { + 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() @@ -350,7 +415,7 @@ impl VolcengineStreamingASR { // 根据鉴权模式选择表头: // - AppIdToken:X-Api-App-Key + X-Api-Access-Key(旧版语音控制台) // - ApiKey:X-Api-Key(新版方舟语音模型,单头即可) - match &self.credentials.auth_mode { + match auth_mode { VolcengineAuthMode::AppIdToken => { headers.insert( "X-Api-App-Key", @@ -398,14 +463,34 @@ impl VolcengineStreamingASR { /// (hung handshake or a transient blip) doesn't kill the whole dictation. /// `AuthRejected` / `RateLimited` short-circuit — bad credentials never heal on /// retry, and hammering a rate-limited account only makes the throttle worse. - async fn connect_with_retry(&self, connect_id: &str) -> Result { + async fn connect_with_retry( + &self, + connect_id: &str, + ) -> Result { let mut attempt = 0usize; loop { attempt += 1; let request_id = Uuid::new_v4().to_string(); let request = self.build_connect_request(connect_id, &request_id)?; - match tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request)).await { - Ok(Ok((ws, _resp))) => return Ok(ws), + log::info!( + "[asr] Volcengine connect endpoint={} connect_id={} request_id={}", + request.uri(), + connect_id, + request_id + ); + match tokio::time::timeout(CONNECT_TIMEOUT, self.connector.connect(request)).await { + 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 { @@ -1062,6 +1147,7 @@ mod tests { for (mode, endpoint, expects_app_headers, expects_api_key) in cases { let asr = VolcengineStreamingASR::new( VolcengineCredentials { + service: VolcengineService::Standard, auth_mode: mode.clone(), app_id: "app".into(), access_token: "secret".into(), @@ -1182,6 +1268,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(), diff --git a/openless-all/app/crates/openless-core/src/cloud_providers.rs b/openless-all/app/crates/openless-core/src/cloud_providers.rs index ac5a06de7..d4a62d552 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -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, @@ -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, @@ -599,6 +611,7 @@ async fn build_cloud_transcription_session( ) .await?; let credentials = VolcengineCredentials { + service, auth_mode, app_id, access_token, diff --git a/openless-all/app/crates/openless-core/src/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index dc6270636..ba1cab461 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -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"; diff --git a/openless-all/app/crates/openless-core/src/credentials_legacy.rs b/openless-all/app/crates/openless-core/src/credentials_legacy.rs index 74b3f1904..2193da9d5 100644 --- a/openless-all/app/crates/openless-core/src/credentials_legacy.rs +++ b/openless-all/app/crates/openless-core/src/credentials_legacy.rs @@ -93,6 +93,7 @@ struct LegacyEntry { app_key: Option, access_key: Option, resource_id: Option, + volcengine_service: Option, auth_mode: Option, volcengine_api_key: Option, vocabulary_id: Option, @@ -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, @@ -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, @@ -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), diff --git a/openless-all/app/crates/openless-core/src/llm_protocol.rs b/openless-all/app/crates/openless-core/src/llm_protocol.rs index 0c4314ba6..6f76f7104 100644 --- a/openless-all/app/crates/openless-core/src/llm_protocol.rs +++ b/openless-all/app/crates/openless-core/src/llm_protocol.rs @@ -274,7 +274,7 @@ pub(crate) fn request_body( && !(config.protocol.format == LlmRequestFormat::Messages && config.thinking_enabled) { if let Some(temperature) = config.temperature { - body["temperature"] = json!(temperature); + body["temperature"] = crate::polish::temperature_json(temperature); } } body diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index ff7c68241..318d27ac5 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -170,6 +170,13 @@ pub fn openai_compatible_temperature_for_provider( } } +/// Preserve the configured f32's shortest decimal representation on the wire. +/// Widening directly into a JSON Value sends 0.30000001192092896 for 0.3; +/// non-finite values retain serde_json's null representation. +pub(crate) fn temperature_json(temperature: f32) -> Value { + serde_json::from_str::(&temperature.to_string()).map_or(Value::Null, |value| json!(value)) +} + fn is_builtin_llm_provider(provider_id: &str) -> bool { matches!( provider_id, @@ -786,7 +793,7 @@ impl OpenAICompatibleLLMProvider { if !(self.config.provider_id.trim() == "openai" && openai_model_is_gpt5_family(&self.config.model)) { - body["temperature"] = json!(temperature); + body["temperature"] = temperature_json(temperature); } } apply_openai_compatible_thinking_control( @@ -2341,6 +2348,11 @@ mod tests { let split = request.windows(4).position(|w| w == b"\r\n\r\n").unwrap(); let headers = String::from_utf8_lossy(&request[..split]).to_ascii_lowercase(); let body: Value = serde_json::from_slice(&request[split + 4..]).unwrap(); + if format == LlmRequestFormat::Responses { + assert!(body.get("temperature").is_none()); + } else { + assert_eq!(body["temperature"].to_string(), "0.7"); + } let path = match format { LlmRequestFormat::ChatCompletions => "chat/completions", LlmRequestFormat::Responses => "responses", @@ -2401,6 +2413,7 @@ mod tests { "fixture-key", "test", ) + .with_temperature(Some(0.7)) .with_protocol(LlmProtocolConfig { format, ..Default::default() @@ -2969,6 +2982,54 @@ mod tests { server.join().unwrap(); } + #[tokio::test] + async fn polish_request_preserves_default_decimal_temperature() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + let header_end = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("request must contain headers"); + let body: Value = serde_json::from_slice(&request[header_end + 4..]).unwrap(); + let response_body = r#"{"choices":[{"message":{"content":"polished"}}]}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}", + response_body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + body + }); + + let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "ark", + "Ark", + format!("http://{addr}"), + "", + "test-model", + )); + let output = provider + .polish( + "raw text", + PolishMode::Raw, + &[], + "", + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + None, + &[], + ) + .await + .unwrap(); + + assert_eq!(output, "polished"); + assert_eq!(server.join().unwrap()["temperature"].to_string(), "0.3"); + } + // ──────────────── 对话感知 polish 的 chat 消息构造 ──────────────── // 用户的核心顾虑:让 LLM 拿到上下文但**不要把上下文吐出来**。 // 这里的不变量保证「不复读」靠两层防御: @@ -3158,7 +3219,7 @@ mod tests { #[test] fn chat_body_sends_configured_temperature() { - for temperature in [0.0, 0.3, 1.0] { + for (temperature, expected) in [(0.0, "0.0"), (0.3, "0.3"), (1.0, "1.0")] { let provider = OpenAICompatibleLLMProvider::new( OpenAICompatibleConfig::new( "custom", @@ -3172,7 +3233,7 @@ mod tests { let body = provider.chat_body(true, vec![json!({ "role": "user", "content": "hi" })]); - assert_eq!(body["temperature"], json!(temperature)); + assert_eq!(body["temperature"].to_string(), expected); } } @@ -3188,7 +3249,7 @@ mod tests { let body = provider.chat_body(true, vec![json!({ "role": "user", "content": "hi" })]); - assert_eq!(body["temperature"], json!(DEFAULT_TEMPERATURE)); + assert_eq!(body["temperature"].to_string(), "0.3"); } #[test] @@ -3230,7 +3291,7 @@ mod tests { let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); - assert_eq!(body["temperature"], json!(DEFAULT_TEMPERATURE)); + assert_eq!(body["temperature"].to_string(), "0.3"); } } diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 6ad7919b4..157e142fd 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -333,6 +333,7 @@ pub struct CredentialConfiguration { pub asr_api_key: bool, pub asr_endpoint: bool, pub asr_model: bool, + pub volcengine_service: Option, pub volcengine_auth_mode: Option, pub volcengine_app_key: bool, pub volcengine_access_key: bool, @@ -358,12 +359,21 @@ pub fn volcengine_configured(configuration: &CredentialConfiguration) -> bool { // resource id 不是配置门槛:留空时运行时回落默认资源 //(见 VolcengineCredentials::resolve_resource_id),认证只取决于密钥本身。 - match configuration - .volcengine_auth_mode - .as_deref() - .map(VolcengineAuthMode::parse) - .unwrap_or(VolcengineAuthMode::AppIdToken) - { + let Ok(service) = crate::asr::volcengine::VolcengineService::parse( + configuration + .volcengine_service + .as_deref() + .unwrap_or_default(), + ) else { + return false; + }; + match service.auth_mode( + configuration + .volcengine_auth_mode + .as_deref() + .map(VolcengineAuthMode::parse) + .unwrap_or(VolcengineAuthMode::AppIdToken), + ) { VolcengineAuthMode::AppIdToken => { configuration.volcengine_app_key && configuration.volcengine_access_key } @@ -693,7 +703,10 @@ pub fn is_stepfun_realtime_provider(id: &str) -> bool { } pub fn is_mimo_provider(id: &str) -> bool { - matches!(id, MIMO_PROVIDER_ID | crate::asr::mimo::ORCAROUTER_PROVIDER_ID) + matches!( + id, + MIMO_PROVIDER_ID | crate::asr::mimo::ORCAROUTER_PROVIDER_ID + ) } pub fn is_dashscope_multimodal_provider(id: &str) -> bool { diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index 102f657dc..9aced760a 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -737,6 +737,10 @@ async fn fetch_models( ) .await .map_err(map_transport_error)?; + // A missing catalog route does not determine whether the inference key/model works. + if response.status == 404 { + return Err(provider_error("providerModelsUnavailable")); + } if !(200..300).contains(&response.status) { return Err(BackendError::new( BackendErrorCode::Provider, @@ -1478,6 +1482,84 @@ mod tests { assert!(!format!("{error:?}").contains("secret-key")); } + #[tokio::test] + async fn missing_catalog_preserves_manual_model_and_other_channel_catalog() { + let credentials = Arc::new(InMemoryCredentialStore::default()); + let agent = create_channel_with_values( + &credentials, + ChannelKind::Llm, + "ark", + &[ + (LLM_API_KEY_ACCOUNT, "agent-key"), + ( + LLM_ENDPOINT_ACCOUNT, + "https://ark.cn-beijing.volces.com/api/plan/v3", + ), + (LLM_MODEL_ACCOUNT, "ark-code-latest"), + ], + ) + .await; + let coding = create_channel_with_values( + &credentials, + ChannelKind::Llm, + "ark", + &[ + (LLM_API_KEY_ACCOUNT, "coding-key"), + ( + LLM_ENDPOINT_ACCOUNT, + "https://ark.cn-beijing.volces.com/api/coding/v3", + ), + (LLM_MODEL_ACCOUNT, "ark-code-latest"), + ], + ) + .await; + let transport = Arc::new(FakeProviderTransport::default()); + transport.push_response(404, b"not found".to_vec()); + transport.push_response(200, br#"{"data":[{"id":"available-model"}]}"#.to_vec()); + let service = ProviderService::new_with_transport( + credentials.clone(), + Arc::new(crate::TokioTaskSpawner), + transport.clone(), + ); + let error = service + .list_models(ProviderRequest { + kind: ProviderKind::Llm, + channel_id: Some(agent.clone()), + thinking_enabled: false, + }) + .await + .unwrap_err(); + assert_eq!(error.message, "providerModelsUnavailable"); + let models = service + .list_models(ProviderRequest { + kind: ProviderKind::Llm, + channel_id: Some(coding), + thinking_enabled: false, + }) + .await + .unwrap() + .models; + assert_eq!(models, vec!["available-model"]); + let saved = credentials + .read( + CredentialKey::new(CredentialNamespace::Llm, Some(agent), LLM_MODEL_ACCOUNT) + .unwrap(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(saved.expose_secret(), "ark-code-latest"); + let requests = transport.requests(); + assert_eq!( + requests[0].url, + "https://ark.cn-beijing.volces.com/api/plan/v3/models" + ); + assert_eq!( + requests[1].url, + "https://ark.cn-beijing.volces.com/api/coding/v3/models" + ); + } + #[tokio::test] async fn tokenhub_catalog_lists_only_online_language_models() { let credentials = Arc::new(InMemoryCredentialStore::default()); diff --git a/openless-all/app/crates/openless-core/tests/volcengine_credentials.rs b/openless-all/app/crates/openless-core/tests/volcengine_credentials.rs new file mode 100644 index 000000000..37b006a92 --- /dev/null +++ b/openless-all/app/crates/openless-core/tests/volcengine_credentials.rs @@ -0,0 +1,126 @@ +use openless_core::credentials::{CredentialKey, CredentialNamespace}; +use openless_core::credentials_legacy::decode_legacy_credentials; + +#[test] +fn reloading_volcengine_channels_preserves_service_and_separate_credentials() { + let saved = r#"{"version":2,"providers":{"asr":{ + "plan-channel":{"providerType":"volcengine","volcengineService":"agent_plan","volcengineApiKey":"plan-key"}, + "legacy-channel":{"providerType":"volcengine","authMode":"app_id_token","appKey":"app","accessKey":"token"} + }}}"#; + let loaded = decode_legacy_credentials(saved).unwrap(); + let read = |channel: &str, account: &str| { + let key = + CredentialKey::new(CredentialNamespace::Asr, Some(channel.into()), account).unwrap(); + loaded + .secrets + .iter() + .find(|(stored, _)| *stored == key) + .map(|(_, value)| value.expose_secret()) + }; + assert_eq!( + read("plan-channel", "volcengine.service"), + Some("agent_plan") + ); + assert_eq!(read("plan-channel", "volcengine.api_key"), Some("plan-key")); + assert_eq!(read("legacy-channel", "volcengine.service"), None); + assert_eq!( + read("legacy-channel", "volcengine.access_key"), + Some("token") + ); +} + +#[tokio::test] +async fn volcengine_sessions_send_service_specific_handshakes() { + use futures_util::{future::BoxFuture, StreamExt}; + use openless_core::asr::volcengine::{ + VolcengineAuthMode, VolcengineConnector, VolcengineCredentials, VolcengineService, + VolcengineStreamingASR, VolcengineWebSocket, + }; + use std::{net::SocketAddr, sync::Arc, time::Duration}; + use tokio_tungstenite::tungstenite::{ + handshake::client::{Request, Response}, + Error, + }; + use tokio_tungstenite::{accept_hdr_async, client_async, MaybeTlsStream}; + struct LocalConnector(SocketAddr); + impl VolcengineConnector for LocalConnector { + fn connect( + &self, + request: Request, + ) -> BoxFuture<'static, Result<(VolcengineWebSocket, Response), Error>> { + let address = self.0; + Box::pin(async move { + assert_eq!(request.uri().scheme_str(), Some("wss")); + let stream = tokio::net::TcpStream::connect(address).await?; + client_async(request, MaybeTlsStream::Plain(stream)).await + }) + } + } + for (service, mode, path, app_headers) in [ + ( + VolcengineService::Standard, + VolcengineAuthMode::AppIdToken, + "/api/v3/sauc/bigmodel_async", + true, + ), + ( + VolcengineService::Standard, + VolcengineAuthMode::ApiKey, + "/api/v3/sauc/bigmodel_async", + false, + ), + ( + VolcengineService::AgentPlan, + VolcengineAuthMode::AppIdToken, + "/api/v3/plan/sauc/bigmodel_async", + false, + ), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (tx, rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut tx = Some(tx); + let mut socket=accept_hdr_async(stream, move |request: &Request, response: tokio_tungstenite::tungstenite::handshake::server::Response| { + tx.take().unwrap().send((request.uri().path().to_string(), request.headers().clone())).unwrap(); + Ok(response) + }).await.unwrap(); + let _ = socket.next().await; + }); + let provider = Arc::new( + VolcengineStreamingASR::new( + VolcengineCredentials { + service, + auth_mode: mode, + app_id: "fixture-app".into(), + access_token: "fixture-secret".into(), + resource_id: "volc.seedasr.sauc.duration".into(), + }, + vec![], + ) + .with_connector(Arc::new(LocalConnector(address))), + ); + tokio::time::timeout(Duration::from_secs(3), provider.open_session()) + .await + .unwrap() + .unwrap(); + let (actual_path, headers) = rx.await.unwrap(); + assert_eq!(actual_path, path); + assert_eq!(headers["host"], "openspeech.bytedance.com"); + assert_eq!(headers["X-Api-Resource-Id"], "volc.seedasr.sauc.duration"); + if app_headers { + assert_eq!(headers["X-Api-App-Key"], "fixture-app"); + assert_eq!(headers["X-Api-Access-Key"], "fixture-secret"); + assert!(!headers.contains_key("X-Api-Key")); + } else { + assert_eq!(headers["X-Api-Key"], "fixture-secret"); + assert!(!headers.contains_key("X-Api-App-Key")); + assert!(!headers.contains_key("X-Api-Access-Key")); + } + assert!(uuid::Uuid::parse_str(headers["X-Api-Connect-Id"].to_str().unwrap()).is_ok()); + assert!(uuid::Uuid::parse_str(headers["X-Api-Request-Id"].to_str().unwrap()).is_ok()); + provider.cancel(); + server.await.unwrap(); + } +} diff --git a/openless-all/app/linux-egui/src/credentials.rs b/openless-all/app/linux-egui/src/credentials.rs index 0f506728e..d33c2fa30 100644 --- a/openless-all/app/linux-egui/src/credentials.rs +++ b/openless-all/app/linux-egui/src/credentials.rs @@ -349,6 +349,29 @@ impl CredentialStore for LinuxCredentialStore { let _ = auth_mode_key; None }; + let service_key = CredentialKey::new( + CredentialNamespace::Asr, + Some(volcengine_provider.to_string()), + openless_core::credentials::VOLCENGINE_SERVICE_ACCOUNT, + )?; + #[cfg(target_os = "linux")] + let volcengine_service = if has( + CredentialNamespace::Asr, + volcengine_provider, + openless_core::credentials::VOLCENGINE_SERVICE_ACCOUNT, + ) { + tokio::task::spawn_blocking(move || read_secret(&service_key)) + .await + .map_err(join_error)?? + .map(SecretValue::into_exposed) + } else { + None + }; + #[cfg(not(target_os = "linux"))] + let volcengine_service = { + let _ = service_key; + None + }; let configuration = openless_core::provider_rules::CredentialConfiguration { asr_api_key: has( CredentialNamespace::Asr, @@ -365,6 +388,7 @@ impl CredentialStore for LinuxCredentialStore { &active_asr_provider, ASR_MODEL_ACCOUNT, ), + volcengine_service, volcengine_auth_mode, volcengine_app_key: has( CredentialNamespace::Asr, diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 2aff6bae9..84155471c 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -77,6 +77,7 @@ mod linux_app { name: String, endpoint: String, model: String, + volcengine_service: String, auth_mode: String, resource_id: String, app_id: String, @@ -379,7 +380,11 @@ mod linux_app { }) .await .map(|models| models.models) - .map_err(|error| error.to_string()); + .map_err(|error| { + if error.message == "providerModelsUnavailable" { + "当前接口无法提供模型列表,请填写控制台中的文本模型名称,再验证连接。列表不可用不代表 API Key 无效。".to_string() + } else { error.to_string() } + }); let _ = tx.send(UiResult::ProviderModels { kind, channel_id, @@ -1640,7 +1645,9 @@ mod linux_app { .map(|pair| std::str::from_utf8(pair).unwrap().to_ascii_uppercase()) .collect::>() .join(" "); - ui.add(egui::Label::new(egui::RichText::new(&display).monospace()).wrap()); + ui.add( + egui::Label::new(egui::RichText::new(&display).monospace()).wrap(), + ); if ui.button("复制完整指纹").clicked() { ui.ctx().copy_text(display); } @@ -2230,6 +2237,19 @@ mod linux_app { .await? .or_else(|| descriptor.default_model.clone()) .unwrap_or_default(); + let volcengine_service = + if descriptor.auth_requirement == openless_core::AuthRequirement::Volcengine { + read_provider_value( + &backend, + kind, + &channel.id, + openless_core::credentials::VOLCENGINE_SERVICE_ACCOUNT, + ) + .await? + .unwrap_or_else(|| "standard".to_string()) + } else { + String::new() + }; let (auth_mode, resource_id) = if descriptor.auth_requirement == openless_core::AuthRequirement::Volcengine { ( @@ -2273,6 +2293,7 @@ mod linux_app { descriptor, endpoint, model, + volcengine_service, auth_mode, resource_id, app_id, @@ -2300,21 +2321,53 @@ mod linux_app { ui.label("此 Provider 使用 OAuth;Linux egui 不读取或显示 OAuth token。"); } openless_core::AuthRequirement::Volcengine => { - egui::ComboBox::from_id_salt("volcengine-auth-mode") - .selected_text(&editor.auth_mode) + let previous_api_key = + editor.volcengine_service == "agent_plan" || editor.auth_mode == "api_key"; + egui::ComboBox::from_id_salt("volcengine-service") + .selected_text(if editor.volcengine_service == "agent_plan" { + "Agent Plan" + } else { + "普通服务" + }) .show_ui(ui, |ui| { ui.selectable_value( - &mut editor.auth_mode, - "app_id_token".to_string(), - "APP ID + Access Token", + &mut editor.volcengine_service, + "standard".to_string(), + "普通服务", ); ui.selectable_value( - &mut editor.auth_mode, - "api_key".to_string(), - "API Key", + &mut editor.volcengine_service, + "agent_plan".to_string(), + "Agent Plan", ); }); - if editor.auth_mode == "api_key" { + if editor.volcengine_service != "agent_plan" { + egui::ComboBox::from_id_salt("volcengine-auth-mode") + .selected_text(&editor.auth_mode) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut editor.auth_mode, + "app_id_token".to_string(), + "APP ID + Access Token", + ); + ui.selectable_value( + &mut editor.auth_mode, + "api_key".to_string(), + "API Key", + ); + }); + } + let api_key = + editor.volcengine_service == "agent_plan" || editor.auth_mode == "api_key"; + if api_key != previous_api_key { + // Input buffers change meaning; persisted credential slots remain untouched. + editor.primary_secret.clear(); + editor.secondary_secret.clear(); + } + if editor.volcengine_service == "agent_plan" { + ui.label("使用 Agent Plan 专属 API Key;普通服务请使用单独渠道。"); + } + if editor.volcengine_service == "agent_plan" || editor.auth_mode == "api_key" { secret_edit(ui, "API Key", &mut editor.primary_secret); } else { secret_edit(ui, "APP ID", &mut editor.primary_secret); @@ -2411,6 +2464,14 @@ mod linux_app { match editor.descriptor.auth_requirement { openless_core::AuthRequirement::None | openless_core::AuthRequirement::OAuth => {} openless_core::AuthRequirement::Volcengine => { + write_or_remove_provider_value( + &backend, + editor.kind, + channel_id, + openless_core::credentials::VOLCENGINE_SERVICE_ACCOUNT, + &editor.volcengine_service, + ) + .await?; write_or_remove_provider_value( &backend, editor.kind, @@ -2435,7 +2496,7 @@ mod linux_app { &editor.model, ) .await?; - if editor.auth_mode == "api_key" { + if editor.volcengine_service == "agent_plan" || editor.auth_mode == "api_key" { write_secret_if_entered( &backend, editor.kind, diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 423aeb8a0..d209355e7 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -535,6 +535,7 @@ fn credential_configuration( asr_api_key: configured(&snap.asr_api_key), asr_endpoint: configured(&snap.asr_endpoint), asr_model: configured(&snap.asr_model), + volcengine_service: snap.volcengine_service.clone(), volcengine_auth_mode: snap.volcengine_auth_mode.clone(), volcengine_app_key: configured(&snap.volcengine_app_key), volcengine_access_key: configured(&snap.volcengine_access_key), @@ -801,6 +802,7 @@ fn account_provider_kind(account: CredentialAccount) -> CredentialProviderKind { CredentialAccount::VolcengineAppKey | CredentialAccount::VolcengineAccessKey | CredentialAccount::VolcengineResourceId + | CredentialAccount::VolcengineService | CredentialAccount::VolcengineAuthMode | CredentialAccount::VolcengineApiKey | CredentialAccount::AsrApiKey @@ -832,6 +834,7 @@ fn parse_account(s: &str) -> Result { "volcengine.app_key" => Ok(CredentialAccount::VolcengineAppKey), "volcengine.access_key" => Ok(CredentialAccount::VolcengineAccessKey), "volcengine.resource_id" => Ok(CredentialAccount::VolcengineResourceId), + "volcengine.service" => Ok(CredentialAccount::VolcengineService), "volcengine.auth_mode" => Ok(CredentialAccount::VolcengineAuthMode), "volcengine.api_key" => Ok(CredentialAccount::VolcengineApiKey), "ark.api_key" => Ok(CredentialAccount::ArkApiKey), diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 3b1c019b1..cbb9c227d 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -325,6 +325,8 @@ struct CredsAsrEntry { resourceId: Option, #[serde(skip_serializing_if = "Option::is_none")] authMode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + volcengineService: Option, /// 方舟(Ark)API Key —— 仅 `api_key` 鉴权模式使用,与旧版 Access Token 槽位 /// (`accessKey`) 隔离,避免两模式切换时残留凭据互相污染。 #[serde(skip_serializing_if = "Option::is_none")] @@ -374,6 +376,7 @@ impl CredsAsrEntry { && self.appKey.as_deref().unwrap_or("").is_empty() && self.accessKey.as_deref().unwrap_or("").is_empty() && self.resourceId.as_deref().unwrap_or("").is_empty() + && self.volcengineService.as_deref().unwrap_or("").is_empty() && self.authMode.as_deref().unwrap_or("").is_empty() && self.volcengineApiKey.as_deref().unwrap_or("").is_empty() && self.vocabularyId.as_deref().unwrap_or("").is_empty() @@ -1650,6 +1653,7 @@ fn lookup_account(root: &CredsRoot, account: CredentialAccount) -> Option asr.and_then(|e| pick(&e.accessKey)), CredentialAccount::VolcengineResourceId => asr.and_then(|e| pick(&e.resourceId)), + CredentialAccount::VolcengineService => asr.and_then(|e| pick(&e.volcengineService)), CredentialAccount::VolcengineAuthMode => asr.and_then(|e| pick(&e.authMode)), CredentialAccount::VolcengineApiKey => asr.and_then(|e| pick(&e.volcengineApiKey)), CredentialAccount::ArkApiKey => llm.and_then(|e| pick(&e.apiKey)), @@ -1728,6 +1732,10 @@ fn write_account(root: &mut CredsRoot, account: CredentialAccount, value: Option let entry = root.providers.asr.entry(asr_id).or_default(); entry.resourceId = normalized; } + CredentialAccount::VolcengineService => { + let entry = root.providers.asr.entry(asr_id).or_default(); + entry.volcengineService = normalized; + } CredentialAccount::VolcengineAuthMode => { let entry = root.providers.asr.entry(asr_id).or_default(); entry.authMode = normalized; @@ -1808,6 +1816,7 @@ pub enum CredentialAccount { VolcengineAppKey, VolcengineAccessKey, VolcengineResourceId, + VolcengineService, VolcengineAuthMode, /// 方舟(Ark)语音模型 API Key(`api_key` 鉴权模式使用,独立于旧版 Access Token 槽位)。 VolcengineApiKey, @@ -1851,6 +1860,7 @@ impl CredentialAccount { CredentialAccount::VolcengineAppKey => "volcengine.app_key", CredentialAccount::VolcengineAccessKey => "volcengine.access_key", CredentialAccount::VolcengineResourceId => "volcengine.resource_id", + CredentialAccount::VolcengineService => "volcengine.service", CredentialAccount::VolcengineAuthMode => "volcengine.auth_mode", CredentialAccount::VolcengineApiKey => "volcengine.api_key", CredentialAccount::ArkApiKey => "ark.api_key", @@ -1877,6 +1887,7 @@ impl CredentialAccount { CredentialAccount::VolcengineAppKey, CredentialAccount::VolcengineAccessKey, CredentialAccount::VolcengineResourceId, + CredentialAccount::VolcengineService, CredentialAccount::VolcengineAuthMode, CredentialAccount::VolcengineApiKey, CredentialAccount::ArkApiKey, @@ -1905,6 +1916,7 @@ pub struct CredentialsSnapshot { pub volcengine_app_key: Option, pub volcengine_access_key: Option, pub volcengine_resource_id: Option, + pub volcengine_service: Option, pub volcengine_auth_mode: Option, pub volcengine_api_key: Option, pub asr_api_key: Option, @@ -1962,6 +1974,7 @@ fn credentials_snapshot(root: &CredsRoot, include_omni: bool) -> CredentialsSnap volcengine_app_key: lookup_account(root, CredentialAccount::VolcengineAppKey), volcengine_access_key: lookup_account(root, CredentialAccount::VolcengineAccessKey), volcengine_resource_id: lookup_account(root, CredentialAccount::VolcengineResourceId), + volcengine_service: lookup_account(root, CredentialAccount::VolcengineService), volcengine_auth_mode: lookup_account(root, CredentialAccount::VolcengineAuthMode), volcengine_api_key: lookup_account(root, CredentialAccount::VolcengineApiKey), asr_api_key: lookup_account(root, CredentialAccount::AsrApiKey), diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index a47dca7cf..6ac9918a9 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1353,6 +1353,12 @@ export const de: typeof zhCN = { volcengineAccessKeyLabel: 'Access Token', volcengineApiKeyLabel: 'API-Schlüssel', volcengineResourceIdLabel: 'Ressourcen-ID', + volcengineServiceLabel: 'Dienst', + volcengineServiceStandard: 'Standarddienst', + volcengineAgentPlanNote: + 'Verwende einen eigenen Agent-Plan-API-Schlüssel für Doubao Streaming-ASR. Standard-Resource-ID: volc.seedasr.sauc.duration. Standard- und Planschlüssel unterscheiden sich; verwende getrennte Kanäle.', + volcengineServiceInvalid: + 'Ungültige Dienstkonfiguration. Wähle Standarddienst oder Agent Plan erneut.', volcengineAuthModeLabel: 'Anmeldemethode', volcengineAuthModeAppIdToken: 'Bisherige App-Anmeldung (APP ID + Access Token)', volcengineAuthModeApiKey: 'API-Schlüssel (neue Konsole)', @@ -1458,6 +1464,10 @@ export const de: typeof zhCN = { modelSaved: 'Modell {{model}} gespeichert.', validateSuccess: 'Verbindungsprüfung bestanden.', validateFailed: 'Verbindungsprüfung fehlgeschlagen.', + arkTextModelsHint: + 'Wähle ein in der Konsole unterstütztes Textmodell, kein Bild-, Video- oder Embedding-Modell. Bei Plänen kannst du ark-code-latest eingeben und prüfen. Katalogeinträge garantieren keinen Zugriff im Plan.', + providerModelsUnavailable: + 'Dieser Endpunkt liefert keine Modellliste. Gib einen Textmodellnamen aus der Anbieter-Konsole ein und prüfe die Verbindung. Eine fehlende Liste bedeutet nicht, dass der API-Schlüssel ungültig ist.', providerHttpStatus: 'Der Anbieter hat HTTP {{status}} zurückgegeben. Prüfe die Berechtigungen des API-Schlüssels oder den Endpunkt.', endpointMustUseHttps: diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 6dda75f85..5255756b5 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1320,6 +1320,12 @@ export const en: typeof zhCN = { volcengineAccessKeyLabel: 'Access Token', volcengineApiKeyLabel: 'API Key', volcengineResourceIdLabel: 'Resource ID', + volcengineServiceLabel: 'Service', + volcengineServiceStandard: 'Standard service', + volcengineAgentPlanNote: + 'Use a dedicated Agent Plan API key for Doubao streaming ASR. Default Resource ID: volc.seedasr.sauc.duration. Standard and plan keys differ; use separate channels for each service.', + volcengineServiceInvalid: + 'Invalid service configuration. Select Standard service or Agent Plan again.', volcengineAuthModeLabel: 'Auth mode', volcengineAuthModeAppIdToken: 'Legacy app (APP ID + Access Token)', volcengineAuthModeApiKey: 'API Key (new console)', @@ -1422,6 +1428,10 @@ export const en: typeof zhCN = { modelSaved: 'Saved model {{model}}.', validateSuccess: 'Connection check passed.', validateFailed: 'Connection check failed.', + arkTextModelsHint: + 'Choose a text generation model supported by the console, not image, video or embedding models. For plans, you can enter ark-code-latest and verify. Catalog entries do not guarantee plan access.', + providerModelsUnavailable: + 'This endpoint could not provide a model list. Enter a text model name from the provider console, then verify the connection. An unavailable list does not mean the API key is invalid.', providerHttpStatus: 'Provider returned HTTP {{status}}. Check the API key permissions or endpoint.', endpointMustUseHttps: diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 75caf90ed..5285e676c 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1345,6 +1345,12 @@ export const es: typeof zhCN = { volcengineAccessKeyLabel: 'Access Token', volcengineApiKeyLabel: 'Clave API', volcengineResourceIdLabel: 'Resource ID', + volcengineServiceLabel: 'Servicio', + volcengineServiceStandard: 'Servicio estándar', + volcengineAgentPlanNote: + 'Usa una clave API exclusiva de Agent Plan para ASR en streaming de Doubao. Resource ID predeterminado: volc.seedasr.sauc.duration. Las claves son distintas; usa canales separados para cada servicio.', + volcengineServiceInvalid: + 'Configuración de servicio no válida. Selecciona de nuevo el servicio estándar o Agent Plan.', volcengineAuthModeLabel: 'Modo de autenticación', volcengineAuthModeAppIdToken: 'Aplicación anterior (APP ID + Access Token)', volcengineAuthModeApiKey: 'Clave API (consola nueva)', @@ -1449,6 +1455,10 @@ export const es: typeof zhCN = { modelSaved: 'Modelo {{model}} guardado.', validateSuccess: 'Conexión comprobada correctamente.', validateFailed: 'La comprobación de conexión falló.', + arkTextModelsHint: + 'Elige un modelo de generación de texto admitido en la consola, no de imagen, vídeo o embeddings. Para planes, puedes introducir ark-code-latest y verificar. El catálogo no garantiza acceso con el plan.', + providerModelsUnavailable: + 'Este endpoint no pudo proporcionar una lista de modelos. Introduce un nombre de modelo de texto de la consola del proveedor y verifica la conexión. Que la lista no esté disponible no significa que la clave API no sea válida.', providerHttpStatus: 'El proveedor devolvió HTTP {{status}}. Comprueba los permisos de la clave API o la dirección.', endpointMustUseHttps: diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 474540dcb..e21e7a787 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1362,6 +1362,12 @@ export const fr: typeof zhCN = { volcengineAccessKeyLabel: 'Access Token', volcengineApiKeyLabel: 'Clé API', volcengineResourceIdLabel: 'Resource ID', + volcengineServiceLabel: 'Service', + volcengineServiceStandard: 'Service standard', + volcengineAgentPlanNote: + 'Utilisez une clé API dédiée à Agent Plan pour la reconnaissance vocale en streaming Doubao. Resource ID par défaut : volc.seedasr.sauc.duration. Les clés sont différentes ; utilisez des canaux séparés.', + volcengineServiceInvalid: + 'Configuration du service invalide. Sélectionnez à nouveau le service standard ou Agent Plan.', volcengineAuthModeLabel: 'Mode d’authentification', volcengineAuthModeAppIdToken: 'Ancienne application (APP ID + Access Token)', volcengineAuthModeApiKey: 'Clé API (nouvelle console)', @@ -1469,6 +1475,10 @@ export const fr: typeof zhCN = { modelSaved: 'Modèle {{model}} enregistré.', validateSuccess: 'Connexion vérifiée avec succès.', validateFailed: 'Échec de la vérification de connexion.', + arkTextModelsHint: + 'Choisissez un modèle de génération de texte pris en charge dans la console, pas un modèle image, vidéo ou embedding. Pour les forfaits, saisissez ark-code-latest puis vérifiez. Le catalogue ne garantit pas l’accès avec le forfait.', + providerModelsUnavailable: + 'Ce point de terminaison ne fournit pas de liste de modèles. Saisissez un nom de modèle de texte depuis la console du fournisseur, puis vérifiez la connexion. Une liste indisponible ne signifie pas que la clé API est invalide.', providerHttpStatus: 'Le fournisseur a renvoyé HTTP {{status}}. Vérifiez les autorisations de la clé API ou l’adresse.', endpointMustUseHttps: diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 6294464c8..bfcdd138c 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1307,6 +1307,12 @@ export const ja: typeof zhCN = { volcengineAccessKeyLabel: 'Access Token', volcengineApiKeyLabel: 'API Key', volcengineResourceIdLabel: 'Resource ID', + volcengineServiceLabel: 'サービス', + volcengineServiceStandard: '通常サービス', + volcengineAgentPlanNote: + '豆包ストリーミング ASR 用の Agent Plan 専用 API キーを使用します。既定の Resource ID: volc.seedasr.sauc.duration。通常サービスとはキーが異なるため、別のチャネルを作成してください。', + volcengineServiceInvalid: + 'サービス設定が無効です。通常サービスまたは Agent Plan を選択してください。', volcengineAuthModeLabel: '認証モード', volcengineAuthModeAppIdToken: 'レガシーアプリ(APP ID + Access Token)', volcengineAuthModeApiKey: '新版コンソール API Key', @@ -1407,6 +1413,10 @@ export const ja: typeof zhCN = { modelSaved: 'モデル {{model}} を保存しました。', validateSuccess: '接続チェックに合格しました。', validateFailed: '接続チェックに失敗しました。', + arkTextModelsHint: + 'コンソールで対応するテキスト生成モデルを選択してください。画像・動画・埋め込みモデルは使用できません。プランでは ark-code-latest を入力して接続を確認できます。一覧への掲載はプランでの利用を保証しません。', + providerModelsUnavailable: + 'このエンドポイントではモデル一覧を取得できません。プロバイダーのコンソールにあるテキストモデル名を入力し、接続を確認してください。一覧を取得できなくても、API キーが無効とは限りません。', providerHttpStatus: 'サプライヤーが {{status}} を返しました。API Key 権限またはエンドポイントを確認してください。', endpointMustUseHttps: diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index e5cd929d8..d13bce441 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1299,6 +1299,12 @@ export const ko: typeof zhCN = { volcengineAccessKeyLabel: 'Access Token', volcengineApiKeyLabel: 'API Key', volcengineResourceIdLabel: 'Resource ID', + volcengineServiceLabel: '서비스', + volcengineServiceStandard: '일반 서비스', + volcengineAgentPlanNote: + 'Doubao 스트리밍 ASR용 Agent Plan 전용 API 키를 사용하세요. 기본 Resource ID: volc.seedasr.sauc.duration. 일반 서비스와 키가 다르므로 별도 채널을 사용하세요.', + volcengineServiceInvalid: + '잘못된 서비스 설정입니다. 일반 서비스 또는 Agent Plan을 다시 선택하세요.', volcengineAuthModeLabel: '인증 모드', volcengineAuthModeAppIdToken: '레거시 앱 (APP ID + Access Token)', volcengineAuthModeApiKey: '새 콘솔 API Key', @@ -1399,6 +1405,10 @@ export const ko: typeof zhCN = { modelSaved: '모델 {{model}} 을(를) 저장했습니다.', validateSuccess: '연결 확인을 통과했습니다.', validateFailed: '연결 확인에 실패했습니다.', + arkTextModelsHint: + '콘솔에서 지원하는 텍스트 생성 모델을 선택하세요. 이미지, 영상, 임베딩 모델은 사용할 수 없습니다. 요금제는 ark-code-latest를 입력한 뒤 연결을 확인할 수 있습니다. 목록에 있어도 요금제에서 지원되지 않을 수 있습니다.', + providerModelsUnavailable: + '이 엔드포인트에서 모델 목록을 가져올 수 없습니다. 제공업체 콘솔의 텍스트 모델 이름을 입력한 뒤 연결을 확인하세요. 목록을 가져올 수 없다고 API 키가 유효하지 않은 것은 아닙니다.', providerHttpStatus: '공급자가 {{status}} 를 반환했습니다. API Key 권한 또는 Endpoint 를 확인해 주세요.', endpointMustUseHttps: diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index cef80f8c5..b6f9a49e5 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1258,6 +1258,11 @@ export const zhCN = { volcengineAccessKeyLabel: 'Access Token', volcengineApiKeyLabel: 'API Key', volcengineResourceIdLabel: 'Resource ID', + volcengineServiceLabel: '服务', + volcengineServiceStandard: '普通服务', + volcengineAgentPlanNote: + '使用 Agent Plan 专属 API Key;仅支持豆包流式 ASR。默认 Resource ID 为 volc.seedasr.sauc.duration。普通服务密钥与套餐密钥不同,建议分别创建渠道。', + volcengineServiceInvalid: '服务配置无效,请重新选择普通服务或 Agent Plan。', volcengineAuthModeLabel: '鉴权模式', volcengineAuthModeAppIdToken: '旧版应用(APP ID + Access Token)', volcengineAuthModeApiKey: '新版控制台 API Key', @@ -1350,6 +1355,10 @@ export const zhCN = { modelSaved: '已保存模型 {{model}}。', validateSuccess: '连接检查通过。', validateFailed: '连接检查未通过。', + arkTextModelsHint: + '仅选择控制台支持的文本生成模型;图片、视频和向量化模型不能用于润色。套餐可填写 ark-code-latest 后验证连接,目录返回不代表套餐均可调用。', + providerModelsUnavailable: + '当前 Endpoint 无法提供模型列表。请填写供应商控制台中的文本模型名称,再点击“验证”。列表不可用不代表 API Key 无效。', providerHttpStatus: '供应商接口返回 {{status}},请检查 API Key 权限或 Endpoint。', endpointMustUseHttps: '允许使用 HTTP Endpoint,但请注意:API Key 和音频内容可能在传输中泄漏。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index c07146e6a..79246a2a2 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1260,6 +1260,11 @@ export const zhTW: typeof zhCN = { volcengineAccessKeyLabel: 'Access Token', volcengineApiKeyLabel: 'API Key', volcengineResourceIdLabel: 'Resource ID', + volcengineServiceLabel: '服務', + volcengineServiceStandard: '一般服務', + volcengineAgentPlanNote: + '使用 Agent Plan 專屬 API Key;僅支援豆包串流 ASR。預設 Resource ID 為 volc.seedasr.sauc.duration。一般服務與套餐密鑰不同,建議分別建立渠道。', + volcengineServiceInvalid: '服務設定無效,請重新選擇一般服務或 Agent Plan。', volcengineAuthModeLabel: '鑑權模式', volcengineAuthModeAppIdToken: '舊版應用(APP ID + Access Token)', volcengineAuthModeApiKey: '新版控制台 API Key', @@ -1352,6 +1357,10 @@ export const zhTW: typeof zhCN = { modelSaved: '已保存模型 {{model}}。', validateSuccess: '連接檢查通過。', validateFailed: '連接檢查未通過。', + arkTextModelsHint: + '僅選擇控制台支援的文字生成模型;圖片、影片和向量化模型不能用於潤色。套餐可填寫 ark-code-latest 後驗證連線,清單不代表套餐均可呼叫。', + providerModelsUnavailable: + '目前 Endpoint 無法提供模型清單。請填寫供應商控制台中的文字模型名稱,再點擊「驗證」。清單不可用不代表 API Key 無效。', providerHttpStatus: '供應商接口返回 {{status}},請檢查 API Key 權限或 Endpoint。', endpointMustUseHttps: '允許使用 HTTP Endpoint,但請注意:API Key 和音訊內容可能在傳輸中外洩。', diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index e35587be7..8e6722281 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -211,16 +211,42 @@ export function ChannelCredentialFields({ 'app_id_token', ); + const providerForm = useContext(ProviderFormContext); + const formTrack = providerForm?.track; + const trackVolcengineSetting = useCallback( + (account: string, blocked: boolean) => { + trackField(account, blocked); + formTrack?.(account, blocked); + }, + [trackField, formTrack], + ); + const [volcengineService, setVolcengineService] = useState('standard'); + const onAsrMutation = () => { + onUserMutation?.(); + setConfigRevision((value) => value + 1); + }; + useEffect(() => { - if (providerType === 'volcengine') { - readCredential('volcengine.auth_mode', channelId) - .then((v) => { - if (v === 'api_key') setVolcengineAuthMode('api_key'); - else setVolcengineAuthMode('app_id_token'); - }) - .catch(() => setVolcengineAuthMode('app_id_token')); - } - }, [providerType, channelId]); + if (providerType !== 'volcengine') return; + let cancelled = false; + trackField('volcengine.config', true); + Promise.all([ + readCredential('volcengine.service', channelId), + readCredential('volcengine.auth_mode', channelId), + ]) + .then(([service, mode]) => { + if (cancelled) return; + setVolcengineService(service || 'standard'); + setVolcengineAuthMode(mode === 'api_key' ? 'api_key' : 'app_id_token'); + trackField('volcengine.config', false); + }) + .catch(() => { + if (!cancelled) emitSaved('failed', t('common.operationFailed')); + }); + return () => { + cancelled = true; + }; + }, [providerType, channelId, trackField, t]); useEffect(() => { if (!unifiedBailian) setBailianModel(''); @@ -373,6 +399,11 @@ export function ChannelCredentialFields({ onBlockedChange={trackField} /> )} + {providerType === 'ark' && ( +

+ {t('settings.providers.arkTextModelsHint')} +

+ )} blockedFields[account]); return ( <> - + { - onUserMutation?.(); - const mode = v as 'app_id_token' | 'api_key'; - const prev = volcengineAuthMode; - setVolcengineAuthMode(mode); + value={volcengineService} + disabled={blocked} + onChange={async (service) => { + onAsrMutation(); + const previous = volcengineService; + setVolcengineService(service); + trackVolcengineSetting('volcengine.service', true); try { - await setCredential('volcengine.auth_mode', mode, channelId); + await setCredential('volcengine.service', service, channelId); + onTested?.(); } catch (error) { - // 写入失败必须回滚 UI 并提示:否则模式看着已切换、重启后却静默回退, - // 配合独立 API Key 槽会造成「Key 存在但模式不对」的混乱。 - console.error('[settings] failed to save volcengine auth mode', error); - setVolcengineAuthMode(prev); + console.error('[settings] failed to save volcengine service', error); + setVolcengineService(previous); emitSaved('failed', t('common.operationFailed')); + } finally { + trackVolcengineSetting('volcengine.service', false); } }} options={[ - { - value: 'app_id_token', - label: t('settings.providers.volcengineAuthModeAppIdToken'), - }, - { value: 'api_key', label: t('settings.providers.volcengineAuthModeApiKey') }, + { value: 'standard', label: t('settings.providers.volcengineServiceStandard') }, + { value: 'agent_plan', label: 'Agent Plan' }, ]} - ariaLabel={t('settings.providers.volcengineAuthModeLabel')} + ariaLabel={t('settings.providers.volcengineServiceLabel')} style={{ ...inputStyle, width: '100%', maxWidth: '100%', height: 38 }} /> + {!agentPlan && ( + + { + onAsrMutation(); + const mode = v as 'app_id_token' | 'api_key'; + const prev = volcengineAuthMode; + setVolcengineAuthMode(mode); + trackVolcengineSetting('volcengine.auth_mode', true); + try { + await setCredential('volcengine.auth_mode', mode, channelId); + onTested?.(); + } catch (error) { + // 写入失败必须回滚 UI 并提示:否则模式看着已切换、重启后却静默回退, + // 配合独立 API Key 槽会造成「Key 存在但模式不对」的混乱。 + console.error('[settings] failed to save volcengine auth mode', error); + setVolcengineAuthMode(prev); + emitSaved('failed', t('common.operationFailed')); + } finally { + trackVolcengineSetting('volcengine.auth_mode', false); + } + }} + options={[ + { + value: 'app_id_token', + label: t('settings.providers.volcengineAuthModeAppIdToken'), + }, + { value: 'api_key', label: t('settings.providers.volcengineAuthModeApiKey') }, + ]} + ariaLabel={t('settings.providers.volcengineAuthModeLabel')} + style={{ ...inputStyle, width: '100%', maxWidth: '100%', height: 38 }} + /> + + )} {/* 两种模式使用各自独立的凭据槽位:旧版 Access Token(volcengine.access_key) 与方舟 API Key(volcengine.api_key)互不预填,切换模式不会残留混淆。 */} - {volcengineAuthMode === 'app_id_token' ? ( + {!agentPlan && volcengineAuthMode === 'app_id_token' ? ( <> ) : ( @@ -457,7 +536,8 @@ export function ChannelCredentialFields({ provider={channelId} mono mask - onUserMutation={onUserMutation} + onUserMutation={onAsrMutation} + onBlockedChange={trackField} /> )}
@@ -469,16 +549,21 @@ export function ChannelCredentialFields({ account="volcengine.resource_id" provider={channelId} mono - onUserMutation={onUserMutation} + onUserMutation={onAsrMutation} + onBlockedChange={trackField} placeholder={ASR_DEFAULT_RESOURCE_ID} defaultValue={ASR_DEFAULT_RESOURCE_ID} />
- {volcengineAuthMode === 'api_key' - ? t('settings.providers.volcengineApiKeyNote') - : t('settings.providers.volcengineMappingNote')} + {agentPlan + ? t('settings.providers.volcengineAgentPlanNote') + : volcengineAuthMode === 'api_key' + ? t('settings.providers.volcengineApiKeyNote') + : t('settings.providers.volcengineMappingNote')}
['t']): string { const message = error instanceof Error ? error.message : String(error); for (const code of [ + 'providerModelsUnavailable', + 'volcengineServiceInvalid', 'llmRequestFormatInvalid', 'llmThinkingModeInvalid', 'llmTokenLimitInvalid',