diff --git a/docs/volcengine-setup.md b/docs/volcengine-setup.md index 842a703f9..fd5635101 100644 --- a/docs/volcengine-setup.md +++ b/docs/volcengine-setup.md @@ -1,19 +1,31 @@ -# 火山引擎(volcengine)ASR 配置 +# 火山引擎(volcengine)配置 -状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-12。 ## 1. 代码中的定义 - Provider:`volcengine`(labelKey `asrVolcengine`),定义于 Core `provider_rules.rs`;`authRequirement = Volcengine`(专用鉴权形态),无内置默认端点/模型(`defaultEndpoint` / `defaultModel` 为空,按通道配置)。 - 验证探针:`asr_silence_allows_no_final`(静音段允许无 final 帧,验证以可取消的静音探测完成)。 -- 凭据字段(`provider_rules.rs:300-302`):`volcengine_auth_mode`(鉴权模式,如 ApiKey/官方端点模式)、`volcengine_app_key`、`volcengine_access_key`(布尔项 + 模式选择;具体取值在设置界面录入,凭据走系统安全存储,不落明文)。 +- 凭据状态字段(`provider_rules.rs`):`volcengine_service`(服务选择)、`volcengine_auth_mode`(普通服务的鉴权模式)、`volcengine_app_key`、`volcengine_access_key`、`volcengine_api_key`(凭据是否已配置;具体取值在设置界面录入,凭据走系统安全存储,不落明文)。 ## 2. 在应用内配置 -设置 → AI 服务 → 语音识别 → 添加渠道,选择火山引擎;按界面提示填入鉴权字段,保存后执行“验证”得到真实验证结果(成功/失败与时间会记录在渠道列表)。 +设置 → AI 服务与模型 → 语音识别 → 添加渠道,选择火山引擎;按界面提示填入鉴权字段,保存后执行“验证”得到真实验证结果(成功/失败与时间会记录在渠道列表)。 + +- 服务选择普通服务或 Agent Plan,按渠道保存为 `volcengine.service`(`standard` / `agent_plan`);旧配置默认普通服务。Agent Plan 使用专属 API Key,不需要 APP ID;切回普通服务保留原鉴权模式及已保存密钥,建议不同服务分别创建渠道。 +- Resource ID 留空时使用 `volc.seedasr.sauc.duration`。Coding Plan 不提供 ASR 选项;验证成功后仍需通过实际录音检查转写及插入。 ## 3. 端点与排错 -- ApiKey 模式使用火山官方实时 ASR 端点(历史修复 #931 后的行为,以 `crates/openless-core/src/asr/volcengine.rs` 当前实现为准)。 +- 普通服务的两种鉴权模式均使用 `wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async`(历史修复 #931 后的行为,以 `crates/openless-core/src/asr/volcengine.rs` 当前实现为准)。 +- Agent Plan 使用专属端点 `wss://openspeech.bytedance.com/api/v3/plan/sauc/bigmodel_async` 和 API Key,见[官方接入文档](https://docs.volcengine.com/docs/82379/2516286?lang=zh)。验证与听写共用服务解析,未知服务值报错,不回退到普通端点;连接日志记录端点和追踪 ID,不记录鉴权头。 - 弱网行为:连接超时与重试在 Host/Core 实现,失败信息展示在渠道验证结果中。 - 开通服务、创建应用与获取密钥属火山控制台操作,以[火山官方文档](https://www.volcengine.com/docs)为准;本仓库只维护代码行为。 + +## 4. 火山方舟语言模型套餐 + +设置 → AI 服务与模型 → 语言模型 → 添加渠道,选择火山方舟;服务可选普通火山方舟、Agent Plan 或 Coding Plan。 + +- Agent Plan:`https://ark.cn-beijing.volces.com/api/plan/v3`;Coding Plan:`https://ark.cn-beijing.volces.com/api/coding/v3`。使用各自套餐的专属 API Key,按所选请求格式适配协议路径。 +- 选择套餐后,通过“查看支持的模型”打开对应的 [Agent Plan 控制台](https://console.volcengine.com/ark/subscription/agent-plan)或 [Coding Plan 控制台](https://console.volcengine.com/ark/subscription/coding-plan),复制支持的文本模型 ID,手动填写后执行“验证”。该按钮不拉取在线模型列表,也不验证密钥。 +- 预设地址只读,已有自定义地址保持可编辑;新建自定义接口使用自定义供应商入口。普通火山方舟及其他供应商保留原有模型列表获取行为。 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..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,7 +16,7 @@ use tokio::net::TcpStream; use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex, Notify}; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::header::HeaderValue; -use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::{handshake::client::Request as WebSocketRequest, Message}; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; use uuid::Uuid; @@ -31,6 +31,9 @@ use crate::ports::{TextStreamChunk, TextStreamSink}; /// 新旧两种鉴权模式共享同一端点,仅握手鉴权头不同。 const ENDPOINT_APP_ID_TOKEN: &str = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async"; const ENDPOINT_API_KEY: &str = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async"; +/// Agent Plan uses a dedicated subscription endpoint with API-key authentication. +/// https://docs.volcengine.com/docs/82379/2516286 +const ENDPOINT_AGENT_PLAN: &str = "wss://openspeech.bytedance.com/api/v3/plan/sauc/bigmodel_async"; /// 200 ms of 16 kHz / 16-bit / mono PCM. pub const TARGET_AUDIO_CHUNK_BYTES: usize = 6_400; /// 16 kHz · 16-bit · mono = 32 000 bytes/sec → 32 bytes/ms. @@ -50,9 +53,10 @@ const CONNECT_RETRY_BACKOFF: Duration = Duration::from_millis(250); /// Volcengine ASR 鉴权模式。 /// /// - `AppIdToken`:旧版语音控制台应用,使用 `X-Api-App-Key` + `X-Api-Access-Key` 双表头鉴权。 -/// - `ApiKey`:新版方舟(Ark)语音模型,使用单个 `X-Api-Key` 表头鉴权。 +/// - `ApiKey`:普通服务 API Key 或 Agent Plan 专属 API Key,使用单个 `X-Api-Key` 表头鉴权。 /// -/// 两种模式共享完全相同的 WebSocket 端点与二进制帧协议,仅握手鉴权头不同。 +/// 普通服务下,两种模式共享 WebSocket 端点与二进制帧协议,仅握手鉴权头不同。 +/// Agent Plan 按服务选择专属端点,并固定使用 ApiKey 鉴权。 #[derive(Clone, Debug, PartialEq, Eq)] pub enum VolcengineAuthMode { AppIdToken, @@ -77,7 +81,7 @@ impl VolcengineAuthMode { /// 当前模式下所需凭据是否齐备(统一 trim 语义)。 /// /// `secret` 的语义随模式:AppIdToken = Access Token(旧版语音控制台), - /// ApiKey = 方舟语音模型 API Key。`app_id` 仅在 AppIdToken 模式要求非空。 + /// ApiKey = 普通服务或 Agent Plan 的 ASR API Key。`app_id` 仅在 AppIdToken 模式要求非空。 /// /// 所有按模式判定凭据完整性的入口(`open_session`、`volcengine_configured`、 /// `ensure_asr_credentials`)都应复用此方法,避免三处规则漂移。 @@ -90,8 +94,34 @@ impl VolcengineAuthMode { } } +/// Service selection is separate from the standard service's authentication mode. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum VolcengineService { + #[default] + Standard, + AgentPlan, +} + +impl VolcengineService { + pub fn parse(value: &str) -> Result { + 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 +144,9 @@ impl VolcengineCredentials { /// 凭据是否满足当前鉴权模式的要求(统一 trim 语义,见 [`VolcengineAuthMode::auth_ok`])。 pub fn auth_ok(&self) -> bool { - self.auth_mode.auth_ok(&self.app_id, &self.access_token) + self.service + .auth_mode(self.auth_mode.clone()) + .auth_ok(&self.app_id, &self.access_token) } } @@ -336,11 +368,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() @@ -349,8 +385,8 @@ impl VolcengineStreamingASR { // 根据鉴权模式选择表头: // - AppIdToken:X-Api-App-Key + X-Api-Access-Key(旧版语音控制台) - // - ApiKey:X-Api-Key(新版方舟语音模型,单头即可) - match &self.credentials.auth_mode { + // - ApiKey:X-Api-Key(普通服务或 Agent Plan 的 ASR API Key,单头即可) + match auth_mode { VolcengineAuthMode::AppIdToken => { headers.insert( "X-Api-App-Key", @@ -404,8 +440,25 @@ impl VolcengineStreamingASR { attempt += 1; let request_id = Uuid::new_v4().to_string(); let request = self.build_connect_request(connect_id, &request_id)?; + log::info!( + "[asr] Volcengine connect endpoint={} connect_id={} request_id={}", + request.uri(), + connect_id, + request_id + ); match tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request)).await { - Ok(Ok((ws, _resp))) => return Ok(ws), + Ok(Ok((ws, response))) => { + log::info!( + "[asr] Volcengine connected connect_id={} log_id={}", + connect_id, + response + .headers() + .get("X-Tt-Logid") + .and_then(|value| value.to_str().ok()) + .unwrap_or("-") + ); + return Ok(ws); + } Ok(Err(e)) => { let classified = classify_connect_error(e); if is_non_retryable(&classified) || attempt >= CONNECT_MAX_ATTEMPTS { @@ -1047,21 +1100,38 @@ mod tests { fn build_connect_request_selects_endpoint_and_headers_per_mode() { let cases = [ ( + VolcengineService::Standard, VolcengineAuthMode::AppIdToken, ENDPOINT_APP_ID_TOKEN, true, // 双表头(X-Api-App-Key / X-Api-Access-Key) false, // 不应带 X-Api-Key ), ( + VolcengineService::Standard, VolcengineAuthMode::ApiKey, ENDPOINT_API_KEY, false, // 不应带双表头 true, // 单表头 X-Api-Key ), + ( + VolcengineService::AgentPlan, + VolcengineAuthMode::AppIdToken, + ENDPOINT_AGENT_PLAN, + false, + true, + ), + ( + VolcengineService::AgentPlan, + VolcengineAuthMode::ApiKey, + ENDPOINT_AGENT_PLAN, + false, + true, + ), ]; - for (mode, endpoint, expects_app_headers, expects_api_key) in cases { + for (service, mode, endpoint, expects_app_headers, expects_api_key) in cases { let asr = VolcengineStreamingASR::new( VolcengineCredentials { + service, auth_mode: mode.clone(), app_id: "app".into(), access_token: "secret".into(), @@ -1182,6 +1252,7 @@ mod tests { async fn await_final_result_returns_error_when_final_frame_never_arrives() { let asr = VolcengineStreamingASR::new( VolcengineCredentials { + service: VolcengineService::Standard, auth_mode: VolcengineAuthMode::AppIdToken, app_id: "app".into(), access_token: "token".into(), 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..d997cbe4c 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, @@ -2479,6 +2492,76 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn ark_polisher_builder_requires_keys_only_for_official_endpoints() { + for endpoint in [ + "https://ark.cn-beijing.volces.com/api/v3", + "https://ark.cn-beijing.volces.com/api/plan/v3", + "https://ark.cn-beijing.volces.com/api/coding/v3", + "http://127.0.0.1:8080/v1", + ] { + // Key rejection is independent of protocol; exercise each key state once, + // then retain successful construction coverage for all three protocols. + for (format, key) in [ + ("chat_completions", None), + ("chat_completions", Some("")), + ("chat_completions", Some(" \t\n")), + ("chat_completions", Some("fixture-key")), + ("responses", Some("fixture-key")), + ("messages", Some("fixture-key")), + ] { + let store = InMemoryCredentialStore::default(); + write_channel_secret( + &store, + CredentialNamespace::Llm, + "ark-channel", + LLM_ENDPOINT_ACCOUNT, + endpoint, + ) + .await; + write_channel_secret( + &store, + CredentialNamespace::Llm, + "ark-channel", + crate::llm_protocol::REQUEST_FORMAT_ACCOUNT, + format, + ) + .await; + if let Some(key) = key { + write_channel_secret( + &store, + CredentialNamespace::Llm, + "ark-channel", + LLM_API_KEY_ACCOUNT, + key, + ) + .await; + } + let mut llm = ProviderInvocation::new("ark-channel", "ark"); + llm.model = Some("fixture-model".to_string()); + let context = DictationContext { + llm, + ..DictationContext::default() + }; + let result = build_cloud_polisher_provider(&store, &context).await; + if !endpoint.starts_with("http://127.0.0.1") + && key.is_none_or(|value| value.trim().is_empty()) + { + let error = match result { + Err(error) => error, + Ok(_) => { + panic!("official endpoint must require an API key: {endpoint}") + } + }; + assert_eq!(error.code, BackendErrorCode::Provider); + assert_eq!(error.message, "LLM API key is not configured"); + } else { + assert!(result.is_ok(), "{endpoint}"); + } + } + } + } + #[tokio::test] async fn cloud_asr_rejects_unknown_protocol_instead_of_falling_back_to_volcengine() { let credentials: Arc = Arc::new(InMemoryCredentialStore::default()); diff --git a/openless-all/app/crates/openless-core/src/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index dc6270636..ba1cab461 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -64,6 +64,7 @@ pub const ASR_ADVANCED_CONFIG_ACCOUNT: &str = "asr.advanced_config"; pub const VOLCENGINE_APP_KEY_ACCOUNT: &str = "volcengine.app_key"; pub const VOLCENGINE_ACCESS_KEY_ACCOUNT: &str = "volcengine.access_key"; pub const VOLCENGINE_RESOURCE_ID_ACCOUNT: &str = "volcengine.resource_id"; +pub const VOLCENGINE_SERVICE_ACCOUNT: &str = "volcengine.service"; pub const VOLCENGINE_AUTH_MODE_ACCOUNT: &str = "volcengine.auth_mode"; pub const VOLCENGINE_API_KEY_ACCOUNT: &str = "volcengine.api_key"; pub const XFYUN_APP_ID_ACCOUNT: &str = "xfyun.app_id"; diff --git a/openless-all/app/crates/openless-core/src/credentials_legacy.rs b/openless-all/app/crates/openless-core/src/credentials_legacy.rs index 74b3f1904..2193da9d5 100644 --- a/openless-all/app/crates/openless-core/src/credentials_legacy.rs +++ b/openless-all/app/crates/openless-core/src/credentials_legacy.rs @@ -93,6 +93,7 @@ struct LegacyEntry { app_key: Option, access_key: Option, resource_id: Option, + volcengine_service: Option, auth_mode: Option, volcengine_api_key: Option, vocabulary_id: Option, @@ -124,6 +125,7 @@ impl Default for LegacyEntry { app_key: None, access_key: None, resource_id: None, + volcengine_service: None, auth_mode: None, volcengine_api_key: None, vocabulary_id: None, @@ -153,6 +155,7 @@ impl LegacyEntry { &self.app_key, &self.access_key, &self.resource_id, + &self.volcengine_service, &self.auth_mode, &self.volcengine_api_key, &self.vocabulary_id, @@ -382,6 +385,7 @@ fn decode_entry( (VOLCENGINE_APP_KEY_ACCOUNT, entry.app_key), (VOLCENGINE_ACCESS_KEY_ACCOUNT, entry.access_key), (VOLCENGINE_RESOURCE_ID_ACCOUNT, entry.resource_id), + (VOLCENGINE_SERVICE_ACCOUNT, entry.volcengine_service), (VOLCENGINE_AUTH_MODE_ACCOUNT, entry.auth_mode), (VOLCENGINE_API_KEY_ACCOUNT, entry.volcengine_api_key), (ASR_VOCABULARY_ID_ACCOUNT, entry.vocabulary_id), diff --git a/openless-all/app/crates/openless-core/src/llm_protocol.rs b/openless-all/app/crates/openless-core/src/llm_protocol.rs index 0c4314ba6..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)> { @@ -274,7 +286,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 @@ -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/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index ff7c68241..8836bfd90 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() @@ -2920,53 +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(); + "test-model", + )); + let output = provider + .polish( + "raw text", + PolishMode::Raw, + &[], + "", + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + None, + &[], + ) + .await + .unwrap(); - assert_eq!(output, "polished"); - server.join().unwrap(); + 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" + ); + } } // ──────────────── 对话感知 polish 的 chat 消息构造 ──────────────── @@ -3158,7 +3176,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 +3190,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 +3206,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 +3248,7 @@ mod tests { let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); - assert_eq!(body["temperature"], json!(DEFAULT_TEMPERATURE)); + assert_eq!(body["temperature"].to_string(), "0.3"); } } diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 6ad7919b4..e5ed5db38 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, @@ -333,6 +389,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, @@ -345,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, @@ -358,12 +415,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 } @@ -421,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, @@ -457,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| matches_endpoint_preset(endpoint, &preset.endpoint)) } _ => true, } @@ -972,6 +1041,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!( @@ -1046,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, @@ -1057,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)); @@ -1113,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() }; @@ -1213,6 +1304,54 @@ 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", + "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/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..10f349f9b 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, @@ -419,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/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 2aff6bae9..627b31bb1 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, @@ -1466,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()); } @@ -2230,6 +2245,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 +2301,7 @@ mod linux_app { descriptor, endpoint, model, + volcengine_service, auth_mode, resource_id, app_id, @@ -2300,21 +2329,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); @@ -2347,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"); @@ -2411,6 +2517,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 +2549,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..3aeed41f0 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), @@ -547,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), @@ -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..b9c1927b8 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -325,8 +325,10 @@ struct CredsAsrEntry { resourceId: Option, #[serde(skip_serializing_if = "Option::is_none")] authMode: Option, - /// 方舟(Ark)API Key —— 仅 `api_key` 鉴权模式使用,与旧版 Access Token 槽位 - /// (`accessKey`) 隔离,避免两模式切换时残留凭据互相污染。 + #[serde(skip_serializing_if = "Option::is_none")] + volcengineService: Option, + /// 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")] @@ -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,8 +1816,9 @@ pub enum CredentialAccount { VolcengineAppKey, VolcengineAccessKey, VolcengineResourceId, + VolcengineService, VolcengineAuthMode, - /// 方舟(Ark)语音模型 API Key(`api_key` 鉴权模式使用,独立于旧版 Access Token 槽位)。 + /// ASR API Key(普通服务 API Key 鉴权或 Agent Plan 使用,独立于旧版 Access Token 槽位)。 VolcengineApiKey, ArkApiKey, ArkModelId, @@ -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..dd8dde5fe 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)', @@ -1443,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 6dda75f85..5eed04b52 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)', @@ -1413,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 75caf90ed..ec0b9702a 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)', @@ -1434,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 474540dcb..0da853d59 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)', @@ -1454,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 6294464c8..e0e11d66f 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', @@ -1398,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 e5cd929d8..59b39bda3 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', @@ -1390,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 cef80f8c5..2bfcd17b1 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', @@ -1341,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 c07146e6a..57f4ddf96 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', @@ -1343,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 e35587be7..6c9c54f34 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); @@ -211,16 +236,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(''); @@ -248,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 ( <> @@ -296,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} @@ -323,7 +392,9 @@ export function ChannelCredentialFields({ {providerType === 'orcarouter' ? ( @@ -381,6 +452,7 @@ export function ChannelCredentialFields({ } kind="llm" modelAccount="ark.model_id" + modelsUrl={modelsUrl} provider={channelId} onModelSelected={() => setLlmModelRevision((v) => v + 1)} onTested={onTested} @@ -395,40 +467,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' ? ( + 与 ASR API Key(volcengine.api_key,普通服务 API Key 鉴权或 Agent Plan 使用)互不预填。 */} + {!agentPlan && volcengineAuthMode === 'app_id_token' ? ( <> ) : ( @@ -457,7 +577,8 @@ export function ChannelCredentialFields({ provider={channelId} mono mask - onUserMutation={onUserMutation} + onUserMutation={onAsrMutation} + onBlockedChange={trackField} /> )}
@@ -469,16 +590,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')}
void; onTested?: () => void; onUserMutation?: () => void; + modelsUrl?: string; showFetchModels?: boolean; disabled?: boolean; }) { @@ -1312,16 +1440,18 @@ function ProviderTools({ - {models.length > 0 && ( + {!modelsUrl && models.length > 0 && ( ['t']): string { const message = error instanceof Error ? error.message : String(error); for (const code of [ + 'volcengineServiceInvalid', 'llmRequestFormatInvalid', 'llmThinkingModeInvalid', 'llmTokenLimitInvalid', @@ -1434,6 +1565,8 @@ interface CredentialFieldProps { onUserMutation?: () => void; /** 提供则渲染为下拉(预设选择)代替输入框;当前值不在预设里时附加为自定义项。 */ options?: SelectOption[]; + /** 地址预设与手填共用同一个字段及保存队列。 */ + endpointPresets?: { label: string; options: SelectOption[] }; } function CredentialField({ @@ -1448,6 +1581,7 @@ function CredentialField({ onValueChange, onUserMutation, options, + endpointPresets, onBlockedChange, }: CredentialFieldProps) { const fieldId = useId(); @@ -1633,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')} - - )} -
-
+ + ); }