From 8ddfec4b14ac81b6d5ff03efd6755b5536a67346 Mon Sep 17 00:00:00 2001 From: MikuHello <97345342+MikuHello@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:11:56 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix(llm):=20=E4=BF=9D=E7=95=99=E6=B8=A9?= =?UTF-8?q?=E5=BA=A6=E5=8F=82=E6=95=B0=E7=9A=84=E7=AE=80=E7=9F=AD=E5=8D=81?= =?UTF-8?q?=E8=BF=9B=E5=88=B6=E8=A1=A8=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../crates/openless-core/src/llm_protocol.rs | 2 +- .../app/crates/openless-core/src/polish.rs | 71 +++++++++++++++++-- 2 files changed, 67 insertions(+), 6 deletions(-) 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"); } } From 656194a41dadff5f77394345a4710ad9c8ed6243 Mon Sep 17 00:00:00 2001 From: MikuHello <97345342+MikuHello@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:11:57 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(asr):=20=E6=8E=A5=E5=85=A5=E7=81=AB?= =?UTF-8?q?=E5=B1=B1=20Agent=20Plan=20=E8=AF=AD=E9=9F=B3=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/volcengine-setup.md | 23 ++- .../openless-core/src/asr/volcengine.rs | 98 ++++++++++-- .../openless-core/src/cloud_providers.rs | 13 ++ .../crates/openless-core/src/credentials.rs | 1 + .../openless-core/src/credentials_legacy.rs | 4 + .../openless-core/src/provider_rules.rs | 22 ++- .../tests/volcengine_credentials.rs | 30 ++++ .../app/linux-egui/src/credentials.rs | 24 +++ openless-all/app/linux-egui/src/main.rs | 75 +++++++-- .../app/src-tauri/src/commands/credentials.rs | 3 + .../src-tauri/src/persistence/credentials.rs | 13 ++ openless-all/app/src/i18n/de.ts | 6 + openless-all/app/src/i18n/en.ts | 6 + openless-all/app/src/i18n/es.ts | 6 + openless-all/app/src/i18n/fr.ts | 6 + openless-all/app/src/i18n/ja.ts | 6 + openless-all/app/src/i18n/ko.ts | 6 + openless-all/app/src/i18n/zh-CN.ts | 5 + openless-all/app/src/i18n/zh-TW.ts | 5 + .../src/pages/settings/ProvidersSection.tsx | 151 ++++++++++++++---- 20 files changed, 429 insertions(+), 74 deletions(-) create mode 100644 openless-all/app/crates/openless-core/tests/volcengine_credentials.rs diff --git a/docs/volcengine-setup.md b/docs/volcengine-setup.md index 842a703f9..f46db57bb 100644 --- a/docs/volcengine-setup.md +++ b/docs/volcengine-setup.md @@ -1,19 +1,18 @@ -# 火山引擎(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)。 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..7554d9f23 100644 --- a/openless-all/app/crates/openless-core/src/asr/volcengine.rs +++ b/openless-all/app/crates/openless-core/src/asr/volcengine.rs @@ -16,7 +16,10 @@ 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, + 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) } } @@ -336,11 +370,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 +388,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 +436,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)?; + 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 { @@ -1047,21 +1105,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(), @@ -1182,6 +1257,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/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 6ad7919b4..0dd774250 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 } 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..0ec7eb2a0 --- /dev/null +++ b/openless-all/app/crates/openless-core/tests/volcengine_credentials.rs @@ -0,0 +1,30 @@ +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") + ); +} 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..5f7440969 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, @@ -2230,6 +2231,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 +2287,7 @@ mod linux_app { descriptor, endpoint, model, + volcengine_service, auth_mode, resource_id, app_id, @@ -2300,21 +2315,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 +2458,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 +2490,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..050a5a9a1 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)', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 6dda75f85..ee1e3ead0 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)', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 75caf90ed..684b9602e 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)', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 474540dcb..6ae1a4088 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)', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 6294464c8..1a02782bc 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', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index e5cd929d8..e811365e2 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', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index cef80f8c5..e3b4727a5 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', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index c07146e6a..3d9c6f0ac 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', diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index e35587be7..8df7ab986 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(''); @@ -395,40 +421,86 @@ export function ChannelCredentialFields({ const defaultModel = descriptor?.defaultModel; if (descriptor?.authRequirement === 'volcengine') { + const agentPlan = volcengineService === 'agent_plan'; + const configBlocked = blockedFields['volcengine.config'] !== false; + const activeAccounts = [ + 'volcengine.service', + 'volcengine.auth_mode', + 'volcengine.resource_id', + ...(!agentPlan && volcengineAuthMode === 'app_id_token' + ? ['volcengine.app_key', 'volcengine.access_key'] + : ['volcengine.api_key']), + ]; + const blocked = configBlocked || activeAccounts.some((account) => 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 +531,8 @@ export function ChannelCredentialFields({ provider={channelId} mono mask - onUserMutation={onUserMutation} + onUserMutation={onAsrMutation} + onBlockedChange={trackField} /> )}
@@ -469,16 +544,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 [ + 'volcengineServiceInvalid', 'llmRequestFormatInvalid', 'llmThinkingModeInvalid', 'llmTokenLimitInvalid', From b4af52340fa2ec225900e3dc7630332b7deaf296 Mon Sep 17 00:00:00 2001 From: MikuHello <97345342+MikuHello@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:11:57 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat(providers):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=81=AB=E5=B1=B1=E5=A5=97=E9=A4=90=E6=9C=8D=E5=8A=A1=E4=B8=8E?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=8E=A7=E5=88=B6=E5=8F=B0=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/volcengine-setup.md | 15 + .../crates/openless-core/src/llm_protocol.rs | 44 ++- .../openless-core/src/provider_rules.rs | 78 ++++ openless-all/app/linux-egui/src/main.rs | 63 +++- openless-all/app/src/i18n/de.ts | 3 + openless-all/app/src/i18n/en.ts | 3 + openless-all/app/src/i18n/es.ts | 3 + openless-all/app/src/i18n/fr.ts | 3 + openless-all/app/src/i18n/ja.ts | 3 + openless-all/app/src/i18n/ko.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + .../lib/ipc/mock-provider-descriptors.json | 12 + openless-all/app/src/lib/ipc/providers.ts | 1 + .../app/src/pages/settings/ChannelList.tsx | 2 + .../src/pages/settings/ProvidersSection.tsx | 340 +++++++++++------- 16 files changed, 440 insertions(+), 136 deletions(-) diff --git a/docs/volcengine-setup.md b/docs/volcengine-setup.md index f46db57bb..952873239 100644 --- a/docs/volcengine-setup.md +++ b/docs/volcengine-setup.md @@ -16,3 +16,18 @@ Resource ID 留空时使用 `volc.seedasr.sauc.duration`;Agent Plan 豆包流 “验证”发送可取消的静音探针,允许没有最终识别结果;连接验证成功后,还应通过实际录音检查转写及插入。连接日志包含端点、连接/请求 ID 和服务端 Log ID,不包含鉴权头。 官方依据:[Agent Plan 接入语音模型](https://docs.volcengine.com/docs/82379/2516286?lang=zh)、[普通流式语音识别](https://www.volcengine.com/docs/6561/1354869?lang=zh)。 + +## Ark 语言模型套餐 + +设置 → AI 服务 → 文本润色 → 添加火山方舟渠道,在“服务”中选择火山方舟、Agent Plan 或 Coding Plan。选择服务会填入对应 Endpoint,预设地址只读;自定义接口使用现有自定义供应商入口。旧渠道已有的自定义地址保持可编辑,不会自动改写;API Key 和模型沿用现有字段。Agent Plan 与 Coding Plan 均支持语言模型;只有 Agent Plan 提供本页接入的套餐语音识别。 + +套餐使用各自的专属 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 不代表该套餐均可调用,实际能力以控制台及连接验证为准。 + +选择 Agent Plan 或 Coding Plan 后,“可用模型”处显示“查看支持的模型”,分别打开对应的[Agent Plan 控制台](https://console.volcengine.com/ark/subscription/agent-plan)或[Coding Plan 控制台](https://console.volcengine.com/ark/subscription/coding-plan)。从控制台复制当前套餐支持的文本模型 ID,填入“模型”字段后验证连接。该按钮不请求在线模型目录,也不验证 API Key。 + +火山方舟标准服务及其他供应商保留原有模型列表获取行为。套餐的 API Key、模型手填和连接验证沿用现有字段与流程。 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 6f76f7104..47ebd14f6 100644 --- a/openless-all/app/crates/openless-core/src/llm_protocol.rs +++ b/openless-all/app/crates/openless-core/src/llm_protocol.rs @@ -52,14 +52,26 @@ impl LlmRequestFormat { } pub fn url(self, endpoint: &str) -> Result { - endpoint_url( - endpoint, - match self { - Self::ChatCompletions => "/chat/completions", - Self::Responses => "/responses", - Self::Messages => "/messages", - }, - ) + let suffix = match self { + Self::ChatCompletions => "/chat/completions", + Self::Responses => "/responses", + Self::Messages => "/messages", + }; + let endpoint = endpoint_url(endpoint, suffix)?; + let mut url = url::Url::parse(&endpoint) + .map_err(|_| LLMError::ParseError("invalid LLM endpoint".into()))?; + // 火山套餐的 Messages 使用 /api/{plan},OpenAI 兼容格式使用 /v3。 + // 只适配官方套餐路径,自定义网关及普通方舟保持原样。 + if url.scheme() == "https" && url.host_str() == Some("ark.cn-beijing.volces.com") { + let prefix = url.path().strip_suffix(suffix).unwrap_or_default(); + let plan = prefix.strip_suffix("/v3").unwrap_or(prefix); + if matches!(plan, "/api/plan" | "/api/coding") { + let version = if self == Self::Messages { "" } else { "/v3" }; + let path = format!("{plan}{version}{suffix}"); + url.set_path(&path); + } + } + Ok(url.to_string()) } pub fn headers(self, api_key: &str) -> Vec<(String, String)> { @@ -596,6 +608,22 @@ mod tests { "https://example.com/gateway/v1/models?tenant=1#local" ); } + // 套餐的 Messages 与 OpenAI 兼容地址使用不同的版本前缀。 + for plan in ["plan", "coding"] { + for base in [format!("/api/{plan}"), format!("/api/{plan}/v3")] { + let prefix = if format == LlmRequestFormat::Messages { + format!("/api/{plan}") + } else { + format!("/api/{plan}/v3") + }; + assert_eq!( + format + .url(&format!("https://ark.cn-beijing.volces.com{base}")) + .unwrap(), + format!("https://ark.cn-beijing.volces.com{prefix}/{suffix}") + ); + } + } let headers = format.headers("test-key"); if format == LlmRequestFormat::Messages { assert!(headers.contains(&("x-api-key".into(), "test-key".into()))); 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 0dd774250..493e8895f 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -135,6 +135,15 @@ pub enum ValidationProbe { OmniText, } +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderEndpointPreset { + pub name: String, + pub endpoint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub models_url: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProviderDescriptor { @@ -142,6 +151,8 @@ pub struct ProviderDescriptor { pub provider_type: ProviderType, pub label_key: String, pub default_endpoint: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub endpoint_presets: Vec, pub default_model: Option, pub auth_requirement: AuthRequirement, pub validation_probe: ValidationProbe, @@ -150,6 +161,28 @@ pub struct ProviderDescriptor { pub supported_request_formats: Vec, } +/// Match a service preset without treating custom URL credentials or parameters as presets. +pub fn matches_endpoint_preset(endpoint: &str, preset: &str) -> bool { + let (Ok(current), Ok(preset)) = (url::Url::parse(endpoint.trim()), url::Url::parse(preset)) + else { + return false; + }; + let base_path = |path: &str| { + let path = path.trim_end_matches('/'); + let path = ["/chat/completions", "/responses", "/messages", "/models"] + .iter() + .find_map(|suffix| path.strip_suffix(suffix)) + .unwrap_or(path); + path.strip_suffix("/v3").unwrap_or(path).to_string() + }; + current.origin() == preset.origin() + && current.query().is_none() + && current.fragment().is_none() + && current.username().is_empty() + && current.password().is_none() + && base_path(current.path()) == base_path(preset.path()) +} + pub fn provider_descriptors(kind: ProviderKind) -> Vec { let providers = match kind { ProviderKind::Asr => ASR_PROVIDER_TYPES, @@ -249,6 +282,29 @@ fn provider_descriptor_with_label( provider_type, label_key: label_key.to_string(), default_endpoint: default_endpoint.map(str::to_string), + endpoint_presets: if kind == ProviderKind::Llm && id == "ark" { + [ + ( + "Agent Plan", + "https://ark.cn-beijing.volces.com/api/plan/v3", + "https://console.volcengine.com/ark/subscription/agent-plan", + ), + ( + "Coding Plan", + "https://ark.cn-beijing.volces.com/api/coding/v3", + "https://console.volcengine.com/ark/subscription/coding-plan", + ), + ] + .into_iter() + .map(|(name, endpoint, models_url)| ProviderEndpointPreset { + name: name.to_string(), + endpoint: endpoint.to_string(), + models_url: Some(models_url.to_string()), + }) + .collect() + } else { + Vec::new() + }, default_model: default_model.map(str::to_string), auth_requirement, validation_probe, @@ -982,6 +1038,28 @@ pub fn whisper_transcribe_timeout(audio_secs: f64) -> Duration { mod tests { use super::*; + #[test] + fn service_presets_match_equivalent_urls_but_preserve_custom_urls() { + let preset = "https://ark.cn-beijing.volces.com/api/plan/v3"; + for endpoint in [ + preset, + "https://ARK.CN-BEIJING.VOLCES.COM:443/api/plan/v3", + "https://ark.cn-beijing.volces.com/api/plan/messages", + ] { + assert!(matches_endpoint_preset(endpoint, preset)); + } + for endpoint in [ + "https://ark.cn-beijing.volces.com/api/plan/v3?tenant=1", + "https://ark.cn-beijing.volces.com/api/plan/v3#custom", + "https://user@ark.cn-beijing.volces.com/api/plan/v3", + "http://ark.cn-beijing.volces.com/api/plan/v3", + "https://ark.cn-beijing.volces.com/api/coding/v3", + "invalid", + ] { + assert!(!matches_endpoint_preset(endpoint, preset)); + } + } + #[test] fn routes_bailian_and_stepfun_models() { assert_eq!( diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 5f7440969..627b31bb1 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -1467,7 +1467,21 @@ mod linux_app { }); } } - if ui.button("列出模型").clicked() { + if let Some(url) = editor + .descriptor + .endpoint_presets + .iter() + .find(|preset| { + openless_core::provider_rules::matches_endpoint_preset( + &editor.endpoint, + &preset.endpoint, + ) + }) + .and_then(|preset| preset.models_url.as_deref()) + { + self.provider_models.clear(); + ui.hyperlink_to("查看支持的模型", url); + } else if ui.button("列出模型").clicked() { self.provider_models.clear(); self.request_provider_models(editor.kind, editor.channel.id.clone()); } @@ -2394,9 +2408,54 @@ mod linux_app { } _ => { secret_edit(ui, "API Key(留空表示不修改)", &mut editor.primary_secret); + let mut endpoint_read_only = false; + if editor.kind == openless_core::ChannelKind::Llm + && editor.channel.provider_type == "ark" + { + let presets = editor + .descriptor + .default_endpoint + .as_deref() + .map(|endpoint| ("火山方舟", endpoint)) + .into_iter() + .chain( + editor + .descriptor + .endpoint_presets + .iter() + .map(|preset| (preset.name.as_str(), preset.endpoint.as_str())), + ) + .collect::>(); + let selected = presets + .iter() + .find(|(_, endpoint)| { + openless_core::provider_rules::matches_endpoint_preset( + &editor.endpoint, + endpoint, + ) + }) + .map(|(label, _)| *label); + endpoint_read_only = selected.is_some(); + egui::ComboBox::from_id_salt("ark-service") + .selected_text(selected.unwrap_or("自定义")) + .show_ui(ui, |ui| { + for (label, endpoint) in presets { + if ui + .selectable_label(selected == Some(label), label) + .clicked() + { + editor.endpoint = endpoint.to_string(); + endpoint_read_only = true; + } + } + }); + } ui.horizontal(|ui| { ui.label("Endpoint"); - ui.text_edit_singleline(&mut editor.endpoint); + ui.add( + egui::TextEdit::singleline(&mut editor.endpoint) + .interactive(!endpoint_read_only), + ); }); ui.horizontal(|ui| { ui.label("Model"); diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 050a5a9a1..dd8dde5fe 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1449,6 +1449,9 @@ export const de: typeof zhCN = { 'Speichere die Felder oben und prüfe anschließend das gewählte Modell oder rufe Modelle ab. Falls das Abrufen fehlschlägt, bleibt die manuelle Eingabe möglich.', validate: 'Prüfen', validating: 'Wird geprüft…', + planModelsHint: + 'Öffnen Sie die Tarifkonsole, kopieren Sie eine unterstützte Textmodell-ID und tragen Sie sie im Modellfeld ein.', + viewModels: 'Unterstützte Modelle ansehen', fetchModels: 'Modelle abrufen', loadingModels: 'Modelle werden abgerufen…', modelMissing: 'Kein Modell eingerichtet. Gib zuerst eine Modell-ID ein.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index ee1e3ead0..5eed04b52 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1419,6 +1419,9 @@ export const en: typeof zhCN = { 'Save the fields above, then validate the selected model or fetch models. Manual model input remains available if fetching fails.', validate: 'Validate', validating: 'Validating…', + planModelsHint: + 'Open the plan console, copy a supported text model ID, and enter it in the model field.', + viewModels: 'View supported models', fetchModels: 'Fetch models', loadingModels: 'Fetching models…', modelMissing: 'No model is configured. Please enter a model ID first.', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 684b9602e..ec0b9702a 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1440,6 +1440,9 @@ export const es: typeof zhCN = { 'Guarda los campos de arriba y después comprueba el modelo o consulta los modelos disponibles. Si la consulta falla, puedes escribir el modelo manualmente.', validate: 'Comprobar', validating: 'Comprobando…', + planModelsHint: + 'Abra la consola del plan, copie un ID de modelo de texto compatible e introdúzcalo en el campo del modelo.', + viewModels: 'Ver modelos compatibles', fetchModels: 'Obtener modelos', loadingModels: 'Obteniendo modelos…', modelMissing: 'No hay ningún modelo configurado. Introduce primero su ID.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 6ae1a4088..0da853d59 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1460,6 +1460,9 @@ export const fr: typeof zhCN = { 'Enregistrez les champs ci-dessus, puis vérifiez le modèle choisi ou récupérez les modèles disponibles. Vous pouvez saisir un modèle manuellement si la récupération échoue.', validate: 'Vérifier', validating: 'Vérification…', + planModelsHint: + 'Ouvrez la console du forfait, copiez un ID de modèle de texte pris en charge et collez-le dans le champ du modèle.', + viewModels: 'Voir les modèles pris en charge', fetchModels: 'Récupérer les modèles', loadingModels: 'Récupération des modèles…', modelMissing: 'Aucun modèle configuré. Saisissez d’abord un ID de modèle.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 1a02782bc..e0e11d66f 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1404,6 +1404,9 @@ export const ja: typeof zhCN = { '上記の設定を保存してから、現在のモデル接続性を検証またはモデル一覧を取得します。失敗してもモデル ID を手動入力できます。', validate: '検証', validating: '検証中…', + planModelsHint: + 'プランのコンソールで対応するテキストモデル ID をコピーし、モデル欄に入力してください。', + viewModels: '対応モデルを確認', fetchModels: 'モデル一覧', loadingModels: 'モデル取得中…', modelMissing: 'モデルが未設定です。先にモデル ID を入力してください。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index e811365e2..59b39bda3 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1396,6 +1396,8 @@ export const ko: typeof zhCN = { '위 설정을 먼저 저장한 후 현재 모델 연결성을 검증하거나 모델을 가져오세요. 실패해도 모델 ID 를 수동 입력할 수 있습니다.', validate: '검증', validating: '검증 중…', + planModelsHint: '요금제 콘솔에서 지원되는 텍스트 모델 ID를 복사해 모델 필드에 입력하세요.', + viewModels: '지원 모델 보기', fetchModels: '모델 가져오기', loadingModels: '모델 가져오는 중…', modelMissing: '모델이 설정되지 않았습니다. 먼저 모델 ID 를 입력해 주세요.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index e3b4727a5..2bfcd17b1 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1346,6 +1346,8 @@ export const zhCN = { toolsDesc: '先保存上方配置,再验证当前模型连通性或拉取模型;失败时仍可手动填写模型 ID。', validate: '验证', validating: '验证中…', + planModelsHint: '打开套餐控制台,复制支持的文本模型 ID 并填写到模型栏。', + viewModels: '查看支持的模型', fetchModels: '拉取模型', loadingModels: '拉取模型中…', modelMissing: '未配置模型,请先填写模型 ID。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 3d9c6f0ac..57f4ddf96 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1348,6 +1348,8 @@ export const zhTW: typeof zhCN = { toolsDesc: '先保存上方配置,再驗證當前模型連通性或拉取模型;失敗時仍可手動填寫模型 ID。', validate: '驗證', validating: '驗證中…', + planModelsHint: '開啟方案控制台,複製支援的文字模型 ID 並填入模型欄位。', + viewModels: '查看支援的模型', fetchModels: '拉取模型', loadingModels: '拉取模型中…', modelMissing: '未配置模型,請先填寫模型 ID。', diff --git a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json index 4b5a47f9b..5d0559a39 100644 --- a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json +++ b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json @@ -4,6 +4,18 @@ "providerType": "ark", "labelKey": "ark", "defaultEndpoint": "https://ark.cn-beijing.volces.com/api/v3", + "endpointPresets": [ + { + "endpoint": "https://ark.cn-beijing.volces.com/api/plan/v3", + "modelsUrl": "https://console.volcengine.com/ark/subscription/agent-plan", + "name": "Agent Plan" + }, + { + "endpoint": "https://ark.cn-beijing.volces.com/api/coding/v3", + "modelsUrl": "https://console.volcengine.com/ark/subscription/coding-plan", + "name": "Coding Plan" + } + ], "defaultModel": "deepseek-v3-2", "authRequirement": "api_key_unless_custom_endpoint", "validationProbe": "llm_text", diff --git a/openless-all/app/src/lib/ipc/providers.ts b/openless-all/app/src/lib/ipc/providers.ts index 7ecc08d2a..04ca73957 100644 --- a/openless-all/app/src/lib/ipc/providers.ts +++ b/openless-all/app/src/lib/ipc/providers.ts @@ -19,6 +19,7 @@ export interface ProviderDescriptor { providerType: string; labelKey: string; defaultEndpoint: string | null; + endpointPresets?: { name: string; endpoint: string; modelsUrl?: string }[]; defaultModel: string | null; authRequirement: AuthRequirement; validationProbe: string; diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index 5807e0b23..c71219475 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -61,6 +61,7 @@ interface PresetOption { id: string; nameKey: string; defaultEndpoint?: string; + endpointPresets?: ProviderDescriptor['endpointPresets']; defaultModel?: string; authRequirement?: ProviderDescriptor['authRequirement']; staticModels?: string[]; @@ -81,6 +82,7 @@ export function presetsFor( id: descriptor.providerType, nameKey: descriptor.labelKey, defaultEndpoint: descriptor.defaultEndpoint ?? undefined, + endpointPresets: descriptor.endpointPresets, defaultModel: descriptor.defaultModel ?? undefined, authRequirement: descriptor.authRequirement, staticModels: descriptor.staticModels, diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 8df7ab986..2b1a33bda 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next'; import { Icon } from '../../components/Icon'; import { listProviderModels, + openExternal, listProviderDescriptors, readCredential, recordChannelTest, @@ -154,6 +155,28 @@ const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration'; /** 模型预设下拉里的「自定义模型…」哨兵值:选中即切回输入框手输。 */ const CUSTOM_MODEL_OPTION_VALUE = '__custom_model__'; +function matchesEndpointPreset(value: string, endpoint: string): boolean { + try { + const current = new URL(value.trim()); + const preset = new URL(endpoint); + const basePath = (path: string) => + path + .replace(/\/$/, '') + .replace(/\/(chat\/completions|responses|messages|models)$/, '') + .replace(/\/v3$/, ''); + return ( + current.origin === preset.origin && + !current.search && + !current.hash && + !current.username && + !current.password && + basePath(current.pathname) === basePath(preset.pathname) + ); + } catch { + return false; + } +} + /** * 一张渠道卡片的凭据字段区(编辑弹窗的主体)。 * @@ -177,6 +200,7 @@ export function ChannelCredentialFields({ ProviderDescriptor, | 'authRequirement' | 'defaultEndpoint' + | 'endpointPresets' | 'defaultModel' | 'staticModels' | 'defaultRequestFormat' @@ -190,6 +214,7 @@ export function ChannelCredentialFields({ }) { const { t } = useTranslation(); const { prefs, updatePrefs } = useHotkeySettings(); + const [llmEndpoint, setLlmEndpoint] = useState(''); const [llmModelRevision, setLlmModelRevision] = useState(0); const [configRevision, setConfigRevision] = useState(0); const [orcarouterCatalogRevision, setOrcarouterCatalogRevision] = useState(0); @@ -274,6 +299,9 @@ export function ChannelCredentialFields({ if (kind === 'llm') { const defaultEndpoint = descriptor?.defaultEndpoint; const defaultModel = descriptor?.defaultModel; + const modelsUrl = descriptor.endpointPresets?.find((preset) => + matchesEndpointPreset(llmEndpoint || defaultEndpoint || '', preset.endpoint), + )?.modelsUrl; const codexOAuthSelected = descriptor?.authRequirement === 'o_auth'; return ( <> @@ -322,6 +350,21 @@ export function ChannelCredentialFields({ key={`${channelId}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="ark.endpoint" + onValueChange={setLlmEndpoint} + endpointPresets={ + providerType === 'ark' && defaultEndpoint && descriptor.endpointPresets?.length + ? { + label: t('settings.providers.volcengineServiceLabel'), + options: [ + { value: defaultEndpoint, label: t('settings.providers.presets.ark') }, + ...descriptor.endpointPresets.map(({ name, endpoint }) => ({ + value: endpoint, + label: name, + })), + ], + } + : undefined + } provider={channelId} placeholder={defaultEndpoint || 'https://your-endpoint/v1'} defaultValue={defaultEndpoint || undefined} @@ -349,7 +392,9 @@ export function ChannelCredentialFields({
{providerType === 'orcarouter' ? ( @@ -407,6 +452,7 @@ export function ChannelCredentialFields({ } kind="llm" modelAccount="ark.model_id" + modelsUrl={modelsUrl} provider={channelId} onModelSelected={() => setLlmModelRevision((v) => v + 1)} onTested={onTested} @@ -1234,6 +1280,7 @@ function ProviderTools({ onModelSelected, onTested, onUserMutation, + modelsUrl, showFetchModels = true, disabled = false, }: { @@ -1243,6 +1290,7 @@ function ProviderTools({ onModelSelected: () => void; onTested?: () => void; onUserMutation?: () => void; + modelsUrl?: string; showFetchModels?: boolean; disabled?: boolean; }) { @@ -1392,16 +1440,18 @@ function ProviderTools({ - {models.length > 0 && ( + {!modelsUrl && models.length > 0 && ( void; /** 提供则渲染为下拉(预设选择)代替输入框;当前值不在预设里时附加为自定义项。 */ options?: SelectOption[]; + /** 地址预设与手填共用同一个字段及保存队列。 */ + endpointPresets?: { label: string; options: SelectOption[] }; } function CredentialField({ @@ -1529,6 +1581,7 @@ function CredentialField({ onValueChange, onUserMutation, options, + endpointPresets, onBlockedChange, }: CredentialFieldProps) { const fieldId = useId(); @@ -1714,137 +1767,172 @@ function CredentialField({ (account === 'ark.endpoint' || account === 'asr.endpoint' || account === 'omni.endpoint') && value.trim().toLowerCase().startsWith('http://'); + const presetValue = + endpointPresets?.options.find((option) => { + return matchesEndpointPreset(value || defaultValue || '', option.value); + })?.value || ''; + return ( - -
+ <> + {endpointPresets && ( + + { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + markMutation(); + setValue(next); + onValueChange?.(next); + setDirty(true); + void save(next, true); + }} + style={{ ...inputStyle, width: '100%', maxWidth: '100%', height: 38 }} + /> + + )} +
- {options && !customModelMode ? ( - { - // 「自定义模型…」逃生口:切回输入框手输任意模型名。 - if (v === CUSTOM_MODEL_OPTION_VALUE) { - setCustomModelMode(true); - return; - } - markMutation(); - setValue(v); - onValueChange?.(v); - if (!loaded) return; - setDirty(true); - void save(v, true); - }} - options={[ - ...(value && !options.some((o) => o.value === value) - ? [{ value, label: value }] - : []), - ...options, - { - value: CUSTOM_MODEL_OPTION_VALUE, - label: t('settings.providers.customModelLabel', 'Custom model…'), - }, - ]} - placeholder={loaded ? placeholder : t('common.loading')} - disabled={disabled} - ariaLabel={label} - style={{ - flex: 1, - height: 38, - minWidth: 0, - maxWidth: '100%', - fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit', - }} - /> - ) : ( - - )} - {options && customModelMode && ( - - )} - {defaultValue && !value && loaded && ( - - )} - {mask && ( +
+ {options && !customModelMode ? ( + { + // 「自定义模型…」逃生口:切回输入框手输任意模型名。 + if (v === CUSTOM_MODEL_OPTION_VALUE) { + setCustomModelMode(true); + return; + } + markMutation(); + setValue(v); + onValueChange?.(v); + if (!loaded) return; + setDirty(true); + void save(v, true); + }} + options={[ + ...(value && !options.some((o) => o.value === value) + ? [{ value, label: value }] + : []), + ...options, + { + value: CUSTOM_MODEL_OPTION_VALUE, + label: t('settings.providers.customModelLabel', 'Custom model…'), + }, + ]} + placeholder={loaded ? placeholder : t('common.loading')} + disabled={disabled} + ariaLabel={label} + style={{ + flex: 1, + height: 38, + minWidth: 0, + maxWidth: '100%', + fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit', + }} + /> + ) : ( + + )} + {options && customModelMode && ( + + )} + {defaultValue && !value && loaded && ( + + )} + {mask && ( + + )} - )} - - {/* readError 是字段无法读取的持续错误,留在原位提示用户该字段不可用; + {/* readError 是字段无法读取的持续错误,留在原位提示用户该字段不可用; 其它瞬态状态(saving / saved / saveError / copied / copyError)都通过 emitSaved 发到右上角统一 toast,不再内联占位。 */} - {status === 'readError' && ( - - {t('settings.providers.readFailed')} + {status === 'readError' && ( + + {t('settings.providers.readFailed')} + + )} +
+ {trailing && ( +
+ {trailing} +
+ )} + {showInsecureEndpointWarning && ( + + {t('settings.providers.endpointHttpWarning')} )}
- {trailing && ( -
{trailing}
- )} - {showInsecureEndpointWarning && ( - - {t('settings.providers.endpointHttpWarning')} - - )} -
-
+
+ ); } From 4466093cfc2862d0cc64b1c255aef2d150038d28 Mon Sep 17 00:00:00 2001 From: MikuHello <97345342+MikuHello@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:39:00 +0800 Subject: [PATCH 4/8] =?UTF-8?q?docs(volcengine):=20=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=8E=9F=E6=9C=89=E7=BB=93=E6=9E=84=E5=B9=B6=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E5=A5=97=E9=A4=90=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/volcengine-setup.md | 42 +++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/docs/volcengine-setup.md b/docs/volcengine-setup.md index 952873239..fd5635101 100644 --- a/docs/volcengine-setup.md +++ b/docs/volcengine-setup.md @@ -1,33 +1,31 @@ -# 火山引擎(Volcengine)配置 +# 火山引擎(volcengine)配置 -## ASR 服务与鉴权 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-12。 -设置 → AI 服务 → 语音识别 → 添加渠道,选择火山引擎。服务选择与普通服务的鉴权模式独立,配置和密钥按渠道保存至系统凭据存储。 +## 1. 代码中的定义 -| 服务 | 鉴权 | 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` | +- Provider:`volcengine`(labelKey `asrVolcengine`),定义于 Core `provider_rules.rs`;`authRequirement = Volcengine`(专用鉴权形态),无内置默认端点/模型(`defaultEndpoint` / `defaultModel` 为空,按通道配置)。 +- 验证探针:`asr_silence_allows_no_final`(静音段允许无 final 帧,验证以可取消的静音探测完成)。 +- 凭据状态字段(`provider_rules.rs`):`volcengine_service`(服务选择)、`volcengine_auth_mode`(普通服务的鉴权模式)、`volcengine_app_key`、`volcengine_access_key`、`volcengine_api_key`(凭据是否已配置;具体取值在设置界面录入,凭据走系统安全存储,不落明文)。 -未包含服务字段的旧配置继续使用普通服务。Agent Plan 自动使用 API Key 鉴权,切回普通服务后保留原有鉴权模式;切换服务不会删除已保存的密钥。不同服务使用不同密钥,建议分别创建渠道。Coding Plan 没有 ASR 服务选项。 +## 2. 在应用内配置 -Resource ID 留空时使用 `volc.seedasr.sauc.duration`;Agent Plan 豆包流式 ASR 使用此资源。服务选择保存在 `volcengine.service`(`standard` / `agent_plan`),由 Core 的同一配置解析和连接路径用于验证、听写及其他 ASR 入口。未知服务值报错,不回退到普通计费端点。 +设置 → AI 服务与模型 → 语音识别 → 添加渠道,选择火山引擎;按界面提示填入鉴权字段,保存后执行“验证”得到真实验证结果(成功/失败与时间会记录在渠道列表)。 -“验证”发送可取消的静音探针,允许没有最终识别结果;连接验证成功后,还应通过实际录音检查转写及插入。连接日志包含端点、连接/请求 ID 和服务端 Log ID,不包含鉴权头。 +- 服务选择普通服务或 Agent Plan,按渠道保存为 `volcengine.service`(`standard` / `agent_plan`);旧配置默认普通服务。Agent Plan 使用专属 API Key,不需要 APP ID;切回普通服务保留原鉴权模式及已保存密钥,建议不同服务分别创建渠道。 +- Resource ID 留空时使用 `volc.seedasr.sauc.duration`。Coding Plan 不提供 ASR 选项;验证成功后仍需通过实际录音检查转写及插入。 -官方依据:[Agent Plan 接入语音模型](https://docs.volcengine.com/docs/82379/2516286?lang=zh)、[普通流式语音识别](https://www.volcengine.com/docs/6561/1354869?lang=zh)。 +## 3. 端点与排错 -## Ark 语言模型套餐 +- 普通服务的两种鉴权模式均使用 `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)为准;本仓库只维护代码行为。 -设置 → AI 服务 → 文本润色 → 添加火山方舟渠道,在“服务”中选择火山方舟、Agent Plan 或 Coding Plan。选择服务会填入对应 Endpoint,预设地址只读;自定义接口使用现有自定义供应商入口。旧渠道已有的自定义地址保持可编辑,不会自动改写;API Key 和模型沿用现有字段。Agent Plan 与 Coding Plan 均支持语言模型;只有 Agent Plan 提供本页接入的套餐语音识别。 +## 4. 火山方舟语言模型套餐 -套餐使用各自的专属 API Key 和对应 Endpoint: +设置 → 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` - -填写控制台显示的**文本生成模型名称**,或使用 `ark-code-latest` 并在控制台选择其对应文本模型,再执行“验证”。不要将图片、视频或向量化模型用于文本润色;列表中的 ID 不代表该套餐均可调用,实际能力以控制台及连接验证为准。 - -选择 Agent Plan 或 Coding Plan 后,“可用模型”处显示“查看支持的模型”,分别打开对应的[Agent Plan 控制台](https://console.volcengine.com/ark/subscription/agent-plan)或[Coding Plan 控制台](https://console.volcengine.com/ark/subscription/coding-plan)。从控制台复制当前套餐支持的文本模型 ID,填入“模型”字段后验证连接。该按钮不请求在线模型目录,也不验证 API Key。 - -火山方舟标准服务及其他供应商保留原有模型列表获取行为。套餐的 API Key、模型手填和连接验证沿用现有字段与流程。 +- 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,手动填写后执行“验证”。该按钮不拉取在线模型列表,也不验证密钥。 +- 预设地址只读,已有自定义地址保持可编辑;新建自定义接口使用自定义供应商入口。普通火山方舟及其他供应商保留原有模型列表获取行为。 From 45111bbe4ac97fb144e068b16d99bb006888851d Mon Sep 17 00:00:00 2001 From: MikuHello <97345342+MikuHello@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:39:08 +0800 Subject: [PATCH 5/8] =?UTF-8?q?chore(asr):=20=E6=98=8E=E7=A1=AE=E5=A5=97?= =?UTF-8?q?=E9=A4=90=E9=89=B4=E6=9D=83=E6=B3=A8=E9=87=8A=E5=B9=B6=E6=B8=85?= =?UTF-8?q?=E7=90=86=E6=9C=AA=E7=94=A8=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openless-core/src/asr/volcengine.rs | 19 +++++++------------ .../src-tauri/src/persistence/credentials.rs | 6 +++--- .../src/pages/settings/ProvidersSection.tsx | 2 +- 3 files changed, 11 insertions(+), 16 deletions(-) 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 7554d9f23..3beb8f576 100644 --- a/openless-all/app/crates/openless-core/src/asr/volcengine.rs +++ b/openless-all/app/crates/openless-core/src/asr/volcengine.rs @@ -16,10 +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::{ - handshake::client::Request as WebSocketRequest, - Error as WebSocketError, Message, -}; +use tokio_tungstenite::tungstenite::{handshake::client::Request as WebSocketRequest, Message}; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; use uuid::Uuid; @@ -56,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, @@ -83,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`)都应复用此方法,避免三处规则漂移。 @@ -387,7 +385,7 @@ impl VolcengineStreamingASR { // 根据鉴权模式选择表头: // - AppIdToken:X-Api-App-Key + X-Api-Access-Key(旧版语音控制台) - // - ApiKey:X-Api-Key(新版方舟语音模型,单头即可) + // - ApiKey:X-Api-Key(普通服务或 Agent Plan 的 ASR API Key,单头即可) match auth_mode { VolcengineAuthMode::AppIdToken => { headers.insert( @@ -436,10 +434,7 @@ 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; diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index cbb9c227d..b9c1927b8 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -327,8 +327,8 @@ struct CredsAsrEntry { authMode: Option, #[serde(skip_serializing_if = "Option::is_none")] volcengineService: Option, - /// 方舟(Ark)API Key —— 仅 `api_key` 鉴权模式使用,与旧版 Access Token 槽位 - /// (`accessKey`) 隔离,避免两模式切换时残留凭据互相污染。 + /// ASR API Key —— 普通服务 API Key 鉴权或 Agent Plan 使用,与旧版 Access Token 槽位 + /// (`accessKey`) 隔离,避免不同鉴权方式的凭据互相污染。 #[serde(skip_serializing_if = "Option::is_none")] volcengineApiKey: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1818,7 +1818,7 @@ pub enum CredentialAccount { VolcengineResourceId, VolcengineService, VolcengineAuthMode, - /// 方舟(Ark)语音模型 API Key(`api_key` 鉴权模式使用,独立于旧版 Access Token 槽位)。 + /// ASR API Key(普通服务 API Key 鉴权或 Agent Plan 使用,独立于旧版 Access Token 槽位)。 VolcengineApiKey, ArkApiKey, ArkModelId, diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 2b1a33bda..6c9c54f34 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -545,7 +545,7 @@ export function ChannelCredentialFields({ )} {/* 两种模式使用各自独立的凭据槽位:旧版 Access Token(volcengine.access_key) - 与方舟 API Key(volcengine.api_key)互不预填,切换模式不会残留混淆。 */} + 与 ASR API Key(volcengine.api_key,普通服务 API Key 鉴权或 Agent Plan 使用)互不预填。 */} {!agentPlan && volcengineAuthMode === 'app_id_token' ? ( <> Date: Sat, 12 Sep 2026 23:39:51 +0800 Subject: [PATCH 6/8] fix(core): require API keys for Ark plan endpoints Apply the official-endpoint API key requirement to Agent Plan and Coding Plan across configured state, validation, and LLM construction. Reuse the Core policy in both credential adapters and preserve optional keys for custom endpoints and the dedicated plan request addresses. Add regression coverage for missing, empty, whitespace-only and nonempty keys, endpoint normalization, and all three LLM builder protocols. This fix addresses a finding from a user-initiated AI red/blue review of PR #1069. Implementation assistance: Codex. Validation on macOS: - Core: 962 tests passed, 1 ignored; new regressions failed before the fix. - Linux Host: 57 tests passed (run on macOS, not native Linux validation). - Tauri: cargo check --locked passed. - git diff --check passed. Workspace and Tauri format checks report only unchanged baseline differences, verified against the parent commit. - GUI/manual platform acceptance and live plan API calls were not run. Co-authored-by: Codex --- .../openless-core/src/cloud_providers.rs | 63 +++++++++++++++++++ .../openless-core/src/provider_rules.rs | 54 ++++++++++++++-- .../openless-core/src/provider_service.rs | 43 +++++++++++++ .../app/linux-egui/src/credentials.rs | 11 ++-- .../app/src-tauri/src/commands/credentials.rs | 10 +-- 5 files changed, 164 insertions(+), 17 deletions(-) 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 d4a62d552..c3f457976 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -2492,6 +2492,69 @@ 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", + ] { + for format in ["chat_completions", "responses", "messages"] { + for key in [None, Some(""), Some(" \t\n"), 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 = Arc::new(InMemoryCredentialStore::default()); 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 493e8895f..1482bfa6e 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -402,7 +402,7 @@ pub struct CredentialConfiguration { pub tencent_cloud_secret_key: bool, pub llm_api_key: bool, pub llm_endpoint: bool, - pub llm_endpoint_matches_default: bool, + pub llm_api_key_required: bool, pub llm_model: bool, pub codex_oauth: bool, pub omni_api_key: bool, @@ -487,8 +487,7 @@ pub fn auth_requirement_satisfied( AuthRequirement::ApiKeyUnlessCustomEndpoint => { endpoint && model - && (api_key - || (configuration.llm_endpoint && !configuration.llm_endpoint_matches_default)) + && (api_key || (configuration.llm_endpoint && !configuration.llm_api_key_required)) } AuthRequirement::Volcengine => volcengine_configured(configuration), AuthRequirement::Xfyun => configuration.xfyun_app_id && configuration.xfyun_api_key, @@ -523,6 +522,10 @@ pub fn api_key_required( .default_endpoint .as_deref() .is_some_and(|default| equivalent_endpoint(endpoint, default)) + || descriptor + .endpoint_presets + .iter() + .any(|preset| equivalent_endpoint(endpoint, &preset.endpoint)) } _ => true, } @@ -1134,7 +1137,7 @@ mod tests { let mut configuration = CredentialConfiguration { asr_api_key: true, llm_endpoint: true, - llm_endpoint_matches_default: true, + llm_api_key_required: true, llm_model: true, omni_api_key: true, omni_model: true, @@ -1145,7 +1148,7 @@ mod tests { configuration.llm_api_key = true; assert!(llm_configured("openrouterFree", &configuration)); configuration.llm_api_key = false; - configuration.llm_endpoint_matches_default = false; + configuration.llm_api_key_required = false; assert!(llm_configured("openrouterFree", &configuration)); assert!(omni_configured("gemini", &configuration)); @@ -1201,7 +1204,7 @@ mod tests { )); let mut configuration = CredentialConfiguration { llm_endpoint: true, - llm_endpoint_matches_default: true, + llm_api_key_required: true, llm_model: true, ..CredentialConfiguration::default() }; @@ -1301,6 +1304,45 @@ mod tests { } } + #[test] + fn ark_official_endpoints_require_keys_but_custom_endpoints_do_not() { + 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", + ] { + let required = !endpoint.starts_with("http://127.0.0.1"); + for suffix in ["", "/", "/chat/completions/", "/responses", "/messages/"] { + let endpoint = format!("{endpoint}{suffix}"); + assert_eq!( + api_key_required(ProviderKind::Llm, "ark", Some(&endpoint)), + required, + "{endpoint}" + ); + for key in [None, Some(""), Some(" \t\n"), Some("fixture-key")] { + let has_key = key.is_some_and(|value: &str| !value.trim().is_empty()); + let configuration = CredentialConfiguration { + llm_api_key: has_key, + llm_endpoint: true, + llm_api_key_required: api_key_required( + ProviderKind::Llm, + "ark", + Some(&endpoint), + ), + llm_model: true, + ..CredentialConfiguration::default() + }; + assert_eq!( + llm_configured("ark", &configuration), + has_key || !required, + "{endpoint}" + ); + } + } + } + } + #[test] fn custom_llm_auth_depends_on_the_effective_endpoint() { assert!(!api_key_required( 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..997c4c6b5 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -994,6 +994,49 @@ mod tests { id } + #[tokio::test] + async fn ark_endpoint_key_validation_runs_before_network_probes() { + 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", + ] { + for key in [None, Some(""), Some(" \t\n"), Some("fixture-key")] { + let credentials = Arc::new(InMemoryCredentialStore::default()); + let mut values = vec![ + (LLM_ENDPOINT_ACCOUNT, endpoint), + (LLM_MODEL_ACCOUNT, "fixture-model"), + ]; + if let Some(key) = key { + values.push((LLM_API_KEY_ACCOUNT, key)); + } + let channel = + create_channel_with_values(&credentials, ChannelKind::Llm, "ark", &values) + .await; + let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)); + let resolved = service + .resolve(ProviderRequest { + kind: ProviderKind::Llm, + channel_id: Some(channel), + thinking_enabled: false, + }) + .await + .unwrap(); + let result = validate_configuration(&resolved); + if !endpoint.starts_with("http://127.0.0.1") + && key.is_none_or(|value| value.trim().is_empty()) + { + let error = result.unwrap_err(); + assert_eq!(error.code, BackendErrorCode::Provider); + assert_eq!(error.message, "LLM API key is not configured"); + } else { + result.unwrap(); + } + } + } + } + #[tokio::test] async fn validation_and_model_lists_use_channel_protocol_and_thinking() { use crate::llm_protocol::*; diff --git a/openless-all/app/linux-egui/src/credentials.rs b/openless-all/app/linux-egui/src/credentials.rs index d33c2fa30..10f349f9b 100644 --- a/openless-all/app/linux-egui/src/credentials.rs +++ b/openless-all/app/linux-egui/src/credentials.rs @@ -443,12 +443,11 @@ impl CredentialStore for LinuxCredentialStore { llm_endpoint: llm_endpoint .as_deref() .is_some_and(|value| !value.trim().is_empty()), - llm_endpoint_matches_default: llm_endpoint.as_deref().is_some_and(|endpoint| { - openless_core::provider_rules::default_llm_endpoint(&llm_provider_type) - .is_some_and(|default| { - openless_core::provider_rules::equivalent_endpoint(endpoint, default) - }) - }), + llm_api_key_required: openless_core::provider_rules::api_key_required( + openless_core::ProviderKind::Llm, + &llm_provider_type, + llm_endpoint.as_deref(), + ), llm_model: has( CredentialNamespace::Llm, &active_llm_provider, diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index d209355e7..3aeed41f0 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -548,11 +548,11 @@ fn credential_configuration( 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| { - openless_core::provider_rules::default_llm_endpoint(llm_provider).is_some_and( - |default| openless_core::provider_rules::equivalent_endpoint(endpoint, default), - ) - }), + llm_api_key_required: openless_core::provider_rules::api_key_required( + openless_core::ProviderKind::Llm, + llm_provider, + llm_endpoint, + ), llm_model: configured(&snap.ark_model_id), codex_oauth, omni_api_key: configured(&snap.omni_api_key), From 0b7e6cb41c153224b2d30d6dd47abd4b09a2b65b Mon Sep 17 00:00:00 2001 From: MikuHello <97345342+MikuHello@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:56:42 +0800 Subject: [PATCH 7/8] test(core): consolidate provider regression coverage Share the HTTP test setup for omitted custom-provider temperature and Ark's default 0.3 representation. Keep both request assertions and the polished response check. Reduce the Ark builder matrix from 48 to 24 cases: exercise every key state once per endpoint and keep successful construction for all three protocols. Production behavior is unchanged. Validation: focused regressions and the full Core suite pass (961 passed, 1 ignored). Changed files pass rustfmt; workspace formatting still reports only existing differences outside this change. git diff --check passes. Matt Standards and Spec reviews reported no findings for this increment. Implementation assistance: Codex. Co-authored-by: Codex --- .../openless-core/src/cloud_providers.rs | 93 ++++++------ .../app/crates/openless-core/src/polish.rs | 139 ++++++------------ 2 files changed, 98 insertions(+), 134 deletions(-) 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 c3f457976..d997cbe4c 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -2500,56 +2500,63 @@ mod tests { "https://ark.cn-beijing.volces.com/api/coding/v3", "http://127.0.0.1:8080/v1", ] { - for format in ["chat_completions", "responses", "messages"] { - for key in [None, Some(""), Some(" \t\n"), Some("fixture-key")] { - let store = InMemoryCredentialStore::default(); - write_channel_secret( - &store, - CredentialNamespace::Llm, - "ark-channel", - LLM_ENDPOINT_ACCOUNT, - endpoint, - ) - .await; + // 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", - crate::llm_protocol::REQUEST_FORMAT_ACCOUNT, - format, + LLM_API_KEY_ACCOUNT, + key, ) .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 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}") + } }; - 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}"); - } + assert_eq!(error.code, BackendErrorCode::Provider); + assert_eq!(error.message, "LLM API key is not configured"); + } else { + assert!(result.is_ok(), "{endpoint}"); } } } diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 318d27ac5..8836bfd90 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -2933,101 +2933,58 @@ mod tests { } #[tokio::test] - async fn polish_request_omits_temperature_for_unconfigured_custom_provider() { - 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: serde_json::Value = serde_json::from_slice(&request[header_end + 4..]) - .expect("request body must be JSON"); - assert!(body.get("temperature").is_none()); - - let 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{body}", - body.len() - ); - stream.write_all(response.as_bytes()).unwrap(); - }); + async fn polish_request_sends_default_temperature_only_for_builtin_provider() { + for (provider_id, expected_temperature) in [("custom", None), ("ark", Some("0.3"))] { + 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( - "custom", - "Custom", - format!("http://{addr}"), - "", - "test-model", - )); - let output = provider - .polish( - "raw text", - PolishMode::Raw, - &[], + let provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + provider_id, + provider_id, + format!("http://{addr}"), "", - &[], - ChineseScriptPreference::Auto, - OutputLanguagePreference::Auto, - None, - None, - &[], - ) - .await - .unwrap(); - - assert_eq!(output, "polished"); - server.join().unwrap(); - } + "test-model", + )); + let output = provider + .polish( + "raw text", + PolishMode::Raw, + &[], + "", + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + None, + &[], + ) + .await + .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() + assert_eq!(output, "polished"); + let request = server.join().unwrap(); + assert_eq!( + request.get("temperature").map(Value::to_string).as_deref(), + expected_temperature, + "{provider_id} default temperature" ); - 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 消息构造 ──────────────── From 49d6296b0f0a97241b9f668f098e4ab771af0631 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Sun, 13 Sep 2026 00:34:33 +0800 Subject: [PATCH 8/8] fix(core): require keys for Ark Messages endpoints --- .../app/crates/openless-core/src/provider_rules.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 1482bfa6e..e5ed5db38 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -525,7 +525,7 @@ pub fn api_key_required( || descriptor .endpoint_presets .iter() - .any(|preset| equivalent_endpoint(endpoint, &preset.endpoint)) + .any(|preset| matches_endpoint_preset(endpoint, &preset.endpoint)) } _ => true, } @@ -1306,6 +1306,15 @@ mod tests { #[test] fn ark_official_endpoints_require_keys_but_custom_endpoints_do_not() { + for endpoint in [ + "https://ark.cn-beijing.volces.com/api/plan/messages", + "https://ark.cn-beijing.volces.com/api/coding/messages", + ] { + assert!( + api_key_required(ProviderKind::Llm, "ark", Some(endpoint)), + "{endpoint}" + ); + } for endpoint in [ "https://ark.cn-beijing.volces.com/api/v3", "https://ark.cn-beijing.volces.com/api/plan/v3",