fix(providers): 支持火山 Agent Plan / Coding Plan,并修正 temperature 参数序列化 (#1069)
* fix(llm): 保留温度参数的简短十进制表示 * feat(asr): 接入火山 Agent Plan 语音识别 * feat(providers): 添加火山套餐服务与模型控制台入口 * docs(volcengine): 保留原有结构并补充套餐配置 * chore(asr): 明确套餐鉴权注释并清理未用导入 * 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 <noreply@openai.com> * 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 <noreply@openai.com> * fix(core): require keys for Ark Messages endpoints --------- Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
26 files changed
+1113
-280
No files matched your search
@@ -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,手动填写后执行“验证”。该按钮不拉取在线模型列表,也不验证密钥。
|
||||
- 预设地址只读,已有自定义地址保持可编辑;新建自定义接口使用自定义供应商入口。普通火山方舟及其他供应商保留原有模型列表获取行为。
|
||||
@@ -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<Self, &'static str> {
|
||||
match value.trim() {
|
||||
"" | "standard" => Ok(Self::Standard),
|
||||
"agent_plan" => Ok(Self::AgentPlan),
|
||||
_ => Err("volcengineServiceInvalid"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auth_mode(self, configured: VolcengineAuthMode) -> VolcengineAuthMode {
|
||||
match self {
|
||||
Self::Standard => configured,
|
||||
Self::AgentPlan => VolcengineAuthMode::ApiKey,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VolcengineCredentials {
|
||||
pub service: VolcengineService,
|
||||
pub auth_mode: VolcengineAuthMode,
|
||||
/// App ID(AppIdToken 模式使用;ApiKey 模式下为空)。
|
||||
pub app_id: String,
|
||||
@@ -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<tokio_tungstenite::tungstenite::handshake::client::Request, VolcengineASRError>
|
||||
{
|
||||
let endpoint = match &self.credentials.auth_mode {
|
||||
VolcengineAuthMode::AppIdToken => ENDPOINT_APP_ID_TOKEN,
|
||||
VolcengineAuthMode::ApiKey => ENDPOINT_API_KEY,
|
||||
) -> Result<WebSocketRequest, VolcengineASRError> {
|
||||
let auth_mode = self
|
||||
.credentials
|
||||
.service
|
||||
.auth_mode(self.credentials.auth_mode.clone());
|
||||
let endpoint = match (self.credentials.service, &auth_mode) {
|
||||
(VolcengineService::AgentPlan, _) => ENDPOINT_AGENT_PLAN,
|
||||
(_, VolcengineAuthMode::AppIdToken) => ENDPOINT_APP_ID_TOKEN,
|
||||
(_, VolcengineAuthMode::ApiKey) => ENDPOINT_API_KEY,
|
||||
};
|
||||
let mut request = endpoint
|
||||
.into_client_request()
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::default());
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -93,6 +93,7 @@ struct LegacyEntry {
|
||||
app_key: Option<String>,
|
||||
access_key: Option<String>,
|
||||
resource_id: Option<String>,
|
||||
volcengine_service: Option<String>,
|
||||
auth_mode: Option<String>,
|
||||
volcengine_api_key: Option<String>,
|
||||
vocabulary_id: Option<String>,
|
||||
@@ -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),
|
||||
|
||||
@@ -52,14 +52,26 @@ impl LlmRequestFormat {
|
||||
}
|
||||
|
||||
pub fn url(self, endpoint: &str) -> Result<String, LLMError> {
|
||||
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())));
|
||||
|
||||
@@ -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::<f64>(&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());
|
||||
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 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();
|
||||
});
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub endpoint_presets: Vec<ProviderEndpointPreset>,
|
||||
pub default_model: Option<String>,
|
||||
pub auth_requirement: AuthRequirement,
|
||||
pub validation_probe: ValidationProbe,
|
||||
@@ -150,6 +161,28 @@ pub struct ProviderDescriptor {
|
||||
pub supported_request_formats: Vec<crate::llm_protocol::LlmRequestFormat>,
|
||||
}
|
||||
|
||||
/// 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<ProviderDescriptor> {
|
||||
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<String>,
|
||||
pub volcengine_auth_mode: Option<String>,
|
||||
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(
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -2232,6 +2247,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 {
|
||||
(
|
||||
@@ -2275,6 +2303,7 @@ mod linux_app {
|
||||
descriptor,
|
||||
endpoint,
|
||||
model,
|
||||
volcengine_service,
|
||||
auth_mode,
|
||||
resource_id,
|
||||
app_id,
|
||||
@@ -2302,21 +2331,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);
|
||||
@@ -2349,9 +2410,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::<Vec<_>>();
|
||||
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");
|
||||
@@ -2413,6 +2519,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,
|
||||
@@ -2437,7 +2551,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,
|
||||
|
||||
@@ -563,6 +563,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),
|
||||
@@ -575,11 +576,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),
|
||||
@@ -829,6 +830,7 @@ fn account_provider_kind(account: CredentialAccount) -> CredentialProviderKind {
|
||||
CredentialAccount::VolcengineAppKey
|
||||
| CredentialAccount::VolcengineAccessKey
|
||||
| CredentialAccount::VolcengineResourceId
|
||||
| CredentialAccount::VolcengineService
|
||||
| CredentialAccount::VolcengineAuthMode
|
||||
| CredentialAccount::VolcengineApiKey
|
||||
| CredentialAccount::AsrApiKey
|
||||
@@ -860,6 +862,7 @@ fn parse_account(s: &str) -> Result<CredentialAccount, String> {
|
||||
"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),
|
||||
|
||||
@@ -372,8 +372,10 @@ struct CredsAsrEntry {
|
||||
resourceId: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
authMode: Option<String>,
|
||||
/// 方舟(Ark)API Key —— 仅 `api_key` 鉴权模式使用,与旧版 Access Token 槽位
|
||||
/// (`accessKey`) 隔离,避免两模式切换时残留凭据互相污染。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
volcengineService: Option<String>,
|
||||
/// ASR API Key —— 普通服务 API Key 鉴权或 Agent Plan 使用,与旧版 Access Token 槽位
|
||||
/// (`accessKey`) 隔离,避免不同鉴权方式的凭据互相污染。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
volcengineApiKey: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -421,6 +423,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()
|
||||
@@ -1698,6 +1701,7 @@ fn lookup_account(root: &CredsRoot, account: CredentialAccount) -> Option<String
|
||||
}
|
||||
CredentialAccount::VolcengineAccessKey => 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)),
|
||||
@@ -1776,6 +1780,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;
|
||||
@@ -1856,8 +1864,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,
|
||||
@@ -1899,6 +1908,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",
|
||||
@@ -1925,6 +1935,7 @@ impl CredentialAccount {
|
||||
CredentialAccount::VolcengineAppKey,
|
||||
CredentialAccount::VolcengineAccessKey,
|
||||
CredentialAccount::VolcengineResourceId,
|
||||
CredentialAccount::VolcengineService,
|
||||
CredentialAccount::VolcengineAuthMode,
|
||||
CredentialAccount::VolcengineApiKey,
|
||||
CredentialAccount::ArkApiKey,
|
||||
@@ -1953,6 +1964,7 @@ pub struct CredentialsSnapshot {
|
||||
pub volcengine_app_key: Option<String>,
|
||||
pub volcengine_access_key: Option<String>,
|
||||
pub volcengine_resource_id: Option<String>,
|
||||
pub volcengine_service: Option<String>,
|
||||
pub volcengine_auth_mode: Option<String>,
|
||||
pub volcengine_api_key: Option<String>,
|
||||
pub asr_api_key: Option<String>,
|
||||
@@ -2010,6 +2022,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),
|
||||
|
||||
@@ -1354,6 +1354,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)',
|
||||
@@ -1444,6 +1450,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.',
|
||||
|
||||
@@ -1321,6 +1321,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)',
|
||||
@@ -1414,6 +1420,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.',
|
||||
|
||||
@@ -1346,6 +1346,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)',
|
||||
@@ -1435,6 +1441,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.',
|
||||
|
||||
@@ -1363,6 +1363,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)',
|
||||
@@ -1455,6 +1461,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.',
|
||||
|
||||
@@ -1308,6 +1308,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',
|
||||
@@ -1399,6 +1405,9 @@ export const ja: typeof zhCN = {
|
||||
'上記の設定を保存してから、現在のモデル接続性を検証またはモデル一覧を取得します。失敗してもモデル ID を手動入力できます。',
|
||||
validate: '検証',
|
||||
validating: '検証中…',
|
||||
planModelsHint:
|
||||
'プランのコンソールで対応するテキストモデル ID をコピーし、モデル欄に入力してください。',
|
||||
viewModels: '対応モデルを確認',
|
||||
fetchModels: 'モデル一覧',
|
||||
loadingModels: 'モデル取得中…',
|
||||
modelMissing: 'モデルが未設定です。先にモデル ID を入力してください。',
|
||||
|
||||
@@ -1300,6 +1300,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',
|
||||
@@ -1391,6 +1397,8 @@ export const ko: typeof zhCN = {
|
||||
'위 설정을 먼저 저장한 후 현재 모델 연결성을 검증하거나 모델을 가져오세요. 실패해도 모델 ID 를 수동 입력할 수 있습니다.',
|
||||
validate: '검증',
|
||||
validating: '검증 중…',
|
||||
planModelsHint: '요금제 콘솔에서 지원되는 텍스트 모델 ID를 복사해 모델 필드에 입력하세요.',
|
||||
viewModels: '지원 모델 보기',
|
||||
fetchModels: '모델 가져오기',
|
||||
loadingModels: '모델 가져오는 중…',
|
||||
modelMissing: '모델이 설정되지 않았습니다. 먼저 모델 ID 를 입력해 주세요.',
|
||||
|
||||
@@ -1259,6 +1259,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',
|
||||
@@ -1342,6 +1347,8 @@ export const zhCN = {
|
||||
toolsDesc: '先保存上方配置,再验证当前模型连通性或拉取模型;失败时仍可手动填写模型 ID。',
|
||||
validate: '验证',
|
||||
validating: '验证中…',
|
||||
planModelsHint: '打开套餐控制台,复制支持的文本模型 ID 并填写到模型栏。',
|
||||
viewModels: '查看支持的模型',
|
||||
fetchModels: '拉取模型',
|
||||
loadingModels: '拉取模型中…',
|
||||
modelMissing: '未配置模型,请先填写模型 ID。',
|
||||
|
||||
@@ -1261,6 +1261,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',
|
||||
@@ -1344,6 +1349,8 @@ export const zhTW: typeof zhCN = {
|
||||
toolsDesc: '先保存上方配置,再驗證當前模型連通性或拉取模型;失敗時仍可手動填寫模型 ID。',
|
||||
validate: '驗證',
|
||||
validating: '驗證中…',
|
||||
planModelsHint: '開啟方案控制台,複製支援的文字模型 ID 並填入模型欄位。',
|
||||
viewModels: '查看支援的模型',
|
||||
fetchModels: '拉取模型',
|
||||
loadingModels: '拉取模型中…',
|
||||
modelMissing: '未配置模型,請先填寫模型 ID。',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -62,6 +62,7 @@ interface PresetOption {
|
||||
id: string;
|
||||
nameKey: string;
|
||||
defaultEndpoint?: string;
|
||||
endpointPresets?: ProviderDescriptor['endpointPresets'];
|
||||
defaultModel?: string;
|
||||
authRequirement?: ProviderDescriptor['authRequirement'];
|
||||
staticModels?: string[];
|
||||
@@ -82,6 +83,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,
|
||||
|
||||
@@ -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({
|
||||
<ChannelSectionHeading
|
||||
icon="settings"
|
||||
title={t('settings.channels.modelTitle')}
|
||||
description={t('settings.channels.modelHint')}
|
||||
description={t(
|
||||
modelsUrl ? 'settings.providers.planModelsHint' : 'settings.channels.modelHint',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{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 (
|
||||
<>
|
||||
<ChannelFormRow label={t('settings.providers.volcengineAuthModeLabel')}>
|
||||
<ChannelFormRow label={t('settings.providers.volcengineServiceLabel')}>
|
||||
<SelectLite
|
||||
value={volcengineAuthMode}
|
||||
onChange={async (v) => {
|
||||
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 }}
|
||||
/>
|
||||
</ChannelFormRow>
|
||||
{!agentPlan && (
|
||||
<ChannelFormRow label={t('settings.providers.volcengineAuthModeLabel')}>
|
||||
<SelectLite
|
||||
value={volcengineAuthMode}
|
||||
disabled={blocked}
|
||||
onChange={async (v) => {
|
||||
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 }}
|
||||
/>
|
||||
</ChannelFormRow>
|
||||
)}
|
||||
{/* 两种模式使用各自独立的凭据槽位:旧版 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' ? (
|
||||
<>
|
||||
<CredentialField
|
||||
key={`${channelId}:app_key`}
|
||||
@@ -437,7 +555,8 @@ export function ChannelCredentialFields({
|
||||
provider={channelId}
|
||||
mono
|
||||
mask
|
||||
onUserMutation={onUserMutation}
|
||||
onUserMutation={onAsrMutation}
|
||||
onBlockedChange={trackField}
|
||||
/>
|
||||
<CredentialField
|
||||
key={`${channelId}:access_key`}
|
||||
@@ -446,7 +565,8 @@ export function ChannelCredentialFields({
|
||||
provider={channelId}
|
||||
mono
|
||||
mask
|
||||
onUserMutation={onUserMutation}
|
||||
onUserMutation={onAsrMutation}
|
||||
onBlockedChange={trackField}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -457,7 +577,8 @@ export function ChannelCredentialFields({
|
||||
provider={channelId}
|
||||
mono
|
||||
mask
|
||||
onUserMutation={onUserMutation}
|
||||
onUserMutation={onAsrMutation}
|
||||
onBlockedChange={trackField}
|
||||
/>
|
||||
)}
|
||||
<div style={channelSectionStyle}>
|
||||
@@ -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}
|
||||
/>
|
||||
<div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
|
||||
{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')}
|
||||
</div>
|
||||
<ProviderTools
|
||||
key={configRevision}
|
||||
disabled={blocked}
|
||||
kind="asr"
|
||||
modelAccount="asr.model"
|
||||
provider={channelId}
|
||||
@@ -1154,6 +1280,7 @@ function ProviderTools({
|
||||
onModelSelected,
|
||||
onTested,
|
||||
onUserMutation,
|
||||
modelsUrl,
|
||||
showFetchModels = true,
|
||||
disabled = false,
|
||||
}: {
|
||||
@@ -1163,6 +1290,7 @@ function ProviderTools({
|
||||
onModelSelected: () => void;
|
||||
onTested?: () => void;
|
||||
onUserMutation?: () => void;
|
||||
modelsUrl?: string;
|
||||
showFetchModels?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
@@ -1312,16 +1440,18 @@ function ProviderTools({
|
||||
<button
|
||||
className="ol-channel-fetch-models"
|
||||
type="button"
|
||||
onClick={loadModels}
|
||||
onClick={modelsUrl ? () => void openExternal(modelsUrl) : loadModels}
|
||||
style={miniBtnStyle}
|
||||
disabled={disabled || status === 'loading'}
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
{status === 'loading' && operation === 'models'
|
||||
? t('settings.providers.loadingModels')
|
||||
: t('settings.providers.fetchModels')}
|
||||
<Icon name={modelsUrl ? 'external' : 'refresh'} size={14} />
|
||||
{modelsUrl
|
||||
? t('settings.providers.viewModels')
|
||||
: status === 'loading' && operation === 'models'
|
||||
? t('settings.providers.loadingModels')
|
||||
: t('settings.providers.fetchModels')}
|
||||
</button>
|
||||
{models.length > 0 && (
|
||||
{!modelsUrl && models.length > 0 && (
|
||||
<SelectLite
|
||||
value={selectedModel}
|
||||
onChange={applyModel}
|
||||
@@ -1370,6 +1500,7 @@ function ProviderTools({
|
||||
function providerErrorMessage(error: unknown, t: ReturnType<typeof useTranslation>['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 (
|
||||
<ChannelFormRow label={label} htmlFor={options && !customModelMode ? undefined : fieldId}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, width: '100%', minWidth: 0 }}>
|
||||
<>
|
||||
{endpointPresets && (
|
||||
<ChannelFormRow label={endpointPresets.label}>
|
||||
<SelectLite
|
||||
value={presetValue}
|
||||
options={endpointPresets.options}
|
||||
placeholder={loaded ? t('settings.providers.presets.custom') : t('common.loading')}
|
||||
disabled={disabled || status === 'readError'}
|
||||
ariaLabel={endpointPresets.label}
|
||||
onChange={(next) => {
|
||||
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 }}
|
||||
/>
|
||||
</ChannelFormRow>
|
||||
)}
|
||||
<ChannelFormRow label={label} htmlFor={options && !customModelMode ? undefined : fieldId}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexWrap: 'nowrap',
|
||||
}}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 5, width: '100%', minWidth: 0 }}
|
||||
>
|
||||
{options && !customModelMode ? (
|
||||
<SelectLite
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
// 「自定义模型…」逃生口:切回输入框手输任意模型名。
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={fieldId}
|
||||
type={inputType}
|
||||
value={value}
|
||||
placeholder={loaded ? placeholder : t('common.loading')}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
...inputStyle,
|
||||
flex: 1,
|
||||
height: 38,
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{options && customModelMode && (
|
||||
<button
|
||||
onClick={() => setCustomModelMode(false)}
|
||||
title={t('settings.providers.presetListLabel', 'Back to presets')}
|
||||
style={iconBtnStyle}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon name="chevDown" size={13} />
|
||||
</button>
|
||||
)}
|
||||
{defaultValue && !value && loaded && (
|
||||
<button
|
||||
onClick={fillDefault}
|
||||
title={t('settings.providers.fillDefault')}
|
||||
style={iconBtnStyle}
|
||||
disabled={!loaded}
|
||||
>
|
||||
<Icon name="check" size={13} />
|
||||
</button>
|
||||
)}
|
||||
{mask && (
|
||||
<button
|
||||
onClick={() => setRevealed((r) => !r)}
|
||||
title={revealed ? t('common.hide') : t('common.show')}
|
||||
style={iconBtnStyle}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon name="eye" size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onCopy}
|
||||
title={t('common.copy')}
|
||||
style={iconBtnStyle}
|
||||
disabled={!value || disabled}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexWrap: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Icon name="copy" size={14} />
|
||||
</button>
|
||||
{/* readError 是字段无法读取的持续错误,留在原位提示用户该字段不可用;
|
||||
{options && !customModelMode ? (
|
||||
<SelectLite
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
// 「自定义模型…」逃生口:切回输入框手输任意模型名。
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={fieldId}
|
||||
type={inputType}
|
||||
value={value}
|
||||
placeholder={loaded ? placeholder : t('common.loading')}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
readOnly={!!endpointPresets && !!presetValue}
|
||||
style={{
|
||||
...inputStyle,
|
||||
flex: 1,
|
||||
height: 38,
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{options && customModelMode && (
|
||||
<button
|
||||
onClick={() => setCustomModelMode(false)}
|
||||
title={t('settings.providers.presetListLabel', 'Back to presets')}
|
||||
style={iconBtnStyle}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon name="chevDown" size={13} />
|
||||
</button>
|
||||
)}
|
||||
{defaultValue && !value && loaded && (
|
||||
<button
|
||||
onClick={fillDefault}
|
||||
title={t('settings.providers.fillDefault')}
|
||||
style={iconBtnStyle}
|
||||
disabled={!loaded}
|
||||
>
|
||||
<Icon name="check" size={13} />
|
||||
</button>
|
||||
)}
|
||||
{mask && (
|
||||
<button
|
||||
onClick={() => setRevealed((r) => !r)}
|
||||
title={revealed ? t('common.hide') : t('common.show')}
|
||||
style={iconBtnStyle}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon name="eye" size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onCopy}
|
||||
title={t('common.copy')}
|
||||
style={iconBtnStyle}
|
||||
disabled={!value || disabled}
|
||||
>
|
||||
<Icon name="copy" size={14} />
|
||||
</button>
|
||||
{/* readError 是字段无法读取的持续错误,留在原位提示用户该字段不可用;
|
||||
其它瞬态状态(saving / saved / saveError / copied / copyError)都通过
|
||||
emitSaved 发到右上角统一 toast,不再内联占位。 */}
|
||||
{status === 'readError' && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--ol-warn)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{t('settings.providers.readFailed')}
|
||||
{status === 'readError' && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--ol-warn)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{t('settings.providers.readFailed')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{trailing && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 6 }}>
|
||||
{trailing}
|
||||
</div>
|
||||
)}
|
||||
{showInsecureEndpointWarning && (
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-warn)', lineHeight: 1.45 }}>
|
||||
{t('settings.providers.endpointHttpWarning')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{trailing && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 6 }}>{trailing}</div>
|
||||
)}
|
||||
{showInsecureEndpointWarning && (
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-warn)', lineHeight: 1.45 }}>
|
||||
{t('settings.providers.endpointHttpWarning')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</ChannelFormRow>
|
||||
</ChannelFormRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user