From 7fd0ed683b23f0843c5d3475e67c460c7d7b57a6 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Tue, 8 Sep 2026 14:21:30 +0800 Subject: [PATCH 1/2] feat(llm): add channel request formats and compatibility presets --- .../app/crates/openless-core/src/api.rs | 187 ++++- .../openless-core/src/cloud_providers.rs | 14 +- .../crates/openless-core/src/credentials.rs | 9 + .../openless-core/src/credentials_legacy.rs | 21 + .../app/crates/openless-core/src/domains.rs | 2 + .../app/crates/openless-core/src/lib.rs | 1 + .../crates/openless-core/src/llm_protocol.rs | 749 ++++++++++++++++++ .../app/crates/openless-core/src/polish.rs | 645 +++++++++------ .../openless-core/src/provider_rules.rs | 18 +- .../openless-core/src/provider_service.rs | 111 ++- openless-all/app/linux-egui/src/backend.rs | 1 + openless-all/app/linux-egui/src/main.rs | 2 + .../app/src-tauri/src/commands/credentials.rs | 18 + .../app/src-tauri/src/commands/providers.rs | 12 +- .../src-tauri/src/persistence/credentials.rs | 88 ++ openless-all/app/src/i18n/en.ts | 21 +- openless-all/app/src/i18n/ja.ts | 21 +- openless-all/app/src/i18n/ko.ts | 21 +- openless-all/app/src/i18n/zh-CN.ts | 21 +- openless-all/app/src/i18n/zh-TW.ts | 21 +- .../app/src/lib/ipc/asr-credentials.ts | 10 +- openless-all/app/src/lib/ipc/channels.ts | 31 +- openless-all/app/src/lib/ipc/mock-data.ts | 1 + openless-all/app/src/lib/ipc/providers.ts | 14 +- .../src/pages/settings/ChannelList.test.ts | 2 + .../app/src/pages/settings/ChannelList.tsx | 15 +- .../pages/settings/LlmProtocolFields.test.ts | 37 + .../src/pages/settings/LlmProtocolFields.tsx | 124 +++ .../pages/settings/ProvidersSection.test.ts | 2 + .../src/pages/settings/ProvidersSection.tsx | 85 +- 30 files changed, 1970 insertions(+), 334 deletions(-) create mode 100644 openless-all/app/crates/openless-core/src/llm_protocol.rs create mode 100644 openless-all/app/src/pages/settings/LlmProtocolFields.test.ts create mode 100644 openless-all/app/src/pages/settings/LlmProtocolFields.tsx diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 75f5f7f61..5841008d8 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -3438,7 +3438,40 @@ impl OpenLessBackend { key: CredentialKey, value: SecretValue, ) -> Result { + if key.namespace == crate::CredentialNamespace::Llm + && crate::llm_protocol::CONFIG_ACCOUNTS.contains(&key.account.as_str()) + { + crate::llm_protocol::LlmProtocolConfig::default() + .apply(&key.account, value.expose_secret())?; + } + let invalidate = if key.namespace == crate::CredentialNamespace::Llm { + let id = match &key.provider_id { + Some(id) => id.clone(), + None => { + self.deps + .credential_store + .active_provider(crate::ProviderSlot::Llm) + .await? + } + }; + self.list_channels(ChannelKind::Llm) + .await? + .into_iter() + .find(|channel| channel.id == id) + .map(|_| id) + } else { + None + }; self.deps.credential_store.write(key, value).await?; + if let Some(id) = invalidate { + self.deps + .credential_store + .mutate_channel(ChannelMutation::InvalidateTest { + kind: ChannelKind::Llm, + id, + }) + .await?; + } self.refresh_and_publish_credentials().await } @@ -3485,13 +3518,51 @@ impl OpenLessBackend { id: String, provider_type: String, ) -> Result<(), BackendError> { - self.apply_channel_mutation(ChannelMutation::SetProviderType { - kind, - id, - provider_type, - }) - .await - .map(|_| ()) + let provider_type = provider_type.trim().to_string(); + if provider_type.trim().is_empty() { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "provider type must not be blank", + )); + } + let previous = self + .list_channels(kind) + .await? + .into_iter() + .find(|channel| channel.id == id) + .ok_or_else(|| { + BackendError::new(BackendErrorCode::InvalidArgument, "unknown channel") + })?; + let key = CredentialKey::new( + crate::CredentialNamespace::Llm, + Some(id.clone()), + crate::llm_protocol::REQUEST_FORMAT_ACCOUNT, + )?; + let reset = kind == ChannelKind::Llm && previous.provider_type != provider_type; + let old_format = if reset { + let value = self.deps.credential_store.read(key.clone()).await?; + self.deps.credential_store.remove(key.clone()).await?; + value + } else { + None + }; + let result = self + .deps + .credential_store + .mutate_channel(ChannelMutation::SetProviderType { + kind, + id, + provider_type, + }) + .await + .map(|_| ()); + if result.is_err() { + if let Some(value) = old_format { + self.deps.credential_store.write(key, value).await?; + } + } + result?; + self.refresh_and_publish_credentials().await.map(|_| ()) } pub async fn delete_channel_if_blank( @@ -8252,6 +8323,108 @@ mod tests { )); } + #[tokio::test] + async fn llm_protocol_mutations_reset_only_the_format_and_invalidate_tests() { + use crate::credentials::{CredentialNamespace, InMemoryCredentialStore, SecretValue}; + use crate::llm_protocol::*; + let backend = OpenLessBackend::new( + BackendConfig { + data_dir: std::env::temp_dir() + .join(format!("openless-protocol-{}", uuid::Uuid::new_v4())), + ..BackendConfig::default() + }, + BackendDependencies { + host_actions: Arc::new(FakeHost::default()), + text_inserter: Arc::new(FakeInserter), + dictation_engine: Arc::new(FakeEngine), + task_spawner: Arc::new(TokioTaskSpawner), + credential_store: Arc::new(InMemoryCredentialStore::default()), + services: crate::domains::BackendServices::unsupported(), + local_asr_runtime: None, + marketplace_config: None, + selection_runtime: None, + selection_polisher: None, + qa_runtime: None, + }, + ) + .unwrap(); + let id = backend + .create_channel(ChannelKind::Llm, "custom".into(), "test".into()) + .await + .unwrap(); + let key = |account: &str| { + CredentialKey::new(CredentialNamespace::Llm, Some(id.clone()), account).unwrap() + }; + backend + .set_credential(key(REQUEST_FORMAT_ACCOUNT), SecretValue::new("messages")) + .await + .unwrap(); + backend + .set_credential( + key(crate::credentials::LLM_API_KEY_ACCOUNT), + SecretValue::new("fixture-key"), + ) + .await + .unwrap(); + backend + .record_channel_test(ChannelKind::Llm, id.clone(), true, Some(1), None) + .await + .unwrap(); + assert!(backend.list_channels(ChannelKind::Llm).await.unwrap()[0] + .last_test + .is_some()); + backend + .set_credential( + key(crate::credentials::LLM_MODEL_ACCOUNT), + SecretValue::new("new-model"), + ) + .await + .unwrap(); + assert!(backend.list_channels(ChannelKind::Llm).await.unwrap()[0] + .last_test + .is_none()); + assert_eq!( + backend + .read_credential(key(REQUEST_FORMAT_ACCOUNT)) + .await + .unwrap() + .unwrap() + .expose_secret(), + "messages" + ); + assert!(backend + .set_credential(key(REQUEST_FORMAT_ACCOUNT), SecretValue::new("invalid")) + .await + .is_err()); + backend + .set_channel_provider_type(ChannelKind::Llm, id.clone(), "custom_responses".into()) + .await + .unwrap(); + assert!(backend + .read_credential(key(REQUEST_FORMAT_ACCOUNT)) + .await + .unwrap() + .is_none()); + assert_eq!( + backend + .read_credential(key(crate::credentials::LLM_API_KEY_ACCOUNT)) + .await + .unwrap() + .unwrap() + .expose_secret(), + "fixture-key" + ); + assert_eq!( + backend + .read_credential(key(crate::credentials::LLM_MODEL_ACCOUNT)) + .await + .unwrap() + .unwrap() + .expose_secret(), + "new-model" + ); + } + #[tokio::test] async fn lifecycle_is_idempotent_and_emits_started_once_per_transition() { let (backend, _) = backend(); 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 67b4640b1..e95d49aea 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -77,6 +77,8 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[ "minimax", "stepfun", "custom", + "custom_responses", + "custom_messages", ]; pub const SHARED_OMNI_PROVIDER_TYPES: &[&str] = &["openai", "gemini", "dashscope-omni", "custom"]; @@ -1037,12 +1039,9 @@ async fn build_cloud_polisher_provider( return Ok(CloudPolisherProvider::Gemini(provider)); } - let base_url = endpoint - .trim() - .trim_end_matches('/') - .trim_end_matches("/chat/completions") - .trim_end_matches('/') - .to_string(); + let protocol = + crate::llm_protocol::LlmProtocolConfig::load(credentials, channel_id, provider_type) + .await?; let temperature = read_channel_credential( credentials, CredentialNamespace::Llm, @@ -1068,10 +1067,11 @@ async fn build_cloud_polisher_provider( let config = crate::polish::OpenAICompatibleConfig::new( provider_type, "OpenLess LLM", - base_url, + endpoint, api_key, model, ) + .with_protocol(protocol) .with_thinking_enabled(context.polish.llm_thinking_enabled) .with_temperature(crate::polish::openai_compatible_temperature_for_provider( provider_type, diff --git a/openless-all/app/crates/openless-core/src/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index 8b0319602..67189a4d5 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -129,6 +129,10 @@ pub struct ChannelTestSummary { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ChannelMutation { + InvalidateTest { + kind: ChannelKind, + id: String, + }, /// Commit a prepared local runtime and its channel in one metadata revision. ActivateLocalAsr { id: Option, @@ -519,6 +523,7 @@ impl CredentialMetadata { ChannelMutation::ActivateLocalAsr { .. } => ChannelKind::Asr, ChannelMutation::Create { kind, .. } | ChannelMutation::SetProviderType { kind, .. } + | ChannelMutation::InvalidateTest { kind, .. } | ChannelMutation::DeleteIfBlank { kind, .. } | ChannelMutation::Rename { kind, .. } | ChannelMutation::Delete { kind, .. } @@ -630,6 +635,10 @@ impl CredentialMetadata { channel.last_test = None; (kind, ChannelMutationResult::Applied) } + ChannelMutation::InvalidateTest { kind, id } => { + find_channel_mut(&mut self.channels, kind, &id)?.last_test = None; + (kind, ChannelMutationResult::Applied) + } ChannelMutation::DeleteIfBlank { kind, id } => { let channels = self.channels.entry(kind).or_default(); let before = channels.len(); 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 3f1cfedd3..6b5bc8788 100644 --- a/openless-all/app/crates/openless-core/src/credentials_legacy.rs +++ b/openless-all/app/crates/openless-core/src/credentials_legacy.rs @@ -101,6 +101,10 @@ struct LegacyEntry { xfyun_api_key: Option, temperature: Option, extra_headers: Option>, + request_format: Option, + messages_thinking: Option, + max_tokens: Option, + thinking_budget: Option, } impl Default for LegacyEntry { @@ -125,6 +129,10 @@ impl Default for LegacyEntry { xfyun_api_key: None, temperature: None, extra_headers: None, + request_format: None, + messages_thinking: None, + max_tokens: None, + thinking_budget: None, } } } @@ -145,6 +153,10 @@ impl LegacyEntry { &self.advanced_config, &self.xfyun_app_id, &self.xfyun_api_key, + &self.request_format, + &self.messages_thinking, + &self.max_tokens, + &self.thinking_budget, ] .into_iter() .any(|value| value.as_deref().is_some_and(|value| !value.is_empty())) @@ -347,6 +359,15 @@ fn decode_entry( (endpoint, entry.base_url), (model, entry.model), ]; + if namespace == CredentialNamespace::Llm { + use crate::llm_protocol::*; + fields.extend([ + (REQUEST_FORMAT_ACCOUNT, entry.request_format), + (MESSAGES_THINKING_ACCOUNT, entry.messages_thinking), + (MAX_TOKENS_ACCOUNT, entry.max_tokens), + (THINKING_BUDGET_ACCOUNT, entry.thinking_budget), + ]); + } if namespace == CredentialNamespace::Asr { fields.extend([ (VOLCENGINE_APP_KEY_ACCOUNT, entry.app_key), diff --git a/openless-all/app/crates/openless-core/src/domains.rs b/openless-all/app/crates/openless-core/src/domains.rs index 8c57fa779..1aefdd1e8 100644 --- a/openless-all/app/crates/openless-core/src/domains.rs +++ b/openless-all/app/crates/openless-core/src/domains.rs @@ -44,6 +44,8 @@ pub enum ProviderKind { #[serde(rename_all = "camelCase")] pub struct ProviderRequest { pub kind: ProviderKind, + #[serde(default)] + pub thinking_enabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub channel_id: Option, } diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 61e317df0..334294671 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -31,6 +31,7 @@ pub mod host_document; mod hotkey_interpreter; mod less_computer; pub mod llm_gemini; +pub mod llm_protocol; mod marketplace; pub mod model_store; pub mod net; diff --git a/openless-all/app/crates/openless-core/src/llm_protocol.rs b/openless-all/app/crates/openless-core/src/llm_protocol.rs new file mode 100644 index 000000000..3787abda8 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/llm_protocol.rs @@ -0,0 +1,749 @@ +//! 渠道级文本协议:请求格式、鉴权和正文事件由 Core 统一解释。 + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::credentials::{CredentialKey, CredentialNamespace, CredentialStore}; +use crate::polish::{LLMError, OpenAICompatibleConfig}; +use crate::{BackendError, BackendErrorCode}; + +pub const REQUEST_FORMAT_ACCOUNT: &str = "ark.request_format"; +pub const MESSAGES_THINKING_ACCOUNT: &str = "ark.messages_thinking"; +pub const MAX_TOKENS_ACCOUNT: &str = "ark.max_tokens"; +pub const THINKING_BUDGET_ACCOUNT: &str = "ark.thinking_budget"; +pub const CONFIG_ACCOUNTS: [&str; 4] = [ + REQUEST_FORMAT_ACCOUNT, + MESSAGES_THINKING_ACCOUNT, + MAX_TOKENS_ACCOUNT, + THINKING_BUDGET_ACCOUNT, +]; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LlmRequestFormat { + #[default] + ChatCompletions, + Responses, + Messages, +} + +impl LlmRequestFormat { + pub const ALL: [Self; 3] = [Self::ChatCompletions, Self::Responses, Self::Messages]; + + pub fn default_for(provider: &str) -> Self { + match provider { + "custom_responses" => Self::Responses, + "custom_messages" => Self::Messages, + _ => Self::ChatCompletions, + } + } + + pub fn selectable(provider: &str) -> bool { + !matches!(provider, "gemini" | "codex_oauth") + } + + pub fn parse(value: &str) -> Result { + match value.trim() { + "chat_completions" => Ok(Self::ChatCompletions), + "responses" => Ok(Self::Responses), + "messages" => Ok(Self::Messages), + _ => Err(config_error("llmRequestFormatInvalid")), + } + } + + pub fn url(self, endpoint: &str) -> Result { + endpoint_url( + endpoint, + match self { + Self::ChatCompletions => "/chat/completions", + Self::Responses => "/responses", + Self::Messages => "/messages", + }, + ) + } + + pub fn headers(self, api_key: &str) -> Vec<(String, String)> { + let mut headers = Vec::new(); + if self == Self::Messages { + headers.push(("anthropic-version".into(), "2023-06-01".into())); + } + if !api_key.trim().is_empty() { + headers.push(if self == Self::Messages { + ("x-api-key".into(), api_key.to_string()) + } else { + ("Authorization".into(), format!("Bearer {api_key}")) + }); + } + headers + } +} + +/// 更换格式只替换已知的末端路径,不破坏网关前缀及查询参数。 +pub fn endpoint_url(endpoint: &str, suffix: &str) -> Result { + let mut url = url::Url::parse(endpoint.trim()) + .map_err(|_| LLMError::ParseError("invalid LLM endpoint".into()))?; + let path = url.path().trim_end_matches('/'); + let prefix = ["/chat/completions", "/responses", "/messages", "/models"] + .iter() + .find_map(|suffix| path.strip_suffix(suffix)) + .unwrap_or(path); + url.set_path(&format!("{prefix}{suffix}")); + Ok(url.to_string()) +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MessagesThinking { + #[default] + Adaptive, + Budget, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LlmProtocolConfig { + pub format: LlmRequestFormat, + pub messages_thinking: MessagesThinking, + pub max_tokens: u32, + pub thinking_budget: u32, +} + +impl Default for LlmProtocolConfig { + fn default() -> Self { + Self { + format: LlmRequestFormat::ChatCompletions, + messages_thinking: MessagesThinking::Adaptive, + max_tokens: 8192, + thinking_budget: 1024, + } + } +} + +impl LlmProtocolConfig { + pub fn validate_headers( + &self, + headers: &std::collections::HashMap, + ) -> Result<(), BackendError> { + if self.format == LlmRequestFormat::Messages + && headers.keys().any(|name| { + name.eq_ignore_ascii_case("x-api-key") + || name.eq_ignore_ascii_case("anthropic-version") + }) + { + return Err(config_error("llmProtocolHeaderConflict")); + } + Ok(()) + } + + pub async fn load( + store: &dyn CredentialStore, + channel: &str, + provider: &str, + ) -> Result { + let mut config = Self { + format: LlmRequestFormat::default_for(provider), + ..Self::default() + }; + if !LlmRequestFormat::selectable(provider) { + return Ok(config); + } + for account in CONFIG_ACCOUNTS { + let key = + CredentialKey::new(CredentialNamespace::Llm, Some(channel.to_string()), account)?; + if let Some(value) = store.read(key).await? { + config.apply(account, value.expose_secret())?; + } + } + config.validate()?; + Ok(config) + } + + pub fn apply(&mut self, account: &str, value: &str) -> Result<(), BackendError> { + let value = value.trim(); + if value.is_empty() { + return Ok(()); + } + match account { + REQUEST_FORMAT_ACCOUNT => self.format = LlmRequestFormat::parse(value)?, + MESSAGES_THINKING_ACCOUNT => { + self.messages_thinking = match value { + "adaptive" => MessagesThinking::Adaptive, + "budget" => MessagesThinking::Budget, + _ => return Err(config_error("llmThinkingModeInvalid")), + } + } + MAX_TOKENS_ACCOUNT => self.max_tokens = positive_tokens(value)?, + THINKING_BUDGET_ACCOUNT => { + self.thinking_budget = positive_tokens(value)?; + if self.thinking_budget < 1024 { + return Err(config_error("llmThinkingBudgetInvalid")); + } + } + _ => return Err(config_error("llmRequestFormatInvalid")), + } + Ok(()) + } + + pub fn validate(&self) -> Result<(), BackendError> { + if self.format == LlmRequestFormat::Messages + && (self.max_tokens == 0 + || self.messages_thinking == MessagesThinking::Budget + && (self.thinking_budget < 1024 || self.thinking_budget >= self.max_tokens)) + { + return Err(config_error("llmThinkingBudgetInvalid")); + } + Ok(()) + } +} + +fn positive_tokens(value: &str) -> Result { + value + .parse::() + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| config_error("llmTokenLimitInvalid")) +} + +fn config_error(message: &str) -> BackendError { + BackendError::new(BackendErrorCode::InvalidArgument, message) +} + +pub(crate) fn request_body( + config: &OpenAICompatibleConfig, + stream: bool, + messages: Vec, +) -> Value { + let mut body = match config.protocol.format { + LlmRequestFormat::ChatCompletions => { + unreachable!("Chat Completions retains its provider rules") + } + LlmRequestFormat::Responses => { + let mut body = + json!({"model": config.model, "stream": stream, "store": false, "input": messages}); + let model = config + .model + .trim() + .strip_prefix("openai/") + .unwrap_or(config.model.trim()) + .to_ascii_lowercase(); + // 已知普通模型不接受 reasoning;未知网关模型按所选兼容协议声明参数。 + if !(model.starts_with("gpt-4") + || model.starts_with("gpt-3.5") + || model.starts_with("chatgpt-4")) + { + let effort = if model.starts_with("gpt-5-pro") + || model.contains("-pro") && model.starts_with("gpt-5.") + { + "high" + } else if config.thinking_enabled { + "medium" + } else { + "low" + }; + body["reasoning"] = json!({"effort": effort}); + } + body + } + LlmRequestFormat::Messages => { + let mut system = Vec::new(); + let mut turns = Vec::new(); + for message in messages { + if matches!(message["role"].as_str(), Some("system" | "developer")) { + if let Some(text) = message["content"].as_str() { + system.push(text.to_string()); + } + } else { + turns.push(message); + } + } + let mut body = json!({"model": config.model, "stream": stream, "messages": turns, "max_tokens": config.protocol.max_tokens}); + if !system.is_empty() { + body["system"] = json!(system.join("\n\n")); + } + body["thinking"] = if !config.thinking_enabled { + json!({"type": "disabled"}) + } else if config.protocol.messages_thinking == MessagesThinking::Adaptive { + json!({"type": "adaptive"}) + } else { + json!({"type": "enabled", "budget_tokens": config.protocol.thinking_budget}) + }; + body + } + }; + if body.get("reasoning").is_none() + && !(config.protocol.format == LlmRequestFormat::Messages && config.thinking_enabled) + { + if let Some(temperature) = config.temperature { + body["temperature"] = json!(temperature); + } + } + body +} + +fn response_error(message: &str) -> LLMError { + LLMError::ParseError(message.to_string()) +} + +fn check_stop_reason(value: &Value) -> Result<(), LLMError> { + if let Some(reason) = value.as_str() { + if !matches!(reason, "end_turn" | "stop_sequence") { + return Err(response_error("llmResponseIncomplete")); + } + } + Ok(()) +} + +pub(crate) fn extract_text(format: LlmRequestFormat, text: &str) -> Result { + if format == LlmRequestFormat::ChatCompletions { + return crate::polish::extract_assistant_content(text); + } + let value: Value = + serde_json::from_str(text).map_err(|_| response_error("invalid LLM JSON"))?; + if !value["error"].is_null() { + return Err(response_error("llmStreamError")); + } + let mut output = String::new(); + match format { + LlmRequestFormat::Responses => { + if value["status"] != "completed" { + return Err(response_error("llmResponseIncomplete")); + } + if let Some(items) = value["output"].as_array() { + for item in items { + if item["type"] == "message" && item["role"] == "assistant" { + append_blocks(&mut output, &item["content"], "output_text"); + } + } + } + } + LlmRequestFormat::Messages => { + check_stop_reason(&value["stop_reason"])?; + if value["stop_reason"].is_null() { + return Err(response_error("llmResponseIncomplete")); + } + append_blocks(&mut output, &value["content"], "text"); + } + LlmRequestFormat::ChatCompletions => unreachable!(), + } + if output.is_empty() { + return Err(response_error("empty LLM response")); + } + Ok(crate::polish::clean_polish_output(&output)) +} + +fn append_blocks(output: &mut String, blocks: &Value, kind: &str) { + if let Some(blocks) = blocks.as_array() { + for block in blocks { + if block["type"] == kind { + if let Some(text) = block["text"].as_str() { + output.push_str(text); + } + } + } + } +} + +pub(crate) enum StreamEvent { + Text(String), + Done, + Ignore, +} + +/// 共用 SSE 分帧;保留未完整的 UTF-8 字节,不能逐个 HTTP chunk 有损解码。 +pub(crate) struct TextEventStream { + format: LlmRequestFormat, + buffer: String, + pending: Vec, + pub done: bool, +} + +impl TextEventStream { + pub fn new(format: LlmRequestFormat) -> Self { + Self { + format, + buffer: String::new(), + pending: Vec::new(), + done: false, + } + } + + pub fn push(&mut self, chunk: &[u8]) -> Result<(), LLMError> { + crate::polish::append_utf8_sse_chunk(&mut self.buffer, &mut self.pending, chunk)?; + // 在完整字符串上替换,兼容 CR 与 LF 分属不同网络块。 + if self.buffer.contains("\r\n") { + self.buffer = self.buffer.replace("\r\n", "\n"); + } + Ok(()) + } + + pub fn next(&mut self) -> Result, LLMError> { + if self.done { + return Ok(None); + } + let Some(end) = self.buffer.find("\n\n") else { + return Ok(None); + }; + let event = self.buffer[..end].to_string(); + self.buffer.drain(..end + 2); + let data = event + .lines() + .filter_map(|line| line.strip_prefix("data:").map(str::trim_start)) + .collect::>() + .join("\n"); + if data.is_empty() { + return Ok(Some(StreamEvent::Ignore)); + } + if self.format == LlmRequestFormat::ChatCompletions && data.trim() == "[DONE]" { + self.done = true; + return Ok(Some(StreamEvent::Done)); + } + let value: Value = match serde_json::from_str(&data) { + Ok(value) => value, + Err(_) if self.format == LlmRequestFormat::ChatCompletions => { + return Ok(Some(StreamEvent::Ignore)) + } + Err(_) => return Err(response_error("invalid LLM SSE JSON")), + }; + let kind = value["type"] + .as_str() + .or_else(|| { + event + .lines() + .find_map(|line| line.strip_prefix("event:").map(str::trim)) + }) + .unwrap_or(""); + if kind == "error" || !value["error"].is_null() { + return Err(response_error("llmStreamError")); + } + let text = match self.format { + LlmRequestFormat::ChatCompletions => value["choices"][0]["delta"]["content"].as_str(), + LlmRequestFormat::Responses => match kind { + "response.output_text.delta" => value["delta"].as_str(), + "response.completed" => { + if value["response"]["status"] + .as_str() + .is_some_and(|s| s != "completed") + { + return Err(response_error("llmResponseIncomplete")); + } + self.done = true; + None + } + "response.failed" | "response.incomplete" => { + return Err(response_error("llmResponseIncomplete")) + } + _ => None, + }, + LlmRequestFormat::Messages => match kind { + "content_block_start" if value["content_block"]["type"] == "text" => { + value["content_block"]["text"].as_str() + } + "content_block_delta" if value["delta"]["type"] == "text_delta" => { + value["delta"]["text"].as_str() + } + "message_delta" => { + check_stop_reason(&value["delta"]["stop_reason"])?; + None + } + "message_stop" => { + self.done = true; + None + } + _ => None, + }, + }; + Ok(Some( + if let Some(text) = text.filter(|text| !text.is_empty()) { + StreamEvent::Text(text.to_string()) + } else if self.done { + StreamEvent::Done + } else { + StreamEvent::Ignore + }, + )) + } + + pub fn finish(&mut self) -> Result<(), LLMError> { + crate::polish::finish_utf8_sse_chunks(&mut self.buffer, &mut self.pending)?; + if self.format != LlmRequestFormat::ChatCompletions && !self.done { + return Err(response_error("llmResponseIncomplete")); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::credentials::{InMemoryCredentialStore, SecretValue}; + + #[tokio::test] + async fn protocol_defaults_overrides_and_channels_are_independent() { + let store = InMemoryCredentialStore::default(); + for (provider, expected) in [ + ("custom", LlmRequestFormat::ChatCompletions), + ("custom_responses", LlmRequestFormat::Responses), + ("custom_messages", LlmRequestFormat::Messages), + ("deepseek", LlmRequestFormat::ChatCompletions), + ] { + assert_eq!( + LlmProtocolConfig::load(&store, "a", provider) + .await + .unwrap() + .format, + expected + ); + } + let key = CredentialKey::new( + CredentialNamespace::Llm, + Some("a".into()), + REQUEST_FORMAT_ACCOUNT, + ) + .unwrap(); + store + .write(key.clone(), SecretValue::new("messages")) + .await + .unwrap(); + assert_eq!( + LlmProtocolConfig::load(&store, "a", "openai") + .await + .unwrap() + .format, + LlmRequestFormat::Messages + ); + assert_eq!( + LlmProtocolConfig::load(&store, "b", "openai") + .await + .unwrap() + .format, + LlmRequestFormat::ChatCompletions + ); + store.write(key, SecretValue::new("invalid")).await.unwrap(); + assert!(LlmProtocolConfig::load(&store, "a", "openai") + .await + .is_err()); + } + + #[test] + fn urls_and_auth_follow_format_without_losing_gateway_paths() { + for descriptor in crate::provider_rules::provider_descriptors(crate::ProviderKind::Llm) { + if LlmRequestFormat::selectable(descriptor.provider_type.as_str()) { + assert_eq!(descriptor.supported_request_formats, LlmRequestFormat::ALL); + assert_eq!( + descriptor.default_request_format, + Some(LlmRequestFormat::default_for( + descriptor.provider_type.as_str() + )) + ); + } else { + assert!(descriptor.supported_request_formats.is_empty()); + assert!(descriptor.default_request_format.is_none()); + } + } + for format in LlmRequestFormat::ALL { + let suffix = match format { + LlmRequestFormat::ChatCompletions => "chat/completions", + LlmRequestFormat::Responses => "responses", + LlmRequestFormat::Messages => "messages", + }; + for old in [ + "", + "/chat/completions/", + "/responses", + "/messages/", + "/models", + ] { + let base = format!("https://example.com/gateway/v1{old}?tenant=1#local"); + assert_eq!( + format.url(&base).unwrap(), + format!("https://example.com/gateway/v1/{suffix}?tenant=1#local") + ); + assert_eq!( + endpoint_url(&base, "/models").unwrap(), + "https://example.com/gateway/v1/models?tenant=1#local" + ); + } + let headers = format.headers("test-key"); + if format == LlmRequestFormat::Messages { + assert!(headers.contains(&("x-api-key".into(), "test-key".into()))); + assert!(!headers.iter().any(|(name, _)| name == "Authorization")); + } else { + assert_eq!( + headers, + vec![("Authorization".into(), "Bearer test-key".into())] + ); + } + } + let headers = + crate::provider_rules::parse_extra_headers(r#"{"X-API-Key":"override"}"#).unwrap(); + assert!(LlmProtocolConfig::default() + .validate_headers(&headers) + .is_ok()); + assert!(LlmProtocolConfig { + format: LlmRequestFormat::Messages, + ..Default::default() + } + .validate_headers(&headers) + .is_err()); + } + + #[test] + fn request_shapes_and_thinking_do_not_leak_between_protocols() { + let messages = vec![ + json!({"role":"system","content":"rules"}), + json!({"role":"user","content":"old"}), + json!({"role":"assistant","content":"answer"}), + json!({"role":"user","content":"new"}), + ]; + let mut config = OpenAICompatibleConfig::new( + "deepseek", + "test", + "https://example.com/v1", + "key", + "gateway-model", + ) + .with_temperature(Some(0.5)); + config.protocol.format = LlmRequestFormat::Responses; + for enabled in [false, true] { + config.thinking_enabled = enabled; + let body = request_body(&config, true, messages.clone()); + assert_eq!(body["input"], json!(messages)); + assert_eq!(body["store"], false); + assert_eq!( + body["reasoning"]["effort"], + if enabled { "medium" } else { "low" } + ); + for absent in [ + "messages", + "thinking", + "enable_thinking", + "reasoning_effort", + "temperature", + ] { + assert!(body.get(absent).is_none()); + } + } + config.model = "gpt-4o".into(); + let body = request_body(&config, false, messages.clone()); + assert!(body.get("reasoning").is_none()); + assert_eq!(body["temperature"], 0.5); + config.model = "gpt-5-pro".into(); + assert_eq!( + request_body(&config, false, messages.clone())["reasoning"]["effort"], + "high" + ); + config.protocol.format = LlmRequestFormat::Messages; + for enabled in [false, true] { + config.thinking_enabled = enabled; + for mode in [MessagesThinking::Adaptive, MessagesThinking::Budget] { + config.protocol.messages_thinking = mode; + let body = request_body(&config, false, messages.clone()); + assert_eq!(body["system"], "rules"); + assert_eq!(body["messages"], json!(&messages[1..])); + assert_eq!(body["max_tokens"], 8192); + assert_eq!( + body["thinking"]["type"], + if !enabled { + "disabled" + } else if mode == MessagesThinking::Adaptive { + "adaptive" + } else { + "enabled" + } + ); + assert_eq!(body.get("temperature").is_none(), enabled); + if enabled && mode == MessagesThinking::Budget { + assert_eq!(body["thinking"]["budget_tokens"], 1024); + } + for absent in ["input", "reasoning", "reasoning_effort", "enable_thinking"] { + assert!(body.get(absent).is_none()); + } + } + } + } + + #[test] + fn budgets_are_validated_and_non_streaming_extracts_only_complete_text() { + let mut config = LlmProtocolConfig { + format: LlmRequestFormat::Messages, + messages_thinking: MessagesThinking::Budget, + ..Default::default() + }; + for value in ["0", "-1", "1.5", "4294967296"] { + assert!(config.apply(MAX_TOKENS_ACCOUNT, value).is_err()); + } + assert!(config.apply(THINKING_BUDGET_ACCOUNT, "1023").is_err()); + config.apply(THINKING_BUDGET_ACCOUNT, "8192").unwrap(); + assert!(config.validate().is_err()); + config.apply(MAX_TOKENS_ACCOUNT, "10000").unwrap(); + config.validate().unwrap(); + for (format, body) in [ + ( + LlmRequestFormat::Responses, + json!({"status":"completed", "output":[{"type":"reasoning","summary":"secret"},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"你"},{"type":"output_text","text":"好"}]}]}), + ), + ( + LlmRequestFormat::Messages, + json!({"stop_reason":"end_turn", "content":[{"type":"thinking","thinking":"secret"},{"type":"text","text":"你"},{"type":"text","text":"好"}]}), + ), + ] { + assert_eq!(extract_text(format, &body.to_string()).unwrap(), "你好"); + } + assert!(extract_text( + LlmRequestFormat::Responses, + r#"{"status":"incomplete","output":[]}"# + ) + .is_err()); + assert!(extract_text( + LlmRequestFormat::Messages, + r#"{"stop_reason":"max_tokens","content":[{"type":"text","text":"partial"}]}"# + ) + .is_err()); + } + + #[test] + fn sse_handles_every_byte_boundary_and_requires_successful_termination() { + for (format, fixture) in [ + (LlmRequestFormat::Responses, "event: response.output_text.delta\r\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"你好🙂\"}\r\n\r\ndata: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"secret\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n"), + (LlmRequestFormat::Messages, "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你好🙂\"}}\r\n\r\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"secret\"}}\n\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n"), + ] { + for size in 1..=fixture.len() { + let mut stream = TextEventStream::new(format); + let mut text = String::new(); + for chunk in fixture.as_bytes().chunks(size) { + stream.push(chunk).unwrap(); + while let Some(event) = stream.next().unwrap() { + if let StreamEvent::Text(delta) = event { text.push_str(&delta); } + } + } + stream.finish().unwrap(); + assert_eq!(text, "你好🙂"); + } + assert!(TextEventStream::new(format).finish().is_err()); + } + for (format, event) in [ + (LlmRequestFormat::Responses, r#"{"type":"response.failed"}"#), + ( + LlmRequestFormat::Responses, + r#"{"type":"response.incomplete"}"#, + ), + ( + LlmRequestFormat::Messages, + r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"}}"#, + ), + ( + LlmRequestFormat::Messages, + r#"{"type":"error","error":{"message":"secret"}}"#, + ), + ] { + let mut stream = TextEventStream::new(format); + stream + .push(format!("data: {event}\n\n").as_bytes()) + .unwrap(); + let error = stream + .next() + .err() + .expect("must reject unsuccessful streams"); + assert!(!error.to_string().contains("secret")); + } + } +} diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 0a0a84764..b4ea5753a 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -1,6 +1,6 @@ #![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] #![allow(clippy::too_many_arguments)] -//! OpenAI-compatible chat completions client + polish prompts. +//! 渠道级文本协议客户端与润色提示词。 //! //! 提示词在 `prompts` 模块中维护:使用 `# 角色 / # 任务 / # 通用规则 / # 输出 / # 示例` //! 段落式结构,每个 mode 有独立的 1-shot 示例。重写背景见 issue #47。 @@ -13,6 +13,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::{json, Value}; use thiserror::Error; +use crate::llm_protocol::{LlmProtocolConfig, LlmRequestFormat, StreamEvent, TextEventStream}; use crate::shared_types::{ChineseScriptPreference, OutputLanguagePreference, QaChatMessage}; use crate::types::PolishMode; @@ -94,6 +95,7 @@ pub(crate) fn polish_total_timeout_secs(input_chars: usize) -> Duration { #[derive(Clone, Debug)] pub struct OpenAICompatibleConfig { + pub protocol: LlmProtocolConfig, pub provider_id: String, pub display_name: String, pub base_url: String, @@ -120,6 +122,10 @@ impl OpenAICompatibleConfig { let temperature = openai_compatible_temperature_for_provider(&provider_id, None); Self { + protocol: LlmProtocolConfig { + format: LlmRequestFormat::default_for(&provider_id), + ..Default::default() + }, provider_id, display_name: display_name.into(), base_url: base_url.into(), @@ -137,6 +143,11 @@ impl OpenAICompatibleConfig { self } + pub fn with_protocol(mut self, protocol: LlmProtocolConfig) -> Self { + self.protocol = protocol; + self + } + pub fn with_extra_headers(mut self, extra_headers: HashMap) -> Self { self.extra_headers = extra_headers; self @@ -234,8 +245,7 @@ impl ActiveLLMProvider { } } - /// 流式润色仅支持 OpenAI-compatible;Codex 使用 Responses API,保留 1.x 的 - /// 非流式润色能力。调用方须先检查此能力,不能把不同的 SSE 协议混用。 + /// 普通渠道的三种文本协议均支持流式润色;Codex OAuth 保留专用调用路径。 /// Gemini 由共享 cloud_providers 单独分流,不进入 ActiveLLMProvider 枚举。 pub fn supports_streaming_polish(&self) -> bool { matches!(self, Self::OpenAI(_)) @@ -418,7 +428,6 @@ impl ActiveLLMProvider { pub struct OpenAICompatibleLLMProvider { config: OpenAICompatibleConfig, - client: reqwest::Client, /// 润色专用客户端:**不带**按输入长度变化的整请求超时,只留一个防连接泄漏的 /// 硬顶。真正的判据在调用点(流式两把尺子 / 非流式一个总预算)。 /// @@ -434,15 +443,8 @@ impl OpenAICompatibleLLMProvider { // pool survives across utterances instead of paying a fresh TLS handshake // every polish. Falls back to a default client if the builder somehow fails // so we still surface a useful error at request time. - let timeout = config.request_timeout_secs; let no_proxy = crate::net::should_bypass_proxy(&config.base_url, crate::net::use_system_proxy()); - let base_url = config.base_url.clone(); - let client = crate::net::cached_client((timeout, no_proxy), || { - http_client_builder(&base_url, timeout) - .build() - .unwrap_or_else(|_| reqwest::Client::new()) - }); let polish_base_url = config.base_url.clone(); let polish_client = crate::net::cached_client((POLISH_CLIENT_HARD_CAP_SECS, no_proxy), || { @@ -452,7 +454,6 @@ impl OpenAICompatibleLLMProvider { }); Self { config, - client, polish_client, } } @@ -632,7 +633,7 @@ impl OpenAICompatibleLLMProvider { user_prompt: &str, budget: Duration, ) -> Result { - let url = chat_completions_url(&self.config.base_url); + let url = self.config.protocol.format.url(&self.config.base_url)?; let messages = build_polish_history_messages(system_prompt, prior_turns, user_prompt); let body = self.chat_body(false, messages); @@ -654,7 +655,7 @@ impl OpenAICompatibleLLMProvider { user_prompt: &str, budget: Duration, ) -> Result { - let url = chat_completions_url(&self.config.base_url); + let url = self.config.protocol.format.url(&self.config.base_url)?; let body = self.chat_body( false, vec![ @@ -674,6 +675,9 @@ impl OpenAICompatibleLLMProvider { } fn chat_body(&self, stream: bool, messages: Vec) -> Value { + if self.config.protocol.format != LlmRequestFormat::ChatCompletions { + return crate::llm_protocol::request_body(&self.config, stream, messages); + } let mut body = json!({ "model": self.config.model, "stream": stream, @@ -724,17 +728,19 @@ impl OpenAICompatibleLLMProvider { url: &str, body: &serde_json::Value, ) -> Result { - let mut request = self - .polish_client - .post(url) - .header("Content-Type", "application/json"); - if !self.config.api_key.trim().is_empty() { - request = request.header("Authorization", format!("Bearer {}", self.config.api_key)); - } - for (k, v) in &self.config.extra_headers { - request = request.header(k.as_str(), v.as_str()); - } - let request = request.json(body); + self.config + .protocol + .validate() + .and_then(|_| { + self.config + .protocol + .validate_headers(&self.config.extra_headers) + }) + .map_err(|error| LLMError::ParseError(error.message))?; + let request = self + .authorize(self.polish_client.post(url)) + .header("Content-Type", "application/json") + .json(body); let response = send_with_transient_retry(request).await?; @@ -752,12 +758,10 @@ impl OpenAICompatibleLLMProvider { }); } - extract_assistant_content(&body_text) + crate::llm_protocol::extract_text(self.config.protocol.format, &body_text) } - /// 与 `chat_completion` 同条 HTTP 通路,但开 `stream: true` 并把 SSE chunk 一边 - /// 解析、一边通过 `on_delta` 推给调用方(用于实时把答案塞进浮窗气泡)。 - /// 最终返回拼好的完整字符串供调用方写入对话历史。 + /// 问答与润色共用协议解码,但问答保留配置中的整请求预算。 async fn chat_completion_history_streaming( &self, system_prompt: &str, @@ -769,125 +773,27 @@ impl OpenAICompatibleLLMProvider { F: Fn(&str) + Send + Sync, C: Fn() -> bool + Send + Sync, { - let mut msgs: Vec = Vec::with_capacity(history.len() + 1); - msgs.push(json!({ "role": "system", "content": system_prompt })); - for m in history { - msgs.push(json!({ "role": m.role, "content": m.content })); - } - - let url = chat_completions_url(&self.config.base_url); - let body = self.chat_body(true, msgs); - - log::info!( - "[llm] POST {} provider={} model={} chat_turns={} stream=true", - crate::net::sanitized_url_for_logs(&url), - self.config.provider_id, - self.config.model, - history.len() - ); - - let mut request = self - .client - .post(&url) - .header("Content-Type", "application/json") - .header("Accept", "text/event-stream"); - if !self.config.api_key.trim().is_empty() { - request = request.header("Authorization", format!("Bearer {}", self.config.api_key)); - } - for (k, v) in &self.config.extra_headers { - request = request.header(k.as_str(), v.as_str()); - } - let request = request.json(&body); - - let response = send_with_transient_retry(request).await?; - - let status = response.status(); - if !status.is_success() { - // 失败时仍把 body 读一遍方便诊断 - let body_text = response.text().await.map_err(llm_error_from_reqwest)?; - let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len()); - let preview = safe_str_slice(&body_text, preview_end); - log::error!("[llm] HTTP {} body={}", status.as_u16(), preview); - return Err(LLMError::InvalidResponse { - status: status.as_u16(), - body: preview.to_string(), - }); - } - - // SSE 流:一帧 = 若干行,以 `\n\n` 分隔。每行如 `data: {...}` 或 `data: [DONE]`。 - // 一个 chunk() 可能包含半帧或多帧;用 buffer 累积后再按 `\n\n` 切。 - let mut response = response; - let mut buffer = String::new(); - let mut utf8_pending: Vec = Vec::new(); - let mut full_text = String::new(); - let mut cancelled = false; - loop { - // 取消旗标:用户取消 / 关浮窗时立即 break,不再 drain HTTP body。 - // 否则 reqwest 会读完整个流(包括 LLM 后续 token)烧 quota。详见 issue #161。 - if should_cancel() { - log::info!("[llm] stream cancelled by caller; breaking SSE loop"); - cancelled = true; - break; - } - let chunk_opt = response.chunk().await.map_err(llm_error_from_reqwest)?; - let Some(chunk) = chunk_opt else { break }; - append_utf8_sse_chunk(&mut buffer, &mut utf8_pending, &chunk)?; - - while let Some(idx) = buffer.find("\n\n") { - let event = buffer[..idx].to_string(); - buffer.drain(..idx + 2); - for line in event.lines() { - let Some(payload) = line - .strip_prefix("data: ") - .or_else(|| line.strip_prefix("data:")) - else { - continue; - }; - let payload = payload.trim(); - if payload.is_empty() || payload == "[DONE]" { - continue; - } - let v: Value = match serde_json::from_str(payload) { - Ok(v) => v, - Err(e) => { - log::warn!( - "[llm] SSE parse skip: {e}; payload preview: {}", - safe_str_slice(payload, 80) - ); - continue; - } - }; - if let Some(delta) = v["choices"][0]["delta"]["content"].as_str() { - if !delta.is_empty() { - full_text.push_str(delta); - on_delta(delta); - } - } - } - } - } - if !cancelled { - finish_utf8_sse_chunks(&mut buffer, &mut utf8_pending)?; - } - - log::info!( - "[llm] HTTP 200 stream done; total chars={}", - full_text.chars().count() - ); - - if full_text.is_empty() { - return Err(LLMError::InvalidResponse { - status: 200, - body: "empty stream".to_string(), - }); - } - Ok(full_text) + let mut messages = vec![json!({ "role": "system", "content": system_prompt })]; + for message in history { + messages.push(json!({ "role": message.role, "content": message.content })); + } + let budget = Duration::from_secs(self.config.request_timeout_secs); + tokio::time::timeout( + budget, + self.chat_completion_messages_streaming( + messages, + StreamingTimeouts { + first_token: budget, + idle: budget, + }, + on_delta, + should_cancel, + ), + ) + .await + .map_err(|_| LLMError::Timeout)? } - /// 把已经构造好的 `messages` 列表(包含 system + 历史 + 当前 user)作为 - /// `stream: true` 的 body 发出去,SSE 一帧一帧解析。供 `polish_streaming` 复用, - /// 跟 `chat_completion_history_streaming` 的 SSE 解析逻辑同款 —— 后者多了一步从 - /// `QaChatMessage[]` 装配 messages 的工作。 async fn chat_completion_messages_streaming( &self, messages: Vec, @@ -899,151 +805,132 @@ impl OpenAICompatibleLLMProvider { F: Fn(&str) + Send + Sync, C: Fn() -> bool + Send + Sync, { - let url = chat_completions_url(&self.config.base_url); + if should_cancel() { + return Err(LLMError::Network("cancelled".into())); + } + self.config + .protocol + .validate() + .and_then(|_| { + self.config + .protocol + .validate_headers(&self.config.extra_headers) + }) + .map_err(|error| LLMError::ParseError(error.message))?; + let url = self.config.protocol.format.url(&self.config.base_url)?; let body = self.chat_body(true, messages); - - let mut request = self - .polish_client - .post(&url) + log::info!( + "[llm] POST {} provider={} model={} format={:?} stream=true", + crate::net::sanitized_url_for_logs(&url), + self.config.provider_id, + self.config.model, + self.config.protocol.format + ); + let request = self + .authorize(self.polish_client.post(&url)) .header("Content-Type", "application/json") - .header("Accept", "text/event-stream"); - if !self.config.api_key.trim().is_empty() { - request = request.header("Authorization", format!("Bearer {}", self.config.api_key)); - } - for (k, v) in &self.config.extra_headers { - request = request.header(k.as_str(), v.as_str()); - } - let request = request.json(&body); - - let response = send_with_transient_retry(request).await?; - + .header("Accept", "text/event-stream") + .json(&body); + let started = std::time::Instant::now(); + // 取消要能唤醒正在等待网络数据的请求,不能只在 chunk 之间检查。 + let cancellation = async { + while !should_cancel() { + tokio::time::sleep(Duration::from_millis(25)).await; + } + }; + tokio::pin!(cancellation); + let mut response = tokio::select! { + _ = &mut cancellation => return Err(LLMError::Network("cancelled".into())), + result = tokio::time::timeout(timeouts.first_token, send_with_transient_retry(request)) => { + result.map_err(|_| LLMError::Timeout)?? + } + }; let status = response.status(); if !status.is_success() { - let body_text = response.text().await.map_err(llm_error_from_reqwest)?; - let preview_end = BODY_PREVIEW_LIMIT.min(body_text.len()); - let preview = safe_str_slice(&body_text, preview_end); - log::error!("[llm] streaming HTTP {} body={}", status.as_u16(), preview); + let body_text = tokio::select! { + _ = &mut cancellation => return Err(LLMError::Network("cancelled".into())), + result = tokio::time::timeout(timeouts.first_token.saturating_sub(started.elapsed()), response.text()) => { + result.map_err(|_| LLMError::Timeout)?.map_err(llm_error_from_reqwest)? + } + }; return Err(LLMError::InvalidResponse { status: status.as_u16(), - body: preview.to_string(), + body: safe_str_slice(&body_text, BODY_PREVIEW_LIMIT.min(body_text.len())) + .to_string(), }); } - - let mut response = response; - let mut buffer = String::new(); - let mut utf8_pending: Vec = Vec::new(); + let mut events = TextEventStream::new(self.config.protocol.format); let mut full_text = String::new(); - let mut delta_count: u64 = 0; let mut cancelled = false; - let stream_started = std::time::Instant::now(); - let mut first_content_at: Option = None; - loop { + while !events.done { if should_cancel() { - log::info!( - "[llm] polish stream cancelled by caller after {} deltas ({} chars); breaking SSE loop", - delta_count, - full_text.chars().count() - ); cancelled = true; break; } - // 首字之前用「还剩多少首字预算」,首字之后用「两个 chunk 之间能空多久」。 - // 注意首字预算是从请求发出起算的**总量**,不随 chunk 到达而重置——推理模型 - // 思考期的 reasoning_content 是一串正常 chunk,若让它续命,用户干等就没有上限。 - let budget = match first_content_at { - None => timeouts - .first_token - .saturating_sub(stream_started.elapsed()), - Some(_) => timeouts.idle, + let budget = if full_text.is_empty() { + timeouts.first_token.saturating_sub(started.elapsed()) + } else { + timeouts.idle }; - let chunk_opt = match tokio::time::timeout(budget, response.chunk()).await { - Ok(result) => result.map_err(llm_error_from_reqwest)?, - Err(_) => { - // 已经交给 on_delta 的字此刻就在用户屏幕上;上层 dictation 的 Failed - // 分支拿 typed_text 当 final_text,屏幕 / history / 剪贴板保持一致。 - match first_content_at { - None => log::error!( - "[llm] polish stream timed out waiting for first content delta (budget {:?}); \ - 模型可能仍在思考——加长首字预算或换非推理模型", - timeouts.first_token - ), - Some(first) => log::error!( - "[llm] polish stream stalled {:?} after {} chars (first delta at {:?}); \ - 已落屏的字保留", - timeouts.idle, - full_text.chars().count(), - first - ), - } - return Err(LLMError::Timeout); + let chunk = tokio::select! { + _ = &mut cancellation => { cancelled = true; break; } + result = tokio::time::timeout(budget, response.chunk()) => { + result.map_err(|_| LLMError::Timeout)?.map_err(llm_error_from_reqwest)? } }; - let Some(chunk) = chunk_opt else { break }; - append_utf8_sse_chunk(&mut buffer, &mut utf8_pending, &chunk)?; - - while let Some(idx) = buffer.find("\n\n") { - let event = buffer[..idx].to_string(); - buffer.drain(..idx + 2); - for line in event.lines() { - let Some(payload) = line - .strip_prefix("data: ") - .or_else(|| line.strip_prefix("data:")) - else { - continue; - }; - let payload = payload.trim(); - if payload.is_empty() || payload == "[DONE]" { - continue; - } - let v: Value = match serde_json::from_str(payload) { - Ok(v) => v, - Err(e) => { - log::warn!( - "[llm] polish SSE parse skip: {e}; payload preview: {}", - safe_str_slice(payload, 80) - ); - continue; - } - }; - if let Some(delta) = v["choices"][0]["delta"]["content"].as_str() { - if !delta.is_empty() { - if first_content_at.is_none() { - let elapsed = stream_started.elapsed(); - first_content_at = Some(elapsed); - // 首字延迟是判断「模型思考太久」还是「网络卡住」的关键读数。 - // 之前日志里没有它,7 分钟录音那次只能靠外部实测才量出 43s。 - log::info!( - "[llm] polish stream first content delta after {:.2}s (budget {:?})", - elapsed.as_secs_f64(), - timeouts.first_token - ); - } - full_text.push_str(delta); - delta_count += 1; - on_delta(delta); - } + let Some(chunk) = chunk else { + break; + }; + events.push(&chunk)?; + loop { + if should_cancel() { + cancelled = true; + break; + } + let Some(event) = events.next()? else { + break; + }; + if let StreamEvent::Text(delta) = event { + if full_text.is_empty() { + log::info!( + "[llm] first content delta after {:.2}s", + started.elapsed().as_secs_f64() + ); } + full_text.push_str(&delta); + on_delta(&delta); } } + if cancelled { + break; + } } if !cancelled { - finish_utf8_sse_chunks(&mut buffer, &mut utf8_pending)?; + events.finish()?; } - log::info!( - "[llm] polish stream done; total deltas={} chars={}", - delta_count, + "[llm] stream done; cancelled={} chars={}", + cancelled, full_text.chars().count() ); - if full_text.is_empty() { return Err(LLMError::InvalidResponse { status: 200, - body: "empty polish stream".to_string(), + body: "empty polish stream".into(), }); } Ok(full_text) } + + fn authorize(&self, mut request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + for (name, value) in self.config.protocol.format.headers(&self.config.api_key) { + request = request.header(name, value); + } + for (name, value) in &self.config.extra_headers { + request = request.header(name, value); + } + request + } } #[derive(Clone, Debug)] @@ -1941,7 +1828,7 @@ mod tests { "https://user:pass@example.com/v1/chat/completions?token=query-secret#client-fragment" ); } - use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Mutex as StdMutex; use std::thread; @@ -2236,6 +2123,166 @@ mod tests { request } + #[tokio::test] + async fn all_text_entrypoints_use_the_selected_protocol_over_http() { + for format in LlmRequestFormat::ALL { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + for index in 0..5 { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + 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(); + let path = match format { + LlmRequestFormat::ChatCompletions => "chat/completions", + LlmRequestFormat::Responses => "responses", + LlmRequestFormat::Messages => "messages", + }; + assert!(headers.starts_with(&format!("post /gateway/v1/{path}?tenant=1 "))); + if format == LlmRequestFormat::Messages { + assert!(headers.contains("x-api-key: fixture-key")); + assert!(headers.contains("anthropic-version: 2023-06-01")); + assert!(!headers.contains("authorization:")); + assert!(body["system"].as_str().is_some_and(|text| !text.is_empty())); + } else { + assert!(headers.contains("authorization: bearer fixture-key")); + } + assert!(!headers.contains("chatgpt-account-id")); + let messages = if format == LlmRequestFormat::Responses { + &body["input"] + } else { + &body["messages"] + }; + assert!(messages + .as_array() + .is_some_and(|messages| !messages.is_empty())); + if index == 1 { + assert!(messages + .as_array() + .unwrap() + .iter() + .any(|m| m["role"] == "assistant" && m["content"] == "prior answer")); + } + if index < 3 { + assert_eq!(body["stream"], false); + let response = match format { + LlmRequestFormat::ChatCompletions => json!({"choices":[{"message":{"content":"你好"}}]}), + LlmRequestFormat::Responses => json!({"status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"你好"}]}]}), + LlmRequestFormat::Messages => json!({"stop_reason":"end_turn","content":[{"type":"text","text":"你好"}]}), + }.to_string(); + write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap(); + } else { + assert_eq!(body["stream"], true); + let response = match format { + LlmRequestFormat::ChatCompletions => "data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n", + LlmRequestFormat::Responses => "data: {\"type\":\"response.output_text.delta\",\"delta\":\"你好\"}\r\n\r\ndata: {\"type\":\"response.completed\"}\r\n\r\n", + LlmRequestFormat::Messages => "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你好\"}}\r\n\r\ndata: {\"type\":\"message_stop\"}\r\n\r\n", + }; + let split = response.find('好').unwrap() + 1; + write_chunked_sse_response( + &mut stream, + &[&response.as_bytes()[..split], &response.as_bytes()[split..]], + ); + } + } + }); + let config = OpenAICompatibleConfig::new( + "custom", + "test", + format!("http://{address}/gateway/v1/chat/completions?tenant=1"), + "fixture-key", + "test", + ) + .with_protocol(LlmProtocolConfig { + format, + ..Default::default() + }); + let provider = OpenAICompatibleLLMProvider::new(config); + for history in [vec![], vec![("prior input".into(), "prior answer".into())]] { + assert_eq!( + provider + .polish( + "input", + PolishMode::Light, + &[], + "", + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + None, + &history + ) + .await + .unwrap(), + "你好" + ); + } + assert_eq!( + provider + .translate_to( + "hello", + "Chinese", + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None + ) + .await + .unwrap(), + "你好" + ); + let output = std::sync::Mutex::new(String::new()); + let delta = |text: &str| output.lock().unwrap().push_str(text); + let history = vec![QaChatMessage { + role: "user".into(), + content: "hello".into(), + selection_text: None, + }]; + assert_eq!( + provider + .answer_chat_streaming( + &history, + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + delta, + || false + ) + .await + .unwrap(), + "你好" + ); + assert_eq!(*output.lock().unwrap(), "你好"); + output.lock().unwrap().clear(); + assert_eq!( + provider + .polish_streaming( + "input", + PolishMode::Light, + &[], + "", + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + None, + &[], + delta, + || false + ) + .await + .unwrap(), + "你好" + ); + assert_eq!(*output.lock().unwrap(), "你好"); + server.join().unwrap(); + } + } + fn write_chunked_sse_response(stream: &mut std::net::TcpStream, chunks: &[&[u8]]) { stream .write_all( @@ -2250,6 +2297,86 @@ mod tests { stream.write_all(b"0\r\n\r\n").unwrap(); } + #[tokio::test] + async fn protocol_stream_errors_and_cancellation_keep_already_emitted_text() { + let cancelled_provider = OpenAICompatibleLLMProvider::new(OpenAICompatibleConfig::new( + "custom", + "test", + "invalid endpoint", + "", + "test", + )); + let error = cancelled_provider + .chat_completion_messages_streaming( + Vec::new(), + StreamingTimeouts::for_input(0), + |_| panic!("cancelled request emitted text"), + || true, + ) + .await + .unwrap_err(); + assert!(matches!(error, LLMError::Network(ref message) if message == "cancelled")); + for (format, delta, terminal_error) in [ + ( + LlmRequestFormat::Responses, + r#"{"type":"response.output_text.delta","delta":"partial"}"#, + r#"{"type":"response.failed"}"#, + ), + ( + LlmRequestFormat::Messages, + r#"{"type":"content_block_delta","delta":{"type":"text_delta","text":"partial"}}"#, + r#"{"type":"message_delta","delta":{"stop_reason":"max_tokens"}}"#, + ), + ] { + for cancel in [false, true] { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + read_http_request(&mut stream); + let fixture = format!("data: {delta}\n\ndata: {terminal_error}\n\n"); + write_chunked_sse_response(&mut stream, &[fixture.as_bytes()]); + }); + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "custom", + "test", + format!("http://{address}"), + "", + "test", + ) + .with_protocol(LlmProtocolConfig { + format, + ..Default::default() + }), + ); + let cancelled = AtomicBool::new(false); + let output = std::sync::Mutex::new(String::new()); + let result = provider + .chat_completion_messages_streaming( + vec![json!({"role":"user","content":"hi"})], + StreamingTimeouts::for_input(2), + |text| { + output.lock().unwrap().push_str(text); + cancelled.store(cancel, Ordering::SeqCst); + }, + || cancelled.load(Ordering::SeqCst), + ) + .await; + assert_eq!(*output.lock().unwrap(), "partial"); + if cancel { + assert_eq!(result.unwrap(), "partial"); + } else { + assert!(result + .unwrap_err() + .to_string() + .contains("llmResponseIncomplete")); + } + server.join().unwrap(); + } + } + } + /// 带间隔的 SSE 发送:每个 chunk 前先睡一段,用来模拟「思考很久才出字」和 /// 「出字中途卡死」两种真实流。 fn write_chunked_sse_response_with_delays( 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 e182797a1..6ab43ab80 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -62,7 +62,9 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[ ("codingPlanX", "codingPlanX"), ("minimax", "minimax"), ("stepfun", "stepfun"), - ("custom", "custom"), + ("custom", "customChatCompletions"), + ("custom_responses", "customResponses"), + ("custom_messages", "customMessages"), ]; const OMNI_PROVIDER_TYPES: &[(&str, &str)] = &[ @@ -133,6 +135,8 @@ pub struct ProviderDescriptor { pub auth_requirement: AuthRequirement, pub validation_probe: ValidationProbe, pub static_models: Vec, + pub default_request_format: Option, + pub supported_request_formats: Vec, } pub fn provider_descriptors(kind: ProviderKind) -> Vec { @@ -210,6 +214,16 @@ fn provider_descriptor_with_label( ), }; Some(ProviderDescriptor { + default_request_format: (kind == ProviderKind::Llm + && crate::llm_protocol::LlmRequestFormat::selectable(&id)) + .then(|| crate::llm_protocol::LlmRequestFormat::default_for(&id)), + supported_request_formats: if kind == ProviderKind::Llm + && crate::llm_protocol::LlmRequestFormat::selectable(&id) + { + crate::llm_protocol::LlmRequestFormat::ALL.to_vec() + } else { + Vec::new() + }, kind, provider_type, label_key: label_key.to_string(), @@ -422,6 +436,8 @@ pub fn equivalent_endpoint(left: &str, right: &str) -> bool { .trim() .trim_end_matches('/') .trim_end_matches("/chat/completions") + .trim_end_matches("/responses") + .trim_end_matches("/messages") .trim_end_matches('/') } normalize(left).eq_ignore_ascii_case(normalize(right)) 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 ece54a2f7..d1d54b073 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -23,6 +23,7 @@ use crate::domains::{ ProviderApi, ProviderCheckResult, ProviderKind, ProviderModelsResult, ProviderRequest, }; use crate::errors::{BackendError, BackendErrorCode}; +use crate::llm_protocol::{LlmProtocolConfig, LlmRequestFormat}; use crate::ports::{TextPolisher, TextStreamChunk, TextStreamSink, TranscriptionEngine}; use crate::provider_rules::{ api_key_required, default_asr_endpoint, default_asr_model, default_llm_endpoint, @@ -158,6 +159,13 @@ impl ProviderService { }; Ok(ResolvedProvider { + thinking_enabled: request.thinking_enabled, + protocol: if request.kind == ProviderKind::Llm { + LlmProtocolConfig::load(self.credentials.as_ref(), &provider_id, &provider_type) + .await? + } else { + LlmProtocolConfig::default() + }, kind: request.kind, provider_id, provider_type, @@ -350,6 +358,8 @@ impl ProviderApi for ProviderService { #[derive(Debug, Clone)] struct ResolvedProvider { + thinking_enabled: bool, + protocol: LlmProtocolConfig, kind: ProviderKind, provider_id: String, provider_type: String, @@ -362,6 +372,7 @@ struct ResolvedProvider { impl ResolvedProvider { fn context(&self) -> DictationContext { let mut context = DictationContext::default(); + context.polish.llm_thinking_enabled = self.thinking_enabled; let invocation = ProviderInvocation { provider_id: self.provider_id.clone(), provider_type: self.provider_type.clone(), @@ -448,7 +459,9 @@ fn validate_configuration(resolved: &ResolvedProvider) -> Result<(), BackendErro validate_provider_endpoint(endpoint, resolved.kind == ProviderKind::Asr)?; } if let Some(headers) = resolved.extra_headers.as_deref() { - parse_extra_headers(headers)?; + resolved + .protocol + .validate_headers(&parse_extra_headers(headers)?)?; } } Ok(()) @@ -585,6 +598,19 @@ fn sanitize_validation_error(error: BackendError) -> BackendError { return error; } let message = error.message.as_str(); + for code in [ + "llmResponseIncomplete", + "llmStreamError", + "llmRequestFormatInvalid", + "llmThinkingModeInvalid", + "llmTokenLimitInvalid", + "llmThinkingBudgetInvalid", + "llmProtocolHeaderConflict", + ] { + if message == code || message == format!("parse error: {code}") { + return provider_error(code); + } + } if message.ends_with("is not configured") { return error; } @@ -620,8 +646,7 @@ async fn fetch_models( .or_else(|| default_omni_endpoint(&resolved.provider_type)) .ok_or_else(|| provider_error("provider endpoint is not configured"))?; let url = models_url(endpoint)?; - let is_gemini = - crate::net::sanitized_url_for_logs(&url).contains("generativelanguage.googleapis.com"); + let is_gemini = resolved.provider_type == "gemini"; let mut request_headers = Vec::new(); if let Some(api_key) = resolved .api_key @@ -631,9 +656,16 @@ async fn fetch_models( if is_gemini { request_headers.push(("x-goog-api-key".to_string(), api_key.to_string())); } else { - request_headers.push(("Authorization".to_string(), format!("Bearer {api_key}"))); + request_headers.extend(resolved.protocol.format.headers(api_key)); } } + if resolved.protocol.format == LlmRequestFormat::Messages + && !request_headers + .iter() + .any(|(name, _)| name == "anthropic-version") + { + request_headers.extend(resolved.protocol.format.headers("")); + } if let Some(extra_headers) = resolved.extra_headers.as_deref() { for (name, value) in parse_extra_headers(extra_headers)? { request_headers.push((name, value)); @@ -710,18 +742,8 @@ fn parse_model_list(body: &[u8], is_gemini: bool) -> Result, Backend } fn models_url(endpoint: &str) -> Result { - let mut url = url::Url::parse(endpoint.trim()) - .map_err(|_| invalid_request("provider endpoint is invalid"))?; - let path = url.path().trim_end_matches('/'); - let next_path = if path.ends_with("/models") { - path.to_string() - } else if let Some(prefix) = path.strip_suffix("/chat/completions") { - format!("{prefix}/models") - } else { - format!("{path}/models") - }; - url.set_path(&next_path); - Ok(url.to_string()) + crate::llm_protocol::endpoint_url(endpoint, "/models") + .map_err(|_| invalid_request("provider endpoint is invalid")) } fn map_transport_error(error: ProviderTransportError) -> BackendError { @@ -866,6 +888,44 @@ mod tests { id } + #[tokio::test] + async fn validation_and_model_lists_use_channel_protocol_and_thinking() { + use crate::llm_protocol::*; + for (format, preset, sse, path) in [ + ("responses", "custom_responses", "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\ndata: {\"type\":\"response.completed\"}\n\n", "/v1/responses"), + ("messages", "custom_messages", "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n", "/v1/messages"), + ] { + for enabled in [false, true] { + let (endpoint, request) = spawn_http_response("200 OK", "text/event-stream", sse); + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = create_channel_with_values(&credentials, ChannelKind::Llm, preset, &[ + (LLM_ENDPOINT_ACCOUNT, &endpoint), (LLM_MODEL_ACCOUNT, "test"), (LLM_API_KEY_ACCOUNT, "fixture-key"), + (REQUEST_FORMAT_ACCOUNT, format), + ]).await; + let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)); + service.validate(ProviderRequest { kind: ProviderKind::Llm, channel_id: Some(channel), thinking_enabled: enabled }).await.unwrap(); + let request = request.recv_timeout(Duration::from_secs(2)).unwrap(); + let request = String::from_utf8(request).unwrap(); + assert!(request.starts_with(&format!("POST {path} "))); + let body: serde_json::Value = serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); + if format == "responses" { assert_eq!(body["reasoning"]["effort"], if enabled { "medium" } else { "low" }); } + else { assert_eq!(body["thinking"]["type"], if enabled { "adaptive" } else { "disabled" }); } + } + let (endpoint, request) = spawn_http_response("200 OK", "application/json", r#"{"data":[{"id":"model"}]}"#); + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = create_channel_with_values(&credentials, ChannelKind::Llm, "openai", &[ + (LLM_ENDPOINT_ACCOUNT, &format!("{endpoint}/{format}")), (LLM_MODEL_ACCOUNT, "test"), + (LLM_API_KEY_ACCOUNT, "fixture-key"), (REQUEST_FORMAT_ACCOUNT, format), + ]).await; + let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)); + assert_eq!(service.list_models(ProviderRequest { kind: ProviderKind::Llm, channel_id: Some(channel), thinking_enabled: false }).await.unwrap().models, vec!["model"]); + let request = String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap().to_ascii_lowercase(); + assert!(request.starts_with("get /v1/models ")); + if format == "messages" { assert!(request.contains("x-api-key: fixture-key")); } + else { assert!(request.contains("authorization: bearer fixture-key")); } + } + } + #[tokio::test] async fn openai_compatible_asr_without_key_reaches_the_configured_endpoint() { let (endpoint, request) = @@ -885,6 +945,7 @@ mod tests { service .validate(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Asr, channel_id: Some(channel), }) @@ -918,6 +979,7 @@ mod tests { service .validate(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel), }) @@ -951,6 +1013,7 @@ mod tests { let error = service .validate(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel), }) @@ -981,6 +1044,7 @@ mod tests { let result = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Asr, channel_id: Some(channel), }) @@ -1024,6 +1088,7 @@ mod tests { service .validate(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Asr, channel_id: Some(channel), }) @@ -1092,6 +1157,7 @@ mod tests { let (service, credentials) = service_with_channel().await; let error = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some("missing".to_string()), }) @@ -1108,6 +1174,7 @@ mod tests { let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)); let error = service .validate(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Omni, channel_id: Some("channel".to_string()), }) @@ -1166,6 +1233,7 @@ mod tests { let first_resolved = service .resolve(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(first_id.clone()), }) @@ -1173,6 +1241,7 @@ mod tests { .unwrap(); let second_resolved = service .resolve(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(second_id.clone()), }) @@ -1180,6 +1249,7 @@ mod tests { .unwrap(); let active_resolved = service .resolve(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: None, }) @@ -1286,6 +1356,7 @@ mod tests { let result = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel), }) @@ -1327,6 +1398,7 @@ mod tests { transport.push_response(status, br#"{"data":[]}"#); let error = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel.clone()), }) @@ -1340,6 +1412,7 @@ mod tests { transport.push_response(200, br#"not-json secret-body"#); let error = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel.clone()), }) @@ -1351,6 +1424,7 @@ mod tests { transport.push_response(200, vec![b'x'; MODEL_LIST_MAX_BYTES + 1]); let error = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel.clone()), }) @@ -1389,6 +1463,7 @@ mod tests { transport.push_error(transport_error); let error = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel.clone()), }) @@ -1409,6 +1484,7 @@ mod tests { let error = service .list_models_with_cancellation( ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel), }, @@ -1441,6 +1517,7 @@ mod tests { let error = service .list_models_with_cancellation( ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Asr, channel_id: Some(channel), }, @@ -1490,6 +1567,8 @@ mod tests { ]; for (provider_type, expected_models) in expected { let resolved = ResolvedProvider { + thinking_enabled: false, + protocol: LlmProtocolConfig::default(), kind: ProviderKind::Asr, provider_id: provider_type.to_string(), provider_type: provider_type.to_string(), diff --git a/openless-all/app/linux-egui/src/backend.rs b/openless-all/app/linux-egui/src/backend.rs index 9065cd0c7..8775b8965 100644 --- a/openless-all/app/linux-egui/src/backend.rs +++ b/openless-all/app/linux-egui/src/backend.rs @@ -1108,6 +1108,7 @@ mod tests { .services() .provider .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: None, }) diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index ce7002bde..bb2e97170 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -349,6 +349,7 @@ mod linux_app { .services() .provider .list_models(openless_core::ProviderRequest { + thinking_enabled: backend.get_preferences().llm_thinking_enabled, kind: provider_kind(kind), channel_id: Some(channel_id.clone()), }) @@ -1981,6 +1982,7 @@ mod linux_app { .services() .provider .validate(openless_core::ProviderRequest { + thinking_enabled: backend.get_preferences().llm_thinking_enabled, kind: provider_kind(kind), channel_id: Some(channel_id.clone()), }) diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 9d0ce9f5c..f9fac199a 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -243,6 +243,11 @@ fn read_vault_credential( key: &openless_core::CredentialKey, ) -> Result, openless_core::BackendError> { let result = match (key.namespace, key.account.as_str()) { + (openless_core::CredentialNamespace::Llm, account) + if openless_core::llm_protocol::CONFIG_ACCOUNTS.contains(&account) => + { + CredentialsVault::get_llm_protocol_option(key.provider_id.as_deref(), account) + } (openless_core::CredentialNamespace::Llm, LLM_EXTRA_HEADERS_ACCOUNT) => { match key.provider_id.as_deref() { Some(provider) => serde_json::to_string( @@ -309,6 +314,11 @@ fn write_vault_credential( value: &str, ) -> Result<(), openless_core::BackendError> { let result = match (key.namespace, key.account.as_str()) { + (openless_core::CredentialNamespace::Llm, account) + if openless_core::llm_protocol::CONFIG_ACCOUNTS.contains(&account) => + { + CredentialsVault::set_llm_protocol_option(key.provider_id.as_deref(), account, value) + } (openless_core::CredentialNamespace::Llm, LLM_EXTRA_HEADERS_ACCOUNT) => { match key.provider_id.as_deref() { Some(provider) => { @@ -738,6 +748,9 @@ fn credential_key( provider: Option, ) -> Result { let namespace = match account { + account if openless_core::llm_protocol::CONFIG_ACCOUNTS.contains(&account) => { + openless_core::CredentialNamespace::Llm + } LLM_EXTRA_HEADERS_ACCOUNT | LLM_TEMPERATURE_ACCOUNT => { openless_core::CredentialNamespace::Llm } @@ -853,6 +866,11 @@ mod tests { #[test] fn core_llm_accounts_are_supported_by_the_tauri_vault_adapter() { + for account in openless_core::llm_protocol::CONFIG_ACCOUNTS { + let key = credential_key(account, Some("channel-b".into())).unwrap(); + assert_eq!(key.namespace, openless_core::CredentialNamespace::Llm); + assert_eq!(key.provider_id.as_deref(), Some("channel-b")); + } for account in [ openless_core::credentials::LLM_API_KEY_ACCOUNT, openless_core::credentials::LLM_MODEL_ACCOUNT, diff --git a/openless-all/app/src-tauri/src/commands/providers.rs b/openless-all/app/src-tauri/src/commands/providers.rs index 8f8ccb2d7..9929fa954 100644 --- a/openless-all/app/src-tauri/src/commands/providers.rs +++ b/openless-all/app/src-tauri/src/commands/providers.rs @@ -29,7 +29,11 @@ pub async fn validate_provider_credentials( let kind = parse_provider_kind(&kind)?; core.services() .provider - .validate(openless_core::ProviderRequest { kind, channel_id }) + .validate(openless_core::ProviderRequest { + kind, + channel_id, + thinking_enabled: core.get_preferences().llm_thinking_enabled, + }) .await .map_err(|error| error.message) } @@ -43,7 +47,11 @@ pub async fn list_provider_models( let kind = parse_provider_kind(&kind)?; core.services() .provider - .list_models(openless_core::ProviderRequest { kind, channel_id }) + .list_models(openless_core::ProviderRequest { + kind, + channel_id, + thinking_enabled: core.get_preferences().llm_thinking_enabled, + }) .await .map_err(|error| error.message) } diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 304881485..eaa643a4f 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -398,9 +398,28 @@ struct CredsLlmEntry { temperature: Option, #[serde(skip_serializing_if = "Option::is_none")] extraHeaders: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + requestFormat: Option, + #[serde(skip_serializing_if = "Option::is_none")] + messagesThinking: Option, + #[serde(skip_serializing_if = "Option::is_none")] + maxTokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + thinkingBudget: Option, } impl CredsLlmEntry { + fn protocol_option(&mut self, account: &str) -> Result<&mut Option> { + use openless_core::llm_protocol::*; + match account { + REQUEST_FORMAT_ACCOUNT => Ok(&mut self.requestFormat), + MESSAGES_THINKING_ACCOUNT => Ok(&mut self.messagesThinking), + MAX_TOKENS_ACCOUNT => Ok(&mut self.maxTokens), + THINKING_BUDGET_ACCOUNT => Ok(&mut self.thinkingBudget), + _ => anyhow::bail!("unsupported LLM protocol option"), + } + } + fn is_empty(&self) -> bool { // 同 CredsAsrEntry::is_empty —— 渠道卡片只能由用户显式删除。 if self.channel.providerType.is_some() { @@ -416,6 +435,10 @@ impl CredsLlmEntry { && self.baseURL.as_deref().unwrap_or("").is_empty() && self.model.as_deref().unwrap_or("").is_empty() && self.temperature.is_none() + && self.requestFormat.is_none() + && self.messagesThinking.is_none() + && self.maxTokens.is_none() + && self.thinkingBudget.is_none() && self .extraHeaders .as_ref() @@ -2360,6 +2383,29 @@ impl CredentialsVault { save_credentials(&root) } + pub fn get_llm_protocol_option(id: Option<&str>, account: &str) -> Result> { + let _guard = credentials_lock().lock(); + let mut root = load_credentials_for_update()?; + let id = id.unwrap_or(&root.active.llm).to_string(); + match root.providers.llm.get_mut(&id) { + Some(entry) => Ok(entry.protocol_option(account)?.clone()), + None => Ok(None), + } + } + + pub fn set_llm_protocol_option(id: Option<&str>, account: &str, value: &str) -> Result<()> { + let _guard = credentials_lock().lock(); + let mut config = openless_core::llm_protocol::LlmProtocolConfig::default(); + config.apply(account, value)?; + let mut root = load_credentials_for_update()?; + let id = id.unwrap_or(&root.active.llm).to_string(); + let entry = root.providers.llm.entry(id).or_default(); + *entry.protocol_option(account)? = + (!value.trim().is_empty()).then(|| value.trim().to_string()); + entry.channel.lastTest = None; + save_credentials(&root) + } + /// 写入指定 LLM 渠道的采样温度,不改变 active 渠道。 pub fn set_llm_temperature_for_provider(id: &str, value: &str) -> Result<()> { let _guard = credentials_lock().lock(); @@ -2442,6 +2488,48 @@ mod tests { .all(|chunk| chunk.encode_utf16().count() <= KEYRING_CHUNK_MAX_UTF16_UNITS)); } + #[test] + fn llm_protocol_options_survive_vault_reload_and_core_projection() { + use openless_core::llm_protocol::*; + let mut root = CredsRoot::default(); + let entry = root.providers.llm.entry("channel-b".into()).or_default(); + let values = ["messages", "budget", "8192", "2048"]; + for (account, value) in CONFIG_ACCOUNTS.into_iter().zip(values) { + *entry.protocol_option(account).unwrap() = Some(value.into()); + } + assert!(!entry.has_no_content()); + let serialized = serde_json::to_string(&root).unwrap(); + let mut restored: CredsRoot = serde_json::from_str(&serialized).unwrap(); + for (account, value) in CONFIG_ACCOUNTS.into_iter().zip(values) { + assert_eq!( + restored + .providers + .llm + .get_mut("channel-b") + .unwrap() + .protocol_option(account) + .unwrap() + .as_deref(), + Some(value) + ); + } + let decoded = + openless_core::credentials_legacy::decode_legacy_credentials(&serialized).unwrap(); + for (account, value) in CONFIG_ACCOUNTS.into_iter().zip(values) { + assert!(decoded + .secrets + .iter() + .any( + |(key, secret)| key.provider_id.as_deref() == Some("channel-b") + && key.account == account + && secret.expose_secret() == value + )); + } + let old: CredsLlmEntry = serde_json::from_str(r#"{"apiKey":"old-key"}"#).unwrap(); + assert!(old.requestFormat.is_none()); + assert!(!restored.providers.llm.contains_key("channel-a")); + } + #[test] fn omni_accounts_route_to_omni_namespace_only() { // 多模态(Omni)凭据必须与 LLM/ASR 命名空间完全隔离(issue #902): diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index b41cfd459..2c49768fc 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1010,6 +1010,9 @@ export const en: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', stepfun: 'StepFun', + customChatCompletions: "Custom · Chat Completions", + customResponses: "Custom · Responses", + customMessages: "Custom · Messages", custom: 'Custom', asrVolcengine: 'Volcengine bigasr', asrBailian: 'Alibaba Bailian realtime ASR', @@ -1084,7 +1087,23 @@ export const en: typeof zhCN = { thinkingModeLabel: 'Thinking', thinkingModeOn: 'On', thinkingModeOff: 'Off', - thinkingModeHint: 'Off disables or minimizes thinking with provider-level official parameters. On enables thinking by channel defaults. No prompt injection or per-model adapters.', + requestFormatLabel: "Request format", + messagesThinkingLabel: "Thinking mode", + thinkingAdaptive: "Adaptive", + thinkingBudget: "Fixed budget", + maxTokensLabel: "Maximum output tokens", + thinkingBudgetLabel: "Thinking budget tokens", + responsesThinkingHint: "Some models can only reduce thinking, not turn it off. Reasoning requests omit temperature.", + messagesThinkingHint: "Older models or compatible gateways may need a fixed budget below the output limit. Thinking requests omit temperature.", + llmRequestFormatInvalid: "Invalid request format. Select a supported format.", + llmThinkingModeInvalid: "Invalid thinking mode. Select a supported mode.", + llmTokenLimitInvalid: "Token limits must be positive integers.", + llmThinkingBudgetInvalid: "Thinking budget must be at least 1024 and below the output limit in fixed-budget mode.", + llmResponseIncomplete: "The response was incomplete or reached its output limit. Already emitted text is retained.", + llmProtocolHeaderConflict: "Messages sets authentication and version headers automatically. Remove x-api-key and anthropic-version from extra headers.", + llmStreamError: "The server returned a stream error. Check the model and request parameters.", + saveProtocol: "Save protocol settings", + thinkingModeHint: "Enable, disable, or reduce thinking using parameters supported by the selected request format and model. No control instructions are injected into prompts.", bailianVocabularyIdLabel: 'Hotword Vocabulary ID (optional)', bailianVocabularyIdNote: 'If you have created a DashScope hotword vocabulary, enter its vocab-... ID. Leave blank to skip hotwords.', bailianModelRealtimeHint: 'Realtime model · transcribes as you speak.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 6d11101fa..036630809 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1012,6 +1012,9 @@ export const ja: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(階躍星辰)', + customChatCompletions: "カスタム · Chat Completions", + customResponses: "カスタム · Responses", + customMessages: "カスタム · Messages", custom: 'カスタム', asrVolcengine: 'Volcengine bigasr', asrBailian: 'Alibaba Bailian リアルタイム ASR', @@ -1086,7 +1089,23 @@ export const ja: typeof zhCN = { thinkingModeLabel: '思考', thinkingModeOn: 'オン', thinkingModeOff: 'オフ', - thinkingModeHint: 'オフではチャネル単位の公式パラメーターで思考を無効化または最小化します。オンではチャネル既定で思考を有効化します。prompt 注入やモデル別適配は行いません。', + requestFormatLabel: "リクエスト形式", + messagesThinkingLabel: "思考方式", + thinkingAdaptive: "適応型", + thinkingBudget: "固定予算", + maxTokensLabel: "最大出力トークン数", + thinkingBudgetLabel: "思考トークン予算", + responsesThinkingHint: "一部のモデルでは思考を軽減できますが、完全には無効にできません。推論リクエストでは温度を送信しません。", + messagesThinkingHint: "旧モデルや互換ゲートウェイでは固定予算が必要な場合があります。予算は最大出力未満にしてください。思考時は温度を送信しません。", + llmRequestFormatInvalid: "リクエスト形式が無効です。選択し直してください。", + llmThinkingModeInvalid: "思考方式が無効です。選択し直してください。", + llmTokenLimitInvalid: "トークン上限は正の整数にしてください。", + llmThinkingBudgetInvalid: "思考予算は1024以上、固定予算では最大出力未満にしてください。", + llmResponseIncomplete: "応答が未完了か出力上限に達しました。出力済みテキストは保持されます。", + llmProtocolHeaderConflict: "Messages の認証とバージョンヘッダーは自動設定されます。追加ヘッダーから x-api-key と anthropic-version を削除してください。", + llmStreamError: "サーバーがストリームエラーを返しました。モデルとリクエスト設定を確認してください。", + saveProtocol: "プロトコル設定を保存", + thinkingModeHint: "選択したリクエスト形式とモデルが対応するパラメータで思考を有効化、無効化または軽減します。プロンプトに制御指示は追加しません。", bailianVocabularyIdLabel: 'ホットワード Vocabulary ID(任意)', bailianVocabularyIdNote: 'DashScope でホットワード辞書を作成済みの場合は vocab-... ID を入力します。空欄なら送信しません。', bailianModelRealtimeHint: 'リアルタイムモデル · 話しながら文字起こし。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index f35c8425f..de0faceb7 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1012,6 +1012,9 @@ export const ko: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax (M3)', stepfun: 'StepFun', + customChatCompletions: "사용자 지정 · Chat Completions", + customResponses: "사용자 지정 · Responses", + customMessages: "사용자 지정 · Messages", custom: '사용자 정의', asrVolcengine: 'Volcengine bigasr', asrBailian: 'Alibaba Bailian 실시간 ASR', @@ -1086,7 +1089,23 @@ export const ko: typeof zhCN = { thinkingModeLabel: '사고', thinkingModeOn: '켜짐', thinkingModeOff: '꺼짐', - thinkingModeHint: '꺼짐은 채널 단위 공식 파라미터로 사고를 끄거나 최소화합니다. 켜짐은 채널 기본값으로 사고를 켭니다. prompt 주입이나 모델별 어댑터는 사용하지 않습니다.', + requestFormatLabel: "요청 형식", + messagesThinkingLabel: "사고 방식", + thinkingAdaptive: "적응형", + thinkingBudget: "고정 예산", + maxTokensLabel: "최대 출력 토큰", + thinkingBudgetLabel: "사고 토큰 예산", + responsesThinkingHint: "일부 모델은 사고를 줄일 수만 있으며 완전히 끌 수 없습니다. 추론 요청에는 온도를 보내지 않습니다.", + messagesThinkingHint: "이전 모델이나 호환 게이트웨이는 고정 예산이 필요할 수 있습니다. 예산은 출력 한도보다 작아야 합니다. 사고 요청에는 온도를 보내지 않습니다.", + llmRequestFormatInvalid: "잘못된 요청 형식입니다. 다시 선택하세요.", + llmThinkingModeInvalid: "잘못된 사고 방식입니다. 다시 선택하세요.", + llmTokenLimitInvalid: "토큰 한도는 양의 정수여야 합니다.", + llmThinkingBudgetInvalid: "사고 예산은 1024 이상이며 고정 예산 모드에서는 출력 한도보다 작아야 합니다.", + llmResponseIncomplete: "응답이 완료되지 않았거나 출력 한도에 도달했습니다. 이미 출력된 텍스트는 유지됩니다.", + llmProtocolHeaderConflict: "Messages 인증 및 버전 헤더는 자동 설정됩니다. 추가 헤더에서 x-api-key와 anthropic-version을 제거하세요.", + llmStreamError: "서버가 스트림 오류를 반환했습니다. 모델과 요청 설정을 확인하세요.", + saveProtocol: "프로토콜 설정 저장", + thinkingModeHint: "선택한 요청 형식과 모델이 지원하는 매개변수로 사고를 켜거나 끄거나 줄입니다. 프롬프트에 제어 지시를 추가하지 않습니다.", bailianVocabularyIdLabel: '핫워드 Vocabulary ID(선택)', bailianVocabularyIdNote: 'DashScope에서 핫워드 사전을 만들었다면 vocab-... ID를 입력하세요. 비워 두면 핫워드를 전송하지 않습니다.', bailianModelRealtimeHint: '실시간 모델 · 말하는 동안 바로 전사.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 88a69f24f..3ce0c4572 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1008,6 +1008,9 @@ export const zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(阶跃星辰)', + customChatCompletions: "自定义 · Chat Completions", + customResponses: "自定义 · Responses", + customMessages: "自定义 · Messages", custom: '自定义', asrVolcengine: '火山引擎 bigasr', asrBailian: '阿里云百炼实时 ASR', @@ -1082,7 +1085,23 @@ export const zhCN = { thinkingModeLabel: '思考', thinkingModeOn: '开启', thinkingModeOff: '关闭', - thinkingModeHint: '关闭时按渠道级官方参数关闭或压低思考;开启时按渠道默认启用思考。不注入 prompt,也不做单模型适配。', + requestFormatLabel: "请求格式", + messagesThinkingLabel: "思考方式", + thinkingAdaptive: "自适应", + thinkingBudget: "固定预算", + maxTokensLabel: "最大输出 tokens", + thinkingBudgetLabel: "思考预算 tokens", + responsesThinkingHint: "部分模型只能降低思考,不能完全关闭。推理请求不发送温度参数。", + messagesThinkingHint: "旧模型或兼容网关可能需要固定预算;思考预算必须小于最大输出。开启思考时不发送温度参数。", + llmRequestFormatInvalid: "请求格式无效,请重新选择。", + llmThinkingModeInvalid: "思考方式无效,请重新选择。", + llmTokenLimitInvalid: "Token 上限必须为正整数。", + llmThinkingBudgetInvalid: "思考预算至少为 1024,且固定预算必须小于最大输出。", + llmResponseIncomplete: "响应未完整结束或达到输出上限;已输出正文会保留。", + llmProtocolHeaderConflict: "Messages 已自动设置鉴权和版本请求头,请移除额外 Headers 中的 x-api-key 和 anthropic-version。", + llmStreamError: "服务端返回流式错误,请检查模型和请求参数。", + saveProtocol: "保存协议设置", + thinkingModeHint: "按所选请求格式和模型支持的参数启用、关闭或降低思考,不向提示词注入控制指令。", bailianVocabularyIdLabel: '热词 Vocabulary ID(可选)', bailianVocabularyIdNote: '如已在百炼创建热词表,可填写 vocab-...;留空则不下发热词。', bailianModelRealtimeHint: '实时模型 · 边说边出字。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 905fd5d24..bf23b2ba3 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1010,6 +1010,9 @@ export const zhTW: typeof zhCN = { codingPlanX: 'CodingPlanX', minimax: 'MiniMax(M3)', stepfun: 'StepFun(階躍星辰)', + customChatCompletions: "自訂 · Chat Completions", + customResponses: "自訂 · Responses", + customMessages: "自訂 · Messages", custom: '自定義', asrVolcengine: '火山引擎 bigasr', asrBailian: '阿里雲百煉即時 ASR', @@ -1084,7 +1087,23 @@ export const zhTW: typeof zhCN = { thinkingModeLabel: '思考', thinkingModeOn: '開啟', thinkingModeOff: '關閉', - thinkingModeHint: '關閉時按渠道級官方參數關閉或降低思考;開啟時按渠道預設啟用思考。不注入 prompt,也不做單模型適配。', + requestFormatLabel: "請求格式", + messagesThinkingLabel: "思考方式", + thinkingAdaptive: "自適應", + thinkingBudget: "固定預算", + maxTokensLabel: "最大輸出 tokens", + thinkingBudgetLabel: "思考預算 tokens", + responsesThinkingHint: "部分模型只能降低思考,無法完全關閉。推理請求不傳送溫度參數。", + messagesThinkingHint: "舊模型或相容閘道可能需要固定預算;思考預算必須小於最大輸出。開啟思考時不傳送溫度參數。", + llmRequestFormatInvalid: "請求格式無效,請重新選擇。", + llmThinkingModeInvalid: "思考方式無效,請重新選擇。", + llmTokenLimitInvalid: "Token 上限必須為正整數。", + llmThinkingBudgetInvalid: "思考預算至少為 1024,且固定預算必須小於最大輸出。", + llmResponseIncomplete: "回應未完整結束或達到輸出上限;已輸出正文會保留。", + llmProtocolHeaderConflict: "Messages 已自動設定驗證與版本標頭,請移除額外 Headers 中的 x-api-key 與 anthropic-version。", + llmStreamError: "伺服器回傳串流錯誤,請檢查模型和請求參數。", + saveProtocol: "儲存協定設定", + thinkingModeHint: "依所選請求格式與模型支援的參數啟用、關閉或降低思考,不在提示詞注入控制指令。", bailianVocabularyIdLabel: '熱詞 Vocabulary ID(可選)', bailianVocabularyIdNote: '如已在百煉建立熱詞表,可填寫 vocab-...;留空則不下發熱詞。', bailianModelRealtimeHint: '即時模型 · 邊說邊出字。', diff --git a/openless-all/app/src/lib/ipc/asr-credentials.ts b/openless-all/app/src/lib/ipc/asr-credentials.ts index d5a6e9695..c039153a4 100644 --- a/openless-all/app/src/lib/ipc/asr-credentials.ts +++ b/openless-all/app/src/lib/ipc/asr-credentials.ts @@ -1,6 +1,7 @@ import type { CredentialsStatus } from "../types" import { invokeOrMock } from "./shared" -import { mockCredentialsStatus } from "./mock-data" +import { mockCredentialsStatus, mockCredentialValues } from "./mock-data" +import { invalidateMockChannelTest } from "./channels" export interface ProviderCheckResult { ok: boolean @@ -19,7 +20,10 @@ export function getCredentials(): Promise { } export function setCredential(account: string, value: string, provider?: string): Promise { - return invokeOrMock("set_credential", { account, value, provider }, () => undefined) + return invokeOrMock("set_credential", { account, value, provider }, () => { + mockCredentialValues.set(`${provider ?? ''}:${account}`, value) + if (provider && account.startsWith('ark.')) invalidateMockChannelTest(provider) + }) } export function setActiveAsrProvider(provider: string): Promise { @@ -50,7 +54,7 @@ export function readCredential(account: string, provider?: string): Promise( "read_credential", { account, provider }, - () => null, + () => mockCredentialValues.get(`${provider ?? ''}:${account}`) ?? null, ) } diff --git a/openless-all/app/src/lib/ipc/channels.ts b/openless-all/app/src/lib/ipc/channels.ts index 14ca64841..6502d2ebf 100644 --- a/openless-all/app/src/lib/ipc/channels.ts +++ b/openless-all/app/src/lib/ipc/channels.ts @@ -7,6 +7,7 @@ // 凭据不走这里:按渠道 id 调 readCredential/setCredential(account, value, id)。 import { invokeOrMock } from "./shared" +import { mockCredentialValues } from "./mock-data" export type ChannelKind = "llm" | "asr" @@ -82,6 +83,11 @@ export function listChannels(kind: ChannelKind): Promise { return invokeOrMock("list_channels", { kind }, () => mockChannels[kind]) } +export function invalidateMockChannelTest(id: string): void { + const channel = mockChannels.llm.find(channel => channel.id === id) + if (channel) channel.lastTest = null +} + /** 返回后端分配的渠道 id。 */ export function createChannel( kind: ChannelKind, @@ -91,7 +97,11 @@ export function createChannel( return invokeOrMock( "create_channel", { kind, providerType, name }, - () => providerType, + () => { + const id = `${providerType}-${Date.now()}-${mockChannels[kind].length}` + mockChannels[kind].push({ id, name, providerType, enabled: true, order: mockChannels[kind].length, lastTest: null }) + return id + }, ) } @@ -104,7 +114,14 @@ export function setChannelProviderType( return invokeOrMock( "set_channel_provider_type", { kind, id, providerType }, - () => undefined, + () => { + const channel = mockChannels[kind].find(channel => channel.id === id) + if (channel && channel.providerType !== providerType) { + channel.providerType = providerType + channel.lastTest = null + if (kind === 'llm') mockCredentialValues.delete(`${id}:ark.request_format`) + } + }, ) } @@ -125,7 +142,10 @@ export function renameChannel( } export function deleteChannel(kind: ChannelKind, id: string): Promise { - return invokeOrMock("delete_channel", { kind, id }, () => undefined) + return invokeOrMock("delete_channel", { kind, id }, () => { + mockChannels[kind] = mockChannels[kind].filter(channel => channel.id !== id) + for (const key of mockCredentialValues.keys()) if (key.startsWith(`${id}:`)) mockCredentialValues.delete(key) + }) } export function setChannelEnabled( @@ -171,6 +191,9 @@ export function recordChannelTest( return invokeOrMock( "record_channel_test", { kind, id, ok, latencyMs, error }, - () => undefined, + () => { + const channel = mockChannels[kind].find(channel => channel.id === id) + if (channel) channel.lastTest = { ok, latencyMs, error, at: Math.floor(Date.now() / 1000) } + }, ) } diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 28d1118cf..86998fb1d 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -1,3 +1,4 @@ +export const mockCredentialValues = new Map() import type { ActivityDay, CorrectionRule, diff --git a/openless-all/app/src/lib/ipc/providers.ts b/openless-all/app/src/lib/ipc/providers.ts index eef2a54b7..2873b3b56 100644 --- a/openless-all/app/src/lib/ipc/providers.ts +++ b/openless-all/app/src/lib/ipc/providers.ts @@ -1,6 +1,7 @@ import { invokeOrMock } from './shared' export type ProviderKind = 'asr' | 'llm' | 'omni' +export type LlmRequestFormat = 'chat_completions' | 'responses' | 'messages' export type AuthRequirement = | 'none' @@ -20,9 +21,20 @@ export interface ProviderDescriptor { authRequirement: AuthRequirement validationProbe: string staticModels: string[] + defaultRequestFormat: LlmRequestFormat | null + supportedRequestFormats: LlmRequestFormat[] } /** Core owns protocol, defaults, and credential requirements. */ export function listProviderDescriptors(kind: ProviderKind): Promise { - return invokeOrMock('list_provider_descriptors', { kind }, () => []) + return invokeOrMock('list_provider_descriptors', { kind }, () => kind === 'llm' ? [ + ['custom', 'customChatCompletions', 'chat_completions'], + ['custom_responses', 'customResponses', 'responses'], + ['custom_messages', 'customMessages', 'messages'], + ].map(([providerType, labelKey, format]) => ({ + kind, providerType, labelKey, defaultEndpoint: null, defaultModel: null, + authRequirement: 'api_key_unless_custom_endpoint', validationProbe: 'llm_text', staticModels: [], + defaultRequestFormat: format as LlmRequestFormat, + supportedRequestFormats: ['chat_completions', 'responses', 'messages'], + })) : []) } diff --git a/openless-all/app/src/pages/settings/ChannelList.test.ts b/openless-all/app/src/pages/settings/ChannelList.test.ts index d832189ef..4e0a699ab 100644 --- a/openless-all/app/src/pages/settings/ChannelList.test.ts +++ b/openless-all/app/src/pages/settings/ChannelList.test.ts @@ -38,6 +38,8 @@ const descriptors: ProviderDescriptor[] = [ : 'api_key', validationProbe: 'unsupported', staticModels: [], + defaultRequestFormat: null, + supportedRequestFormats: [], })); const asrPresets = (os: OS, supportsQwen3Mlx = true, currentProviderId?: string) => diff --git a/openless-all/app/src/pages/settings/ChannelList.tsx b/openless-all/app/src/pages/settings/ChannelList.tsx index c4f8cda88..d97d5ef26 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -51,6 +51,8 @@ interface PresetOption { defaultModel?: string; authRequirement?: ProviderDescriptor['authRequirement']; staticModels?: string[]; + defaultRequestFormat?: ProviderDescriptor['defaultRequestFormat']; + supportedRequestFormats?: ProviderDescriptor['supportedRequestFormats']; } /** 「添加渠道」下拉里的供应商清单。本地引擎与 Codex OAuth 也在其中 —— 它们不是预置的 @@ -69,6 +71,8 @@ export function presetsFor( defaultModel: descriptor.defaultModel ?? undefined, authRequirement: descriptor.authRequirement, staticModels: descriptor.staticModels, + defaultRequestFormat: descriptor.defaultRequestFormat, + supportedRequestFormats: descriptor.supportedRequestFormats, })); if (kind === 'llm') return descriptorPresets; const available = descriptorPresets; @@ -725,6 +729,7 @@ function ChannelModal({ const { t } = useTranslation(); const [name, setName] = useState(channel.name); const [providerType, setProviderType] = useState(channel.providerType); + const [changingProvider, setChangingProvider] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const saveName = async () => { @@ -761,16 +766,17 @@ function ChannelModal({ const changeProvider = async (next: string) => { const previous = providerType; onUserMutation(); - setProviderType(next); + setChangingProvider(true); try { await setChannelProviderType(kind, channel.id, next); await fillProviderDefaults(next); + setProviderType(next); await onChanged(); } catch (error) { console.error('[channels] change provider failed', error); setProviderType(previous); emitSaved('failed', t('common.operationFailed')); - } + } finally { setChangingProvider(false); } }; const remove = async () => { @@ -796,6 +802,7 @@ function ChannelModal({ void changeProvider(next)} options={presets.map(p => ({ value: p.id, @@ -818,7 +825,7 @@ function ChannelModal({ /> {/* key 决定:换供应商时整组凭据字段重挂载,读的是新厂商对应的槽位。 */} - void onChanged()} onUserMutation={onUserMutation} - /> + />} {isLocalEngine && (
diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts new file mode 100644 index 000000000..cc3e8c55d --- /dev/null +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts @@ -0,0 +1,37 @@ +import { protocolValidationError, type ProtocolValues } from './LlmProtocolFields'; +import { listProviderDescriptors } from '../../lib/ipc/providers'; +import { createChannel, deleteChannel, listChannels, recordChannelTest, setChannelProviderType } from '../../lib/ipc/channels'; +import { readCredential, setCredential } from '../../lib/ipc/asr-credentials'; +import { presetsFor } from './ChannelList'; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +const values: ProtocolValues = { 'ark.request_format': '', 'ark.messages_thinking': '', 'ark.max_tokens': '', 'ark.thinking_budget': '' }; +assert(protocolValidationError(values, 'messages') === null, 'Core-compatible defaults must be valid'); +assert(protocolValidationError({ ...values, 'ark.request_format': 'invalid' }, 'messages') === 'llmRequestFormatInvalid', 'Unknown formats must not silently fall back'); +assert(protocolValidationError({ ...values, 'ark.max_tokens': '0' }, 'messages') === 'llmTokenLimitInvalid', 'Zero output tokens must be rejected'); +assert(protocolValidationError({ ...values, 'ark.messages_thinking': 'budget', 'ark.max_tokens': '1024' }, 'messages') === 'llmThinkingBudgetInvalid', 'Fixed thinking must leave room for output'); +assert(protocolValidationError({ ...values, 'ark.messages_thinking': 'budget', 'ark.max_tokens': '4096', 'ark.thinking_budget': '2048' }, 'messages') === null, 'Valid fixed budget must be accepted'); + +const descriptors = await listProviderDescriptors('llm'); +const presets = presetsFor('llm', 'win', true, undefined, descriptors); +assert(presets.length === 3, 'Browser catalog should expose three compatibility presets'); +assert(presets.find(p => p.id === 'custom_messages')?.defaultRequestFormat === 'messages', 'Picker must retain Core protocol defaults'); +for (const preset of presets) assert(preset.supportedRequestFormats?.length === 3, 'All compatibility presets allow switching'); + +const first = await createChannel('llm', 'custom', 'first'); +const second = await createChannel('llm', 'custom', 'second'); +await setCredential('ark.request_format', 'messages', first); +await setCredential('ark.api_key', 'fixture-key', first); +await recordChannelTest('llm', first, true, 1, null); +await setCredential('ark.model_id', 'new-model', first); +assert(await readCredential('ark.request_format', first) === 'messages', 'Changing model must preserve format'); +assert(await readCredential('ark.request_format', second) === null, 'Formats must be scoped by channel'); +assert((await listChannels('llm')).find(c => c.id === first)?.lastTest === null, 'Credential mutation invalidates old validation'); +await setChannelProviderType('llm', first, 'custom_responses'); +assert(await readCredential('ark.request_format', first) === null, 'Changing preset resets the format override'); +assert(await readCredential('ark.api_key', first) === 'fixture-key', 'Changing preset preserves the key'); +await deleteChannel('llm', first); +await deleteChannel('llm', second); diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.tsx b/openless-all/app/src/pages/settings/LlmProtocolFields.tsx new file mode 100644 index 000000000..4397fdaa2 --- /dev/null +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.tsx @@ -0,0 +1,124 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SelectLite } from '../../components/ui/SelectLite'; +import { readCredential, setCredential } from '../../lib/ipc'; +import type { LlmRequestFormat } from '../../lib/ipc/providers'; +import { emitSaved } from '../../lib/savedEvent'; +import { SettingRow, inputStyle } from './shared'; + +const accounts = ['ark.request_format', 'ark.messages_thinking', 'ark.max_tokens', 'ark.thinking_budget'] as const; +type Account = typeof accounts[number]; +export type ProtocolValues = Record; +const emptyValues: ProtocolValues = { + 'ark.request_format': '', 'ark.messages_thinking': '', 'ark.max_tokens': '', 'ark.thinking_budget': '', +}; + +/** 界面即时提示;Core 仍对实际存储和发出的请求做同样的校验。 */ +export function protocolValidationError(values: ProtocolValues, defaultFormat: LlmRequestFormat): string | null { + const format = values['ark.request_format'] || defaultFormat; + if (!['chat_completions', 'responses', 'messages'].includes(format)) return 'llmRequestFormatInvalid'; + const mode = values['ark.messages_thinking'] || 'adaptive'; + if (!['adaptive', 'budget'].includes(mode)) return 'llmThinkingModeInvalid'; + const max = values['ark.max_tokens'] || '8192'; + const budget = values['ark.thinking_budget'] || '1024'; + if (![max, budget].every(value => /^\d+$/.test(value) && Number(value) > 0 && Number(value) <= 4294967295)) return 'llmTokenLimitInvalid'; + if (Number(budget) < 1024 || (format === 'messages' && mode === 'budget' && Number(budget) >= Number(max))) return 'llmThinkingBudgetInvalid'; + return null; +} + +export function LlmProtocolFields({ channelId, defaultFormat, formats, onUserMutation, onBlockedChange, onSaved }: { + channelId: string; + defaultFormat: LlmRequestFormat; + formats: LlmRequestFormat[]; + onUserMutation: () => void; + onBlockedChange: (account: string, blocked: boolean) => void; + onSaved?: () => void; +}) { + const { t } = useTranslation(); + const [values, setValues] = useState(emptyValues); + const [saved, setSaved] = useState(emptyValues); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState<'read' | 'save' | null>(null); + const mounted = useRef(true); + const writing = useRef(false); + const dirty = accounts.some(account => values[account] !== saved[account]); + const validation = protocolValidationError(values, defaultFormat); + + useEffect(() => { + mounted.current = true; + Promise.all(accounts.map(account => readCredential(account, channelId))) + .then(result => { + if (!mounted.current) return; + const next = Object.fromEntries(accounts.map((account, index) => [account, result[index] ?? ''])) as ProtocolValues; + setValues(next); setSaved(next); setLoaded(true); + }) + .catch(() => { if (mounted.current) setError('read'); }); + return () => { mounted.current = false; }; + }, [channelId]); + + useEffect(() => { + onBlockedChange('protocol', !loaded || saving || dirty || error !== null || validation !== null); + }, [loaded, saving, dirty, error, validation, onBlockedChange]); + + const save = async (next: ProtocolValues) => { + if (!loaded || writing.current || protocolValidationError(next, defaultFormat)) return; + writing.current = true; + setSaving(true); setError(null); + try { + for (const account of accounts) { + if (next[account] !== saved[account]) await setCredential(account, next[account], channelId); + } + if (mounted.current) { + setSaved(next); + emitSaved('saved', t('common.saved')); + onSaved?.(); + } + } catch { + if (mounted.current) { setError('save'); emitSaved('failed', t('common.operationFailed')); } + } finally { + writing.current = false; + if (mounted.current) setSaving(false); + } + }; + + const change = (account: Account, value: string, immediate = false) => { + onUserMutation(); + const next = { ...values, [account]: value }; + setValues(next); + if (immediate) void save(next); + }; + const format = values['ark.request_format'] || defaultFormat; + const mode = values['ark.messages_thinking'] || 'adaptive'; + const disabled = !loaded || saving; + const numberField = (account: Account, label: string, placeholder: string) => ( + + change(account, event.target.value)} onBlur={() => { if (dirty) void save(values); }} + style={{ ...inputStyle, width: '100%', maxWidth: 420 }} /> + + ); + return <> + + ({ value, label: ({ chat_completions: 'Chat Completions', responses: 'Responses', messages: 'Messages' })[value] }))} + onChange={value => change('ark.request_format', value, true)} /> + + {format === 'responses' &&

{t('settings.providers.responsesThinkingHint')}

} + {format === 'messages' && <> + + change('ark.messages_thinking', value, true)} /> + + {numberField('ark.max_tokens', t('settings.providers.maxTokensLabel'), '8192')} + {mode === 'budget' && numberField('ark.thinking_budget', t('settings.providers.thinkingBudgetLabel'), '1024')} +

{t('settings.providers.messagesThinkingHint')}

+ } + {(validation || error) &&

+ {validation ? t(`settings.providers.${validation}`) : t(error === 'read' ? 'settings.providers.readFailed' : 'common.operationFailed')} +

} + {dirty && } + ; +} diff --git a/openless-all/app/src/pages/settings/ProvidersSection.test.ts b/openless-all/app/src/pages/settings/ProvidersSection.test.ts index 07fa719ab..2668d8d46 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.test.ts +++ b/openless-all/app/src/pages/settings/ProvidersSection.test.ts @@ -29,6 +29,8 @@ const coreAsr = presetsFor('asr', 'win', true, undefined, [{ authRequirement: 'endpoint_model_optional_api_key', validationProbe: 'asr_silence', staticModels: [], + defaultRequestFormat: null, + supportedRequestFormats: [], }]); if (coreAsr.length !== 1 || coreAsr[0].authRequirement !== 'endpoint_model_optional_api_key') { diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 4a032e5fc..f831555fb 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -1,7 +1,7 @@ // 服务 → AI 提供商:LLM 润色模型 + ASR 语音转写两张卡片。 // 自 Settings.tsx 整体迁出,逻辑零改动;i18n key 全部保持 `settings.providers.*`。 -import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../../components/Icon'; import { detectOS } from '../../components/WindowChrome'; @@ -15,6 +15,7 @@ import { validateProviderCredentials, type ProviderDescriptor, } from '../../lib/ipc'; +import { LlmProtocolFields } from './LlmProtocolFields'; import { emitSaved } from '../../lib/savedEvent'; import { useLayoutStack, useConservativeLayout } from '../../lib/useMobileLayout'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; @@ -90,7 +91,7 @@ export const LLM_LABELS = [ ['codex_oauth', 'codexOAuth'], ['mimo', 'mimo'], ['cometapi', 'cometapi'], ['openrouterFree', 'openrouterFree'], ['alibabaCoding', 'alibabaCoding'], ['codingPlanX', 'codingPlanX'], ['minimax', 'minimax'], ['stepfun', 'stepfun'], - ['custom', 'custom'], + ['custom', 'customChatCompletions'], ['custom_responses', 'customResponses'], ['custom_messages', 'customMessages'], ].map(([id, nameKey]) => ({ id, nameKey })) as readonly { id: string; nameKey: string }[]; // 多模态(Omni)模型预设(issue #902):一个模型同时接收「提示词 + 音频」一步输出 @@ -122,7 +123,7 @@ export function ChannelCredentialFields({ kind: 'llm' | 'asr'; providerType: string; channelId: string; - descriptor?: Partial>; + descriptor?: Partial>; /** 测试连通出结果后通知外层刷新卡片上的延迟/标红。 */ onTested?: () => void; /** 新建草稿发生用户交互时同步通知外层,避免关闭流程误删。 */ @@ -134,6 +135,13 @@ export function ChannelCredentialFields({ const conservative = useConservativeLayout(); const layoutStack = conservative || baseLayoutStack; const [llmModelRevision, setLlmModelRevision] = useState(0); + const [configRevision, setConfigRevision] = useState(0); + const [blockedFields, setBlockedFields] = useState>({}); + const trackField = useCallback((account: string, blocked: boolean) => { + setBlockedFields(previous => previous[account] === blocked ? previous : { ...previous, [account]: blocked }); + }, []); + const onLlmMutation = () => { onUserMutation?.(); setConfigRevision(value => value + 1); }; + const [asrModelRevision, setAsrModelRevision] = useState(0); const unifiedBailian = providerType === 'bailian'; const [bailianModel, setBailianModel] = useState(''); @@ -156,10 +164,12 @@ export function ChannelCredentialFields({ const onLlmThinkingToggle = (enabled: boolean) => { if (!prefs) return; + onLlmMutation(); + trackField('thinking', true); void updatePrefs(current => ({ ...current, llmThinkingEnabled: enabled })).catch(error => { console.error('[settings] failed to update LLM thinking mode', error); emitSaved('failed', t('common.operationFailed')); - }); + }).finally(() => trackField('thinking', false)); }; // Provider policy 必须 fail-closed:Core descriptor 尚未返回或加载失败时, @@ -174,6 +184,11 @@ export function ChannelCredentialFields({ const codexOAuthSelected = descriptor?.authRequirement === 'o_auth'; return ( <> + {!!descriptor.supportedRequestFormats?.length && descriptor.defaultRequestFormat && ( + + )} {codexOAuthSelected ? (
{t('settings.providers.codexOAuthNotice')} @@ -181,29 +196,29 @@ export function ChannelCredentialFields({ ) : ( <> + account="ark.api_key" provider={channelId} mono mask onUserMutation={onLlmMutation} onBlockedChange={trackField} /> - {providerType === 'custom' && ( + defaultValue={defaultEndpoint || undefined} onUserMutation={onLlmMutation} onBlockedChange={trackField} /> + {['custom', 'custom_responses', 'custom_messages'].includes(providerType) && ( <> )} @@ -213,7 +228,7 @@ export function ChannelCredentialFields({ account="ark.model_id" provider={channelId} placeholder={defaultModel || 'model-name'} mono defaultValue={defaultModel || undefined} - onUserMutation={onUserMutation} + onUserMutation={onLlmMutation} onBlockedChange={trackField} trailing={( )} /> - setLlmModelRevision(v => v + 1)} onTested={onTested} onUserMutation={onUserMutation} /> @@ -571,17 +586,20 @@ function BailianProtocolHint({ currentModel }: { currentModel: string }) { type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'; -function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested, onUserMutation, showFetchModels = true }: { kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; onTested?: () => void; onUserMutation?: () => void; showFetchModels?: boolean }) { +function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested, onUserMutation, showFetchModels = true, disabled = false }: { disabled?: boolean; kind: 'llm' | 'asr' | 'omni'; modelAccount: string; provider?: string; onModelSelected: () => void; onTested?: () => void; onUserMutation?: () => void; showFetchModels?: boolean }) { const { t } = useTranslation(); const baseLayoutStack = useLayoutStack(); const conservative = useConservativeLayout(); const layoutStack = conservative || baseLayoutStack; + const mounted = useRef(true); + useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState(''); const [status, setStatus] = useState('idle'); const [message, setMessage] = useState(''); const setResult = (next: ProviderToolStatus, nextMessage: string) => { + if (!mounted.current) return; setStatus(next); setMessage(nextMessage); }; @@ -590,7 +608,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested // 测试本身已经在按钮旁给出结论,记录不上只是卡片少一行历史。 const persistTest = async (ok: boolean, latencyMs: number | null, message: string | null) => { // Omni 不走渠道化(独立命名空间),没有可落测试结果的渠道卡片。 - if (!provider || kind === 'omni') return; + if (!mounted.current || !provider || kind === 'omni') return; try { await recordChannelTest(kind, provider, ok, latencyMs, message); onTested?.(); @@ -600,6 +618,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested }; const validate = async () => { + if (disabled) return; onUserMutation?.(); setModels([]); setSelectedModel(''); @@ -631,6 +650,7 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested }; const loadModels = async () => { + if (disabled) return; onUserMutation?.(); setResult('loading', t('settings.providers.loadingModels')); try { @@ -665,15 +685,15 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested
- + {showFetchModels && ( - + )} {showFetchModels && models.length > 0 && ( ({ value: model, label: model }))} placeholder={t('settings.providers.selectModel')} ariaLabel={t('settings.providers.selectModel')} @@ -693,6 +713,9 @@ function ProviderTools({ kind, modelAccount, provider, onModelSelected, onTested function providerErrorMessage(error: unknown, t: ReturnType['t']): string { const message = error instanceof Error ? error.message : String(error); + for (const code of ['llmRequestFormatInvalid', 'llmThinkingModeInvalid', 'llmTokenLimitInvalid', 'llmThinkingBudgetInvalid', 'llmResponseIncomplete', 'llmStreamError', 'llmProtocolHeaderConflict']) { + if (message.includes(code)) return t(`settings.providers.${code}`); + } if (message.startsWith('providerHttpStatus:')) { return t('settings.providers.providerHttpStatus', { status: message.split(':')[1] || '?' }); } @@ -723,6 +746,7 @@ function providerErrorMessage(error: unknown, t: ReturnType void; label: string; account: string; provider?: string; @@ -738,7 +762,7 @@ interface CredentialFieldProps { options?: SelectOption[]; } -function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, onUserMutation, options }: CredentialFieldProps) { +function CredentialField({ label, account, provider, placeholder, mono, mask, defaultValue, trailing, onValueChange, onUserMutation, options, onBlockedChange }: CredentialFieldProps) { const { t } = useTranslation(); const baseLayoutStack = useLayoutStack(); const conservative = useConservativeLayout(); @@ -750,9 +774,16 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de const [status, setStatus] = useState('idle'); // 预设下拉的「自定义模型…」逃生口:选中后切回输入框,保证后端支持的任意模型名都能手输。 const [customModelMode, setCustomModelMode] = useState(false); + useEffect(() => { + onBlockedChange?.(account, !loaded || dirty || status === 'saving' || status === 'readError' || status === 'saveError'); + }, [account, loaded, dirty, status, onBlockedChange]); + const debounceRef = useRef(null); const statusRef = useRef(null); const mountedRef = useRef(true); + const editRevision = useRef(0); + const saveQueue = useRef>(Promise.resolve()); + const markMutation = () => { editRevision.current += 1; onUserMutation?.(); }; useEffect(() => { let cancelled = false; @@ -816,22 +847,26 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de const save = async (v: string, force = false) => { if (!loaded || (!dirty && !force)) return; if (!mountedRef.current) return; + const revision = editRevision.current; setStatus('saving'); emitSaved('saving', t('common.saving')); try { - await setCredential(account, v, provider); - if (!mountedRef.current) return; + // 按编辑顺序写入,旧请求完成不能把新值标记为已保存。 + const write = saveQueue.current.catch(() => undefined).then(() => setCredential(account, v, provider)); + saveQueue.current = write; + await write; + if (!mountedRef.current || revision !== editRevision.current) return; setDirty(false); showTemporaryStatus('saved'); } catch (error) { - if (!mountedRef.current) return; + if (!mountedRef.current || revision !== editRevision.current) return; console.error('[settings] failed to save credential', account, error); showTemporaryStatus('saveError'); } }; const handleChange = (e: React.ChangeEvent) => { - onUserMutation?.(); + markMutation(); const v = e.target.value; setValue(v); onValueChange?.(v); @@ -852,7 +887,7 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de const fillDefault = async () => { if (!loaded || !defaultValue) return; - onUserMutation?.(); + markMutation(); setValue(defaultValue); onValueChange?.(defaultValue); setDirty(true); @@ -891,7 +926,7 @@ function CredentialField({ label, account, provider, placeholder, mono, mask, de setCustomModelMode(true); return; } - onUserMutation?.(); + markMutation(); setValue(v); onValueChange?.(v); if (!loaded) return; From 9d484aa2664452c8c4adf3dffce648e84f5c30c9 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Tue, 8 Sep 2026 18:13:20 +0800 Subject: [PATCH 2/2] fix(llm): harden channel protocol handling --- .../app/crates/openless-core/src/api.rs | 40 +++++++++++ .../crates/openless-core/src/credentials.rs | 10 +++ .../crates/openless-core/src/llm_protocol.rs | 67 +++++++++++++------ .../app/crates/openless-core/src/polish.rs | 2 +- .../openless-core/src/provider_service.rs | 5 +- .../app/src-tauri/src/commands/settings.rs | 22 +++++- openless-all/app/src/lib/ipc/channels.ts | 17 ++++- openless-all/app/src/lib/ipc/settings.ts | 3 + .../pages/settings/LlmProtocolFields.test.ts | 19 +++++- .../src/pages/settings/ProvidersSection.tsx | 11 +-- 10 files changed, 164 insertions(+), 32 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 5841008d8..953d191a0 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -3644,6 +3644,12 @@ impl OpenLessBackend { .map(|_| ()) } + pub async fn invalidate_channel_tests(&self, kind: ChannelKind) -> Result<(), BackendError> { + self.apply_channel_mutation(ChannelMutation::InvalidateTests { kind }) + .await + .map(|_| ()) + } + pub async fn active_provider(&self, slot: ProviderSlot) -> Result { self.deps.credential_store.active_provider(slot).await } @@ -8323,6 +8329,40 @@ mod tests { )); } + #[tokio::test] + async fn invalidating_llm_tests_preserves_asr_test_results() { + let (backend, _) = backend(); + for (kind, provider, name) in [ + (ChannelKind::Llm, "custom", "first"), + (ChannelKind::Llm, "custom_messages", "second"), + (ChannelKind::Asr, "openai-compatible", "asr"), + ] { + let id = backend + .create_channel(kind, provider.into(), name.into()) + .await + .unwrap(); + backend + .record_channel_test(kind, id, true, Some(1), None) + .await + .unwrap(); + } + + backend + .invalidate_channel_tests(ChannelKind::Llm) + .await + .unwrap(); + + assert!(backend + .list_channels(ChannelKind::Llm) + .await + .unwrap() + .iter() + .all(|channel| channel.last_test.is_none())); + assert!(backend.list_channels(ChannelKind::Asr).await.unwrap()[0] + .last_test + .is_some()); + } + #[tokio::test] async fn llm_protocol_mutations_reset_only_the_format_and_invalidate_tests() { use crate::credentials::{CredentialNamespace, InMemoryCredentialStore, SecretValue}; diff --git a/openless-all/app/crates/openless-core/src/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index 67189a4d5..db6516786 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -133,6 +133,9 @@ pub enum ChannelMutation { kind: ChannelKind, id: String, }, + InvalidateTests { + kind: ChannelKind, + }, /// Commit a prepared local runtime and its channel in one metadata revision. ActivateLocalAsr { id: Option, @@ -524,6 +527,7 @@ impl CredentialMetadata { ChannelMutation::Create { kind, .. } | ChannelMutation::SetProviderType { kind, .. } | ChannelMutation::InvalidateTest { kind, .. } + | ChannelMutation::InvalidateTests { kind } | ChannelMutation::DeleteIfBlank { kind, .. } | ChannelMutation::Rename { kind, .. } | ChannelMutation::Delete { kind, .. } @@ -639,6 +643,12 @@ impl CredentialMetadata { find_channel_mut(&mut self.channels, kind, &id)?.last_test = None; (kind, ChannelMutationResult::Applied) } + ChannelMutation::InvalidateTests { kind } => { + for channel in self.channels.entry(kind).or_default() { + channel.last_test = None; + } + (kind, ChannelMutationResult::Applied) + } ChannelMutation::DeleteIfBlank { kind, id } => { let channels = self.channels.entry(kind).or_default(); let before = channels.len(); 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 3787abda8..ee0e01e7a 100644 --- a/openless-all/app/crates/openless-core/src/llm_protocol.rs +++ b/openless-all/app/crates/openless-core/src/llm_protocol.rs @@ -259,13 +259,14 @@ pub(crate) fn request_body( if !system.is_empty() { body["system"] = json!(system.join("\n\n")); } - body["thinking"] = if !config.thinking_enabled { - json!({"type": "disabled"}) - } else if config.protocol.messages_thinking == MessagesThinking::Adaptive { - json!({"type": "adaptive"}) - } else { - json!({"type": "enabled", "budget_tokens": config.protocol.thinking_budget}) - }; + if config.thinking_enabled { + body["thinking"] = + if config.protocol.messages_thinking == MessagesThinking::Adaptive { + json!({"type": "adaptive"}) + } else { + json!({"type": "enabled", "budget_tokens": config.protocol.thinking_budget}) + }; + } body } }; @@ -283,13 +284,14 @@ fn response_error(message: &str) -> LLMError { LLMError::ParseError(message.to_string()) } -fn check_stop_reason(value: &Value) -> Result<(), LLMError> { +fn check_stop_reason(value: &Value) -> Result { if let Some(reason) = value.as_str() { if !matches!(reason, "end_turn" | "stop_sequence") { return Err(response_error("llmResponseIncomplete")); } + return Ok(true); } - Ok(()) + Ok(false) } pub(crate) fn extract_text(format: LlmRequestFormat, text: &str) -> Result { @@ -316,8 +318,7 @@ pub(crate) fn extract_text(format: LlmRequestFormat, text: &str) -> Result { - check_stop_reason(&value["stop_reason"])?; - if value["stop_reason"].is_null() { + if !check_stop_reason(&value["stop_reason"])? { return Err(response_error("llmResponseIncomplete")); } append_blocks(&mut output, &value["content"], "text"); @@ -353,6 +354,7 @@ pub(crate) struct TextEventStream { format: LlmRequestFormat, buffer: String, pending: Vec, + messages_complete: bool, pub done: bool, } @@ -362,6 +364,7 @@ impl TextEventStream { format, buffer: String::new(), pending: Vec::new(), + messages_complete: false, done: false, } } @@ -441,10 +444,15 @@ impl TextEventStream { value["delta"]["text"].as_str() } "message_delta" => { - check_stop_reason(&value["delta"]["stop_reason"])?; + if check_stop_reason(&value["delta"]["stop_reason"])? { + self.messages_complete = true; + } None } "message_stop" => { + if !self.messages_complete { + return Err(response_error("llmResponseIncomplete")); + } self.done = true; None } @@ -640,16 +648,18 @@ mod tests { assert_eq!(body["system"], "rules"); assert_eq!(body["messages"], json!(&messages[1..])); assert_eq!(body["max_tokens"], 8192); - assert_eq!( - body["thinking"]["type"], - if !enabled { - "disabled" - } else if mode == MessagesThinking::Adaptive { - "adaptive" - } else { - "enabled" - } - ); + if enabled { + assert_eq!( + body["thinking"]["type"], + if mode == MessagesThinking::Adaptive { + "adaptive" + } else { + "enabled" + } + ); + } else { + assert!(body.get("thinking").is_none()); + } assert_eq!(body.get("temperature").is_none(), enabled); if enabled && mode == MessagesThinking::Budget { assert_eq!(body["thinking"]["budget_tokens"], 1024); @@ -745,5 +755,18 @@ mod tests { .expect("must reject unsuccessful streams"); assert!(!error.to_string().contains("secret")); } + + let mut stream = TextEventStream::new(LlmRequestFormat::Messages); + stream + .push( + b"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n", + ) + .unwrap(); + assert!(matches!(stream.next().unwrap(), Some(StreamEvent::Text(_)))); + let error = match stream.next() { + Err(error) => error, + Ok(_) => panic!("message_stop without stop_reason must fail"), + }; + assert!(error.to_string().contains("llmResponseIncomplete")); } } diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index b4ea5753a..694fefb74 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -2178,7 +2178,7 @@ mod tests { let response = match format { LlmRequestFormat::ChatCompletions => "data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n", LlmRequestFormat::Responses => "data: {\"type\":\"response.output_text.delta\",\"delta\":\"你好\"}\r\n\r\ndata: {\"type\":\"response.completed\"}\r\n\r\n", - LlmRequestFormat::Messages => "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你好\"}}\r\n\r\ndata: {\"type\":\"message_stop\"}\r\n\r\n", + LlmRequestFormat::Messages => "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你好\"}}\r\n\r\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\r\n\r\ndata: {\"type\":\"message_stop\"}\r\n\r\n", }; let split = response.find('好').unwrap() + 1; write_chunked_sse_response( 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 d1d54b073..1256553f0 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -893,7 +893,7 @@ mod tests { use crate::llm_protocol::*; for (format, preset, sse, path) in [ ("responses", "custom_responses", "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\ndata: {\"type\":\"response.completed\"}\n\n", "/v1/responses"), - ("messages", "custom_messages", "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n", "/v1/messages"), + ("messages", "custom_messages", "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n", "/v1/messages"), ] { for enabled in [false, true] { let (endpoint, request) = spawn_http_response("200 OK", "text/event-stream", sse); @@ -909,7 +909,8 @@ mod tests { assert!(request.starts_with(&format!("POST {path} "))); let body: serde_json::Value = serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); if format == "responses" { assert_eq!(body["reasoning"]["effort"], if enabled { "medium" } else { "low" }); } - else { assert_eq!(body["thinking"]["type"], if enabled { "adaptive" } else { "disabled" }); } + else if enabled { assert_eq!(body["thinking"]["type"], "adaptive"); } + else { assert!(body.get("thinking").is_none()); } } let (endpoint, request) = spawn_http_response("200 OK", "application/json", r#"{"data":[{"id":"model"}]}"#); let credentials = Arc::new(InMemoryCredentialStore::default()); diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 2e16703ab..5fa1aa4a0 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -179,6 +179,21 @@ pub(crate) fn persist_strict_settings( .map_err(|error| error.to_string()) } +async fn invalidate_llm_tests_if_thinking_changed( + coord: &Coordinator, + previous: &UserPreferences, + next: &UserPreferences, +) -> Result<(), String> { + if previous.llm_thinking_enabled != next.llm_thinking_enabled { + coord + .backend() + .invalidate_channel_tests(openless_core::ChannelKind::Llm) + .await + .map_err(|error| error.to_string())?; + } + Ok(()) +} + #[cfg(not(mobile))] #[tauri::command] pub async fn set_settings( @@ -195,6 +210,7 @@ pub async fn set_settings( .map_err(|e| e.to_string())?; sync_style_pack_preferences(&mut prefs, &packs); prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized(); + invalidate_llm_tests_if_thinking_changed(&coord, &remote_prev, &prefs).await?; // 广播给所有 webview。issue #205:QaPanel 跑在独立 webview, // 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时 // 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。 @@ -249,7 +265,10 @@ pub async fn set_settings( #[cfg(mobile)] #[tauri::command] -pub fn set_settings(coord: CoordinatorState<'_>, mut prefs: UserPreferences) -> Result<(), String> { +pub async fn set_settings( + coord: CoordinatorState<'_>, + mut prefs: UserPreferences, +) -> Result<(), String> { let previous = coord.backend().get_preferences(); let packs = coord .backend() @@ -257,6 +276,7 @@ pub fn set_settings(coord: CoordinatorState<'_>, mut prefs: UserPreferences) -> .map_err(|e| e.to_string())?; sync_style_pack_preferences(&mut prefs, &packs); prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized(); + invalidate_llm_tests_if_thinking_changed(&coord, &previous, &prefs).await?; persist_settings(&*coord, prefs)?; let prefs = coord.backend().get_preferences(); // 保存即同步胶囊样式原子(Android 通知胶囊 payload 同源,见 emit_capsule)。 diff --git a/openless-all/app/src/lib/ipc/channels.ts b/openless-all/app/src/lib/ipc/channels.ts index 6502d2ebf..349a7e49b 100644 --- a/openless-all/app/src/lib/ipc/channels.ts +++ b/openless-all/app/src/lib/ipc/channels.ts @@ -88,6 +88,10 @@ export function invalidateMockChannelTest(id: string): void { if (channel) channel.lastTest = null } +export function invalidateMockChannelTests(kind: ChannelKind): void { + for (const channel of mockChannels[kind]) channel.lastTest = null +} + /** 返回后端分配的渠道 id。 */ export function createChannel( kind: ChannelKind, @@ -130,7 +134,18 @@ export function deleteChannelIfBlank( kind: ChannelKind, id: string, ): Promise { - return invokeOrMock("delete_channel_if_blank", { kind, id }, () => true) + return invokeOrMock("delete_channel_if_blank", { kind, id }, () => { + const channel = mockChannels[kind].find(channel => channel.id === id) + const prefix = `${id}:` + const hasCredentials = [...mockCredentialValues] + .some(([key, value]) => key.startsWith(prefix) && value.length > 0) + if (!channel || channel.name.trim() || hasCredentials) return false + mockChannels[kind] = mockChannels[kind].filter(channel => channel.id !== id) + for (const key of mockCredentialValues.keys()) { + if (key.startsWith(prefix)) mockCredentialValues.delete(key) + } + return true + }) } export function renameChannel( diff --git a/openless-all/app/src/lib/ipc/settings.ts b/openless-all/app/src/lib/ipc/settings.ts index fc9b6e7b4..ba90137ea 100644 --- a/openless-all/app/src/lib/ipc/settings.ts +++ b/openless-all/app/src/lib/ipc/settings.ts @@ -7,6 +7,7 @@ import { type StartupSnapshot, } from "./shared" import { mockSettings, mockDefaultStyleSystemPrompts, mockSetSettings } from "./mock-data" +import { invalidateMockChannelTests } from "./channels" export { BACKEND_CONTRACT_VERSION } export type { StartupSnapshot } @@ -27,7 +28,9 @@ export function getDefaultStyleSystemPrompts(): Promise { export function setSettings(prefs: UserPreferences): Promise { return invokeOrMock("set_settings", { prefs }, () => { + const thinkingChanged = mockSettings.llmThinkingEnabled !== prefs.llmThinkingEnabled mockSetSettings(prefs) + if (thinkingChanged) invalidateMockChannelTests("llm") return undefined }) } diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts index cc3e8c55d..9216eafd7 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts @@ -1,7 +1,8 @@ import { protocolValidationError, type ProtocolValues } from './LlmProtocolFields'; import { listProviderDescriptors } from '../../lib/ipc/providers'; -import { createChannel, deleteChannel, listChannels, recordChannelTest, setChannelProviderType } from '../../lib/ipc/channels'; +import { createChannel, deleteChannel, deleteChannelIfBlank, listChannels, recordChannelTest, setChannelProviderType } from '../../lib/ipc/channels'; import { readCredential, setCredential } from '../../lib/ipc/asr-credentials'; +import { getSettings, setSettings } from '../../lib/ipc/settings'; import { presetsFor } from './ChannelList'; function assert(condition: unknown, message: string): asserts condition { @@ -33,5 +34,21 @@ assert((await listChannels('llm')).find(c => c.id === first)?.lastTest === null, await setChannelProviderType('llm', first, 'custom_responses'); assert(await readCredential('ark.request_format', first) === null, 'Changing preset resets the format override'); assert(await readCredential('ark.api_key', first) === 'fixture-key', 'Changing preset preserves the key'); +await recordChannelTest('llm', first, true, 1, null); +const settings = await getSettings(); +const asrTestAt = (await listChannels('asr'))[0].lastTest?.at; +await setSettings({ ...settings, llmThinkingEnabled: !settings.llmThinkingEnabled }); +assert((await listChannels('llm')).find(c => c.id === first)?.lastTest === null, 'Changing thinking invalidates LLM tests'); +assert((await listChannels('asr'))[0].lastTest?.at === asrTestAt, 'Changing thinking preserves ASR tests'); +await setSettings(settings); + +const blank = await createChannel('llm', 'custom', ''); +assert(await deleteChannelIfBlank('llm', blank), 'An empty browser draft should be recycled'); +assert(!(await listChannels('llm')).some(channel => channel.id === blank), 'A recycled browser draft must leave no channel'); +const configured = await createChannel('llm', 'custom', ''); +await setCredential('ark.api_key', 'keep-me', configured); +assert(!(await deleteChannelIfBlank('llm', configured)), 'A configured browser draft must be preserved'); +assert((await listChannels('llm')).some(channel => channel.id === configured), 'A configured browser draft must remain visible'); +await deleteChannel('llm', configured); await deleteChannel('llm', first); await deleteChannel('llm', second); diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index f831555fb..bbc08e4cb 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -166,10 +166,13 @@ export function ChannelCredentialFields({ if (!prefs) return; onLlmMutation(); trackField('thinking', true); - void updatePrefs(current => ({ ...current, llmThinkingEnabled: enabled })).catch(error => { - console.error('[settings] failed to update LLM thinking mode', error); - emitSaved('failed', t('common.operationFailed')); - }).finally(() => trackField('thinking', false)); + void updatePrefs(current => ({ ...current, llmThinkingEnabled: enabled })) + .then(() => onTested?.()) + .catch(error => { + console.error('[settings] failed to update LLM thinking mode', error); + emitSaved('failed', t('common.operationFailed')); + }) + .finally(() => trackField('thinking', false)); }; // Provider policy 必须 fail-closed:Core descriptor 尚未返回或加载失败时,