diff --git a/README.md b/README.md index c2b13cb1..3cba9023 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ Every item below is one more layer sedimented into a default — a capability yo - **Toggle and push-to-talk** recording modes, plus a **MediaPlayPause trigger** so wired-earbud inline controls can start and stop recording. `Esc` cancels at any phase, including polish and insertion. - **Cloud ASR**: Volcengine streaming ASR (bigasr), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, ElevenLabs Scribe, OpenAI-compatible batch transcription (OpenAI Whisper / Groq / SiliconFlow SenseVoice / OpenRouter / ZenMux), and Apple Speech (macOS). - **Local ASR**: bundled Qwen3-ASR (0.6B / 1.7B) via vendored `Open-Less/qwen-asr` (macOS); Windows Foundry Local Whisper and sherpa-onnx (experimental) variants. -- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, and StepFun — plus any OpenAI-compatible endpoint you bring. +- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, CometAPI, OpenRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, StepFun, and OpenCode Zen — plus any OpenAI-compatible endpoint you bring. - **Four output modes**: raw, light polish, structured (**AI-prompt mode**), and formal. Plus a **translation hotkey** that converts speech directly into the configured target language ([#43](../../issues/43)). - **Selection-ask QA panel** — a separate hotkey opens a floating panel that runs voice Q&A against the highlighted text in any app ([#118](../../issues/118)). - **Main window**: Overview / History / Vocab / Style / Marketplace / Settings. Persistent tray icon, plus a mini status capsule that floats on screen and follows the display you are typing on (multi-monitor). diff --git a/README.zh.md b/README.zh.md index d5062b15..58d7f865 100644 --- a/README.zh.md +++ b/README.zh.md @@ -205,7 +205,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 - **切换式与按住说话(push-to-talk)** 两种录音模式,外加 **MediaPlayPause 触发**,让有线耳机的线控也能开始 / 停止录音。`Esc` 可在任意阶段取消,包括润色与插入。 - **云端 ASR**:Volcengine 流式 ASR(bigasr)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、ElevenLabs Scribe、OpenAI 兼容批量转写(OpenAI Whisper / Groq / 硅基流动 SenseVoice / OpenRouter / ZenMux),以及 Apple Speech(macOS)。 - **本地 ASR**:通过 vendored 的 `Open-Less/qwen-asr` 内置 Qwen3-ASR(0.6B / 1.7B)(macOS);Windows 上的 Foundry Local Whisper 与 sherpa-onnx(实验性)变体。 -- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun,以及你自带的任意 OpenAI 兼容端点。 +- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、CometAPI、OpenRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。 - **四种输出模式**:原文、轻度润色、结构化(**AI 提示词模式**)、正式。另有一个**翻译快捷键**,将语音直接转换为所配置的目标语言([#43](../../issues/43))。 - **选区问答面板**——一个独立快捷键打开浮动面板,针对任意应用中被高亮选中的文本进行语音问答([#118](../../issues/118))。 - **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。 diff --git a/docs/architecture.md b/docs/architecture.md index 5e32e5b3..692c1ac9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,7 +65,7 @@ Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装 `src-tauri/tauri.conf.json` 声明 `main`、`capsule` 两个窗口。`src/main.tsx` 读取 `?window=`,`src/App.tsx` 按类型加载胶囊、`qa`、`selection-polish-preview`、`selection-voice-intent`、`less-computer` 和 `less-computer-glow`;未指定类型时进入主界面。各 WebView 共用前端入口,重页面按需加载;移动端再依据平台能力选择布局。Linux 单实例由 `linux-egui/src/single_instance.rs` 守护并转发启动意图。 -主窗口默认逻辑尺寸为 1300×835,允许用户调整;macOS 原生窗口按钮采用左右/顶部 16px 的对齐基准,前端保留 44px 拖动区。桌面侧栏宽 226px,主内容从版本行下方开始,设置面板单独限制高度并在内部滚动。 +主窗口默认逻辑尺寸为 1300×835,允许用户调整;macOS 原生窗口按钮左侧和顶部均留出 16px,前端保留 44px 拖动区。桌面侧栏宽 226px,主内容从版本行下方开始,设置面板单独限制高度并在内部滚动。 Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺寸与点击范围在保存偏好时同步。胶囊按显示器工作区底部定位,避开未自动隐藏的 Dock/任务栏;可见期间重新检查工作区。带正文的浮窗使用不透明底色,聊天面板另叠加细噪点纹理,圆角外部仍保留透明区域。 diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index dc6e7385..169cc8d7 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -3486,7 +3486,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 } @@ -3533,13 +3566,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( @@ -3621,6 +3692,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 } @@ -8352,6 +8429,142 @@ 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}; + 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 1fa42479..9c037173 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -76,7 +76,10 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[ "codingPlanX", "minimax", "stepfun", + "opencode", "custom", + "custom_responses", + "custom_messages", ]; pub const SHARED_OMNI_PROVIDER_TYPES: &[&str] = &["openai", "gemini", "dashscope-omni", "custom"]; @@ -1122,12 +1125,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, @@ -1153,10 +1153,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, @@ -1970,14 +1971,22 @@ mod tests { } } - async fn assert_translation_streams_before_completion( + async fn assert_translation_streams_for_protocol( gemini: bool, + format: crate::llm_protocol::LlmRequestFormat, + preset: &str, cancel: bool, fallback: bool, ) { + use crate::llm_protocol::LlmRequestFormat; use tokio::io::{AsyncReadExt, AsyncWriteExt}; - async fn check_request(socket: &mut tokio::net::TcpStream, gemini: bool) { + async fn check_request( + socket: &mut tokio::net::TcpStream, + gemini: bool, + format: LlmRequestFormat, + prefix: &str, + ) { let mut request = Vec::new(); let mut buf = [0_u8; 4096]; loop { @@ -2001,6 +2010,25 @@ mod tests { assert!(headers.contains(":streamGenerateContent?alt=sse")); } else { assert_eq!(body["stream"], true); + let suffix = match format { + LlmRequestFormat::ChatCompletions => "chat/completions", + LlmRequestFormat::Responses => "responses", + LlmRequestFormat::Messages => "messages", + }; + assert!( + headers.starts_with(&format!("POST {prefix}/{suffix}?tenant=1 ")) + ); + let lower = headers.to_ascii_lowercase(); + if format == LlmRequestFormat::Messages { + assert!(lower.contains("x-api-key: fixture-key")); + assert!(lower.contains("anthropic-version: 2023-06-01")); + assert!(!lower.contains("authorization:")); + assert!(body["system"] + .as_str() + .is_some_and(|text| !text.is_empty())); + } else { + assert!(lower.contains("authorization: bearer fixture-key")); + } } break; } @@ -2009,32 +2037,88 @@ mod tests { } let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let prefix = if preset == "opencode" { + "/zen/go/v1" + } else { + "/gateway/v1" + }; + let endpoint = if gemini { + format!("http://{}", listener.local_addr().unwrap()) + } else { + format!( + "http://{}{prefix}/chat/completions?tenant=1", + listener.local_addr().unwrap() + ) + }; let (source_sent, source_ready) = tokio::sync::oneshot::channel(); let (advance, next) = tokio::sync::oneshot::channel(); let (finish, finishing) = tokio::sync::oneshot::channel(); let server = tokio::spawn(async move { let (mut socket, _) = listener.accept().await.unwrap(); - check_request(&mut socket, gemini).await; + check_request(&mut socket, gemini, format, prefix).await; socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").await.unwrap(); let event = |text: &str| { let value = if gemini { serde_json::json!({"candidates":[{"content":{"parts":[{"text":text}]}}]}) } else { - serde_json::json!({"choices":[{"delta":{"content":text}}]}) + match format { + LlmRequestFormat::ChatCompletions => { + serde_json::json!({"choices":[{"delta":{"content":text}}]}) + } + LlmRequestFormat::Responses => { + serde_json::json!({"type":"response.output_text.delta","delta":text}) + } + LlmRequestFormat::Messages => { + serde_json::json!({"type":"content_block_delta","delta":{"type":"text_delta","text":text}}) + } + } }; let data = format!("data: {value}\n\n"); format!("{:X}\r\n{data}\r\n", data.len()) }; + let end = if gemini { + "" + } else { + match format { + LlmRequestFormat::ChatCompletions => "data: [DONE]\n\n", + LlmRequestFormat::Responses => "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n", + LlmRequestFormat::Messages => "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n", + } + }; + let terminal = if end.is_empty() { + String::new() + } else { + format!("{:X}\r\n{end}\r\n", end.len()) + }; + if !gemini { + let hidden = "[[OPENLESS_TRANSLATION]]do not expose reasoning"; + let value = match format { + LlmRequestFormat::ChatCompletions => { + serde_json::json!({"choices":[{"delta":{"reasoning_content":hidden}}]}) + } + LlmRequestFormat::Responses => { + serde_json::json!({"type":"response.reasoning_summary_text.delta","delta":hidden}) + } + LlmRequestFormat::Messages => { + serde_json::json!({"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":hidden}}) + } + }; + let frame = format!("data: {value}\n\n"); + socket + .write_all(format!("{:X}\r\n{frame}\r\n", frame.len()).as_bytes()) + .await + .unwrap(); + } if fallback { socket .write_all(event("malformed combined output").as_bytes()) .await .unwrap(); + socket.write_all(terminal.as_bytes()).await.unwrap(); socket.write_all(b"0\r\n\r\n").await.unwrap(); socket.shutdown().await.unwrap(); (socket, _) = listener.accept().await.unwrap(); - check_request(&mut socket, gemini).await; + check_request(&mut socket, gemini, format, prefix).await; socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").await.unwrap(); } else { socket @@ -2059,6 +2143,7 @@ mod tests { return; } let _ = socket.write_all(event(" 🌍\n").as_bytes()).await; + let _ = socket.write_all(terminal.as_bytes()).await; let _ = socket.write_all(b"0\r\n\r\n").await; }); @@ -2079,16 +2164,24 @@ mod tests { "fixture-key", ) .await; + if !gemini { + let request_format = match format { + LlmRequestFormat::ChatCompletions => "chat_completions", + LlmRequestFormat::Responses => "responses", + LlmRequestFormat::Messages => "messages", + }; + write_channel_secret( + credentials.as_ref(), + CredentialNamespace::Llm, + "translation", + crate::llm_protocol::REQUEST_FORMAT_ACCOUNT, + request_format, + ) + .await; + } let polisher = Arc::new(SharedCloudTextPolisher::new(credentials)); let mut context = DictationContext { - llm: ProviderInvocation::new( - "translation", - if gemini { - "gemini" - } else { - "openai-compatible" - }, - ), + llm: ProviderInvocation::new("translation", if gemini { "gemini" } else { preset }), ..DictationContext::default() }; context.llm.model = Some("fixture-model".into()); @@ -2177,6 +2270,34 @@ mod tests { } } + async fn assert_translation_streams_before_completion( + gemini: bool, + cancel: bool, + fallback: bool, + ) { + assert_translation_streams_for_protocol( + gemini, + crate::llm_protocol::LlmRequestFormat::ChatCompletions, + "openai-compatible", + cancel, + fallback, + ) + .await; + } + + #[tokio::test] + async fn translation_streams_safely_across_channel_protocols_and_zen() { + for format in crate::llm_protocol::LlmRequestFormat::ALL { + for preset in ["custom", "opencode"] { + for fallback in [false, true] { + assert_translation_streams_for_protocol(false, format, preset, false, fallback) + .await; + } + } + assert_translation_streams_for_protocol(false, format, "custom", true, false).await; + } + } + #[tokio::test] async fn openai_translation_streams_target_before_completion() { assert_translation_streams_before_completion(false, false, false).await; diff --git a/openless-all/app/crates/openless-core/src/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index 8b031960..db651678 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -129,6 +129,13 @@ pub struct ChannelTestSummary { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ChannelMutation { + InvalidateTest { + kind: ChannelKind, + id: String, + }, + InvalidateTests { + kind: ChannelKind, + }, /// Commit a prepared local runtime and its channel in one metadata revision. ActivateLocalAsr { id: Option, @@ -519,6 +526,8 @@ impl CredentialMetadata { ChannelMutation::ActivateLocalAsr { .. } => ChannelKind::Asr, ChannelMutation::Create { kind, .. } | ChannelMutation::SetProviderType { kind, .. } + | ChannelMutation::InvalidateTest { kind, .. } + | ChannelMutation::InvalidateTests { kind } | ChannelMutation::DeleteIfBlank { kind, .. } | ChannelMutation::Rename { kind, .. } | ChannelMutation::Delete { kind, .. } @@ -630,6 +639,16 @@ 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::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/credentials_legacy.rs b/openless-all/app/crates/openless-core/src/credentials_legacy.rs index 3f1cfedd..6b5bc878 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 8c57fa77..1aefdd1e 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 d4d89dd8..98edf187 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -36,6 +36,7 @@ mod hotkey_interpreter; pub mod language_catalog; 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 00000000..ee0e01e7 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/llm_protocol.rs @@ -0,0 +1,772 @@ +//! 渠道级文本协议:请求格式、鉴权和正文事件由 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")); + } + 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 + } + }; + 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 { + if let Some(reason) = value.as_str() { + if !matches!(reason, "end_turn" | "stop_sequence") { + return Err(response_error("llmResponseIncomplete")); + } + return Ok(true); + } + Ok(false) +} + +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 => { + if !check_stop_reason(&value["stop_reason"])? { + 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, + messages_complete: bool, + pub done: bool, +} + +impl TextEventStream { + pub fn new(format: LlmRequestFormat) -> Self { + Self { + format, + buffer: String::new(), + pending: Vec::new(), + messages_complete: false, + 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" => { + 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 + } + _ => 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); + 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); + } + 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")); + } + + 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 ce823eea..cef5cf31 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 @@ -176,6 +187,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool { | "codingPlanX" | "minimax" | "stepfun" + | "opencode" ) } @@ -234,6 +246,12 @@ impl ActiveLLMProvider { } } + /// Channel formats use their protocol decoder; Codex uses its dedicated + /// Responses transport. Gemini is routed separately by cloud_providers. + pub fn supports_streaming_polish(&self) -> bool { + matches!(self, Self::OpenAI(_) | Self::Codex(_)) + } + pub async fn polish_streaming( &self, raw_text: &str, @@ -473,7 +491,6 @@ impl ActiveLLMProvider { pub struct OpenAICompatibleLLMProvider { config: OpenAICompatibleConfig, - client: reqwest::Client, /// 润色专用客户端:**不带**按输入长度变化的整请求超时,只留一个防连接泄漏的 /// 硬顶。真正的判据在调用点(流式两把尺子 / 非流式一个总预算)。 /// @@ -489,15 +506,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), || { @@ -507,7 +517,6 @@ impl OpenAICompatibleLLMProvider { }); Self { config, - client, polish_client, } } @@ -718,7 +727,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); @@ -740,7 +749,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![ @@ -760,6 +769,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, @@ -810,17 +822,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?; @@ -838,12 +852,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, @@ -855,125 +867,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 mut messages = vec![json!({ "role": "system", "content": system_prompt })]; + for message in history { + messages.push(json!({ "role": message.role, "content": message.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 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, @@ -985,151 +899,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)] @@ -1905,8 +1800,23 @@ pub(crate) fn apply_openai_compatible_thinking_control( ) { // 优先按 provider_id 预设分派;custom / 未声明 provider 时回退到 base_url 兜底, // 让用户用"自定义"preset 接入 MiniMax 也能正确下发 thinking 控制参数。 - let control = openai_compatible_thinking_control(provider_id) - .or_else(|| openai_compatible_thinking_control_for_base_url(base_url)); + // Zen 是多模型网关,仅 DeepSeek 模型使用 DeepSeek 的思考参数。 + let is_opencode = provider_id.trim() == "opencode" + || (matches!( + provider_id.trim(), + "custom" | "custom_responses" | "custom_messages" + ) && url::Url::parse(base_url.trim()) + .ok() + .is_some_and(|url| url.host_str() == Some("opencode.ai"))); + let control = if is_opencode { + model + .trim() + .starts_with("deepseek-") + .then_some(ThinkingControl::DeepSeekThinking) + } else { + openai_compatible_thinking_control(provider_id) + .or_else(|| openai_compatible_thinking_control_for_base_url(base_url)) + }; match control { Some(ThinkingControl::ReasoningEffort) => { // OpenAI 官方 Chat Completions 只在推理模型族接受 reasoning_effort; @@ -2084,7 +1994,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; @@ -2379,6 +2289,256 @@ mod tests { request } + #[tokio::test] + async fn all_text_entrypoints_use_the_selected_protocol_over_http() { + for (format, (preset, prefix)) in LlmRequestFormat::ALL.into_iter().flat_map(|format| { + [ + ("custom", "/gateway/v1"), + ("opencode", "/zen/v1"), + ("opencode", "/zen/go/v1"), + ] + .map(|entry| (format, entry)) + }) { + 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..6 { + 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 {prefix}/{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_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( + &mut stream, + &[&response.as_bytes()[..split], &response.as_bytes()[split..]], + ); + } + } + }); + let config = OpenAICompatibleConfig::new( + preset, + "test", + format!("http://{address}{prefix}/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(), "你好"); + output.lock().unwrap().clear(); + assert_eq!( + provider + .translate_to_streaming( + "hello", + "Chinese", + &[], + ChineseScriptPreference::Auto, + OutputLanguagePreference::Auto, + None, + delta, + || false, + ) + .await + .unwrap(), + "你好" + ); + assert_eq!(*output.lock().unwrap(), "你好"); + server.join().unwrap(); + } + } + + #[test] + fn opencode_thinking_is_scoped_to_model_host_and_protocol() { + for (preset, endpoint, zen) in [ + ("opencode", "https://opencode.ai/zen/v1", true), + ("opencode", "https://gateway.example/v1", true), + ("custom", "https://opencode.ai/zen/v1", true), + ("custom_responses", "https://opencode.ai/zen/v1", true), + ("custom_messages", "https://opencode.ai/zen/v1", true), + ( + "custom", + "https://OPENCODE.AI:443/zen/go/v1/chat/completions", + true, + ), + ("custom", "https://opencode.ai.example/zen/v1", false), + ("custom", "https://fakeopencode.ai/zen/v1", false), + ("custom", "https://opencode.ai@example.com/zen/v1", false), + ("custom", "https://example.com/opencode.ai", false), + ] { + for model in ["deepseek-v4-flash", "minimax-m3", "gateway-model"] { + for enabled in [false, true] { + for format in LlmRequestFormat::ALL { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new(preset, "test", endpoint, "key", model) + .with_thinking_enabled(enabled) + .with_protocol(LlmProtocolConfig { + format, + ..Default::default() + }), + ); + let body = + provider.chat_body(false, vec![json!({"role":"user","content":"hi"})]); + match format { + LlmRequestFormat::ChatCompletions + if zen && model.starts_with("deepseek-") => + { + assert_eq!( + body["thinking"]["type"], + if enabled { "enabled" } else { "disabled" } + ); + } + LlmRequestFormat::Messages if enabled => { + assert_eq!(body["thinking"]["type"], "adaptive"); + } + _ => assert!( + body.get("thinking").is_none(), + "{preset} {endpoint} {model} {format:?}" + ), + } + assert!(body.get("reasoning_effort").is_none()); + assert!(body.get("enable_thinking").is_none()); + if format == LlmRequestFormat::Responses { + assert_eq!( + body["reasoning"]["effort"], + if enabled { "medium" } else { "low" } + ); + assert!(body.get("messages").is_none()); + } else { + assert!(body.get("reasoning").is_none()); + } + } + } + } + } + } + fn write_chunked_sse_response(stream: &mut std::net::TcpStream, chunks: &[&[u8]]) { stream .write_all( @@ -2393,6 +2553,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 ced46716..9ca25a0a 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,10 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[ ("codingPlanX", "codingPlanX"), ("minimax", "minimax"), ("stepfun", "stepfun"), - ("custom", "custom"), + ("opencode", "opencode"), + ("custom", "customChatCompletions"), + ("custom_responses", "customResponses"), + ("custom_messages", "customMessages"), ]; const OMNI_PROVIDER_TYPES: &[(&str, &str)] = &[ @@ -133,6 +136,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 +215,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 +437,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)) @@ -465,6 +482,7 @@ pub fn default_asr_model(provider_type: &str) -> Option<&'static str> { pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> { match provider_type { + "opencode" => Some("https://opencode.ai/zen/v1"), "ark" => Some("https://ark.cn-beijing.volces.com/api/v3"), "deepseek" => Some("https://api.deepseek.com/v1"), "siliconflow" => Some("https://api.siliconflow.cn/v1"), @@ -485,7 +503,7 @@ pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> { pub fn default_llm_model(provider_type: &str) -> Option<&'static str> { match provider_type { "ark" => Some("deepseek-v3-2"), - "deepseek" => Some("deepseek-v4-flash"), + "deepseek" | "opencode" => Some("deepseek-v4-flash"), "siliconflow" => Some("Qwen/Qwen2.5-7B-Instruct"), "atlascloud" => Some("qwen/qwen3.5-flash"), "openai" | "cometapi" => Some("gpt-4o"), @@ -956,6 +974,42 @@ mod tests { assert!(parse_extra_headers(r#"{"authorization":"secret"}"#).is_err()); } + #[test] + fn opencode_descriptor_supplies_defaults_formats_and_credentials() { + use crate::llm_protocol::LlmRequestFormat; + assert!(crate::cloud_providers::SHARED_CLOUD_LLM_PROVIDER_TYPES.contains(&"opencode")); + let descriptor = provider_descriptor(ProviderKind::Llm, "opencode").unwrap(); + assert_eq!(descriptor.label_key, "opencode"); + assert_eq!( + descriptor.default_endpoint.as_deref(), + Some("https://opencode.ai/zen/v1") + ); + assert_eq!( + descriptor.default_model.as_deref(), + Some("deepseek-v4-flash") + ); + assert_eq!( + descriptor.default_request_format, + Some(LlmRequestFormat::ChatCompletions) + ); + assert_eq!(descriptor.supported_request_formats, LlmRequestFormat::ALL); + assert_eq!(descriptor.validation_probe, ValidationProbe::LlmText); + assert!(api_key_required( + ProviderKind::Llm, + "opencode", + Some("https://opencode.ai/zen/v1/chat/completions/") + )); + let mut configuration = CredentialConfiguration { + llm_endpoint: true, + llm_endpoint_matches_default: true, + llm_model: true, + ..CredentialConfiguration::default() + }; + assert!(!llm_configured("opencode", &configuration)); + configuration.llm_api_key = true; + assert!(llm_configured("opencode", &configuration)); + } + #[test] fn descriptors_are_the_single_source_for_defaults_auth_and_probes() { let compatible = provider_descriptor(ProviderKind::Asr, "openai-compatible").unwrap(); 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 ece54a2f..a4d1c2d4 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,48 @@ 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 [ + ("chat_completions", "opencode", "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n", "/v1/chat/completions"), + ("responses", "opencode", "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\ndata: {\"type\":\"response.completed\"}\n\n", "/v1/responses"), + ("messages", "opencode", "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"), + ("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_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); + 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 if format == "messages" && 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()); + let channel = create_channel_with_values(&credentials, ChannelKind::Llm, preset, &[ + (LLM_ENDPOINT_ACCOUNT, &format!("{endpoint}/{}", if format == "chat_completions" { "chat/completions" } else { 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 +949,7 @@ mod tests { service .validate(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Asr, channel_id: Some(channel), }) @@ -918,6 +983,7 @@ mod tests { service .validate(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel), }) @@ -951,6 +1017,7 @@ mod tests { let error = service .validate(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel), }) @@ -981,6 +1048,7 @@ mod tests { let result = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Asr, channel_id: Some(channel), }) @@ -1024,6 +1092,7 @@ mod tests { service .validate(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Asr, channel_id: Some(channel), }) @@ -1092,6 +1161,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 +1178,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 +1237,7 @@ mod tests { let first_resolved = service .resolve(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(first_id.clone()), }) @@ -1173,6 +1245,7 @@ mod tests { .unwrap(); let second_resolved = service .resolve(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(second_id.clone()), }) @@ -1180,6 +1253,7 @@ mod tests { .unwrap(); let active_resolved = service .resolve(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: None, }) @@ -1286,6 +1360,7 @@ mod tests { let result = service .list_models(ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel), }) @@ -1327,6 +1402,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 +1416,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 +1428,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 +1467,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 +1488,7 @@ mod tests { let error = service .list_models_with_cancellation( ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Llm, channel_id: Some(channel), }, @@ -1441,6 +1521,7 @@ mod tests { let error = service .list_models_with_cancellation( ProviderRequest { + thinking_enabled: false, kind: ProviderKind::Asr, channel_id: Some(channel), }, @@ -1490,6 +1571,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/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 7d541914..c6c50adf 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -565,6 +565,10 @@ pub struct UserPreferences { /// 手动检查按钮显式指定 channel,与此 pref 解耦。 #[serde(default)] pub update_channel: UpdateChannel, + /// 是否由用户明确选择过更新渠道。旧版默认会把 Stable 写入配置,单看 + /// `update_channel` 无法区分默认值与主动切换;历史 Beta 则必然来自用户 opt-in。 + #[serde(default)] + pub update_channel_explicit: bool, /// 历史记录保留天数。0 = 不按时间清理(仅受 200 条上限)。默认 7 天。 /// 写入新条目时执行清理,避免后台轮询。 #[serde(default = "default_history_retention_days")] @@ -892,6 +896,8 @@ struct UserPreferencesWire { sherpa_onnx_keep_loaded_secs: u32, #[serde(default)] update_channel: UpdateChannel, + #[serde(default)] + update_channel_explicit: Option, #[serde(default = "default_history_retention_days")] history_retention_days: u32, #[serde(default = "default_polish_context_window_minutes")] @@ -1061,6 +1067,8 @@ impl Default for UserPreferencesWire { sherpa_onnx_language_hint: prefs.sherpa_onnx_language_hint, sherpa_onnx_keep_loaded_secs: prefs.sherpa_onnx_keep_loaded_secs, update_channel: prefs.update_channel, + // None 保留旧配置缺少标记的信息;反序列化时只有历史 Beta 视为显式选择。 + update_channel_explicit: None, history_retention_days: prefs.history_retention_days, polish_context_window_minutes: prefs.polish_context_window_minutes, start_minimized: prefs.start_minimized, @@ -1129,6 +1137,9 @@ impl<'de> Deserialize<'de> for UserPreferences { }; let (local_asr_active_model, local_whisper_active_model) = migrate_local_asr_models(wire.local_asr_active_model, wire.local_whisper_active_model); + let update_channel_explicit = wire + .update_channel_explicit + .unwrap_or(matches!(wire.update_channel, UpdateChannel::Beta)); Ok(Self { hotkey: wire.hotkey, @@ -1224,6 +1235,7 @@ impl<'de> Deserialize<'de> for UserPreferences { sherpa_onnx_language_hint: wire.sherpa_onnx_language_hint, sherpa_onnx_keep_loaded_secs: wire.sherpa_onnx_keep_loaded_secs, update_channel: wire.update_channel, + update_channel_explicit, history_retention_days: wire.history_retention_days, polish_context_window_minutes: wire.polish_context_window_minutes, start_minimized: wire.start_minimized, @@ -1565,6 +1577,7 @@ impl Default for UserPreferences { sherpa_onnx_language_hint: String::new(), sherpa_onnx_keep_loaded_secs: default_local_asr_keep_loaded_secs(), update_channel: UpdateChannel::default(), + update_channel_explicit: false, history_retention_days: default_history_retention_days(), polish_context_window_minutes: default_polish_context_window_minutes(), start_minimized: false, @@ -3133,6 +3146,32 @@ mod tests { assert!(!prefs.streaming_insert_save_clipboard); } + #[test] + fn update_channel_migration_preserves_only_explicit_legacy_beta_opt_in() { + let legacy_stable: UserPreferences = + serde_json::from_str(r#"{ "updateChannel": "stable" }"#).unwrap(); + assert_eq!(legacy_stable.update_channel, UpdateChannel::Stable); + assert!(!legacy_stable.update_channel_explicit); + + let legacy_beta: UserPreferences = + serde_json::from_str(r#"{ "updateChannel": "beta" }"#).unwrap(); + assert_eq!(legacy_beta.update_channel, UpdateChannel::Beta); + assert!(legacy_beta.update_channel_explicit); + + let explicit_stable: UserPreferences = serde_json::from_str( + r#"{ + "updateChannel": "stable", + "updateChannelExplicit": true + }"#, + ) + .unwrap(); + assert!(explicit_stable.update_channel_explicit); + + let round_trip: UserPreferences = + serde_json::from_str(&serde_json::to_string(&explicit_stable).unwrap()).unwrap(); + assert!(round_trip.update_channel_explicit); + } + #[test] fn paste_shortcut_round_trips_explicit_values() { for (raw, expected) in [ diff --git a/openless-all/app/linux-egui/src/backend.rs b/openless-all/app/linux-egui/src/backend.rs index 9065cd0c..8775b896 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 35cb4b65..d8d90f1e 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -372,6 +372,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()), }) @@ -2500,6 +2501,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/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 93621dfb..46e9fe79 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -4241,6 +4241,7 @@ dependencies = [ "regex", "reqwest 0.12.28", "rustls", + "semver", "serde", "serde_json", "sha1", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 347d85a2..81385425 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -30,6 +30,7 @@ tauri-plugin-shell = "2.3.5" tauri-plugin-dialog = "2.7.2" serde = { version = "1", features = ["derive"] } serde_json = "1" +semver = "1" # OpenRouter ASR 把音频以标准 base64(带 padding)放进 JSON body(issue #582)。 base64 = "0.22" sha2 = "0.10" diff --git a/openless-all/app/src-tauri/src/android/updater_logic.rs b/openless-all/app/src-tauri/src/android/updater_logic.rs index 2fe83328..072d7b67 100644 --- a/openless-all/app/src-tauri/src/android/updater_logic.rs +++ b/openless-all/app/src-tauri/src/android/updater_logic.rs @@ -22,25 +22,13 @@ pub fn map_abi_to_arch(abi: &str) -> &'static str { } pub fn version_is_newer(remote: &str, current: &str) -> bool { - fn parts(v: &str) -> Vec { - v.split(|c| c == '.' || c == '-') - .filter_map(|p| p.parse().ok()) - .collect() - } - let remote_parts = parts(remote); - let current_parts = parts(current); - let max = remote_parts.len().max(current_parts.len()); - for i in 0..max { - let r = remote_parts.get(i).copied().unwrap_or(0); - let c = current_parts.get(i).copied().unwrap_or(0); - if r > c { - return true; - } - if r < c { - return false; - } - } - false + let (Ok(remote), Ok(current)) = ( + semver::Version::parse(remote), + semver::Version::parse(current), + ) else { + return false; + }; + remote > current } pub fn stable_manifest_urls(arch: &str) -> Vec { @@ -78,9 +66,12 @@ mod tests { } #[test] - fn version_is_newer_handles_beta_suffix() { - assert!(version_is_newer("1.3.8-1", "1.3.8")); - assert!(!version_is_newer("1.3.8", "1.3.8-1")); + fn version_is_newer_uses_semver_prerelease_ordering() { + assert!(version_is_newer("1.3.18", "1.3.18-Beta.7")); + assert!(version_is_newer("1.3.18-Beta.8", "1.3.18-Beta.7")); + assert!(!version_is_newer("1.3.18-Beta.7", "1.3.18-Beta.7")); + assert!(!version_is_newer("1.3.17", "1.3.18-Beta.7")); + assert!(!version_is_newer("not-a-version", "1.3.18-Beta.7")); } #[test] diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 04f3f2d8..97ee3d31 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -250,6 +250,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( @@ -316,6 +321,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) => { @@ -745,6 +755,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 } @@ -860,6 +873,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 8f8ccb2d..9929fa95 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/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 8952dd2e..9dea0b06 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -2,7 +2,9 @@ use super::*; #[tauri::command] pub fn get_settings(core: CoreState<'_>) -> UserPreferences { - core.get_preferences() + let mut prefs = core.get_preferences(); + prefs.update_channel = effective_update_channel(None, &prefs, env!("CARGO_PKG_VERSION")); + prefs } #[tauri::command] @@ -143,8 +145,10 @@ impl openless_core::SettingsRuntime for TauriSettingsRuntime<'_> { } } -pub(crate) fn persist_settings(coord: &Coordinator, prefs: UserPreferences) -> Result<(), String> { - let _host_guard = coord.lock_settings_host(); +fn persist_settings_with_host_lock_held( + coord: &Coordinator, + prefs: UserPreferences, +) -> Result<(), String> { coord .backend() .update_settings( @@ -163,11 +167,22 @@ pub(crate) fn persist_settings(coord: &Coordinator, prefs: UserPreferences) -> R .map_err(|error| error.to_string()) } -pub(crate) fn persist_strict_settings( +fn persist_settings_preserving_update_channel( coord: &Coordinator, - prefs: UserPreferences, + mut prefs: UserPreferences, ) -> Result<(), String> { let _host_guard = coord.lock_settings_host(); + // 在同一把写锁内读取并回填,避免并发渠道切换被旧设置快照覆盖。 + preserve_update_channel_preferences(&mut prefs, &coord.backend().get_preferences()); + persist_settings_with_host_lock_held(coord, prefs) +} + +pub(crate) fn persist_strict_settings( + coord: &Coordinator, + mut prefs: UserPreferences, +) -> Result<(), String> { + let _host_guard = coord.lock_settings_host(); + preserve_update_channel_preferences(&mut prefs, &coord.backend().get_preferences()); coord .backend() .update_settings( @@ -179,6 +194,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( @@ -194,10 +224,11 @@ 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}" 文案一直停留在旧值。 - persist_settings(&*coord, prefs)?; + persist_settings_preserving_update_channel(&*coord, prefs)?; let prefs = coord.backend().get_preferences(); // 保存即同步胶囊样式原子:下一次录音的入场帧就携带新样式,不依赖 emit_capsule // 主线程闭包的 ~30Hz 同步(Windows 主线程拥塞时闭包延迟 → 整场显示旧样式)。 @@ -245,7 +276,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() @@ -253,7 +287,8 @@ 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(); - persist_settings(&*coord, prefs)?; + invalidate_llm_tests_if_thinking_changed(&coord, &previous, &prefs).await?; + persist_settings_preserving_update_channel(&*coord, prefs)?; let prefs = coord.backend().get_preferences(); // 保存即同步胶囊样式原子(Android 通知胶囊 payload 同源,见 emit_capsule)。 coord.sync_capsule_style_from_preferences(); @@ -293,6 +328,63 @@ mod tests { ); assert_eq!(stale_settings_payload.default_mode, PolishMode::Light); } + + #[test] + fn update_channel_defaults_to_build_channel_until_user_selects_one() { + let mut prefs = UserPreferences::default(); + + assert_eq!( + effective_update_channel(None, &prefs, "2.0.0-Beta.1"), + UpdateChannel::Beta + ); + assert_eq!( + effective_update_channel(None, &prefs, "2.0.0"), + UpdateChannel::Stable + ); + let legacy_beta = UserPreferences { + update_channel: UpdateChannel::Beta, + update_channel_explicit: true, + ..UserPreferences::default() + }; + assert_eq!( + effective_update_channel(None, &legacy_beta, "2.0.0"), + UpdateChannel::Beta + ); + assert_eq!( + effective_update_channel(Some(UpdateChannel::Stable), &prefs, "2.0.0-Beta.1"), + UpdateChannel::Stable + ); + + assert!(select_update_channel(&mut prefs, UpdateChannel::Stable)); + assert!(prefs.update_channel_explicit); + assert_eq!( + effective_update_channel(None, &prefs, "2.0.0-Beta.1"), + UpdateChannel::Stable + ); + assert!(!select_update_channel(&mut prefs, UpdateChannel::Stable)); + assert!(select_update_channel(&mut prefs, UpdateChannel::Beta)); + assert_eq!(prefs.update_channel, UpdateChannel::Beta); + assert!(prefs.update_channel_explicit); + } + + #[test] + fn general_settings_save_preserves_dedicated_update_channel_fields() { + let current = UserPreferences { + update_channel: UpdateChannel::Stable, + update_channel_explicit: true, + ..UserPreferences::default() + }; + let mut stale_payload = UserPreferences { + update_channel: UpdateChannel::Beta, + update_channel_explicit: false, + ..UserPreferences::default() + }; + + preserve_update_channel_preferences(&mut stale_payload, ¤t); + + assert_eq!(stale_payload.update_channel, UpdateChannel::Stable); + assert!(stale_payload.update_channel_explicit); + } } // ─────────────────────────── release channel (Beta opt-in) ─────────────────────────── @@ -308,9 +400,38 @@ mod tests { // (Beta tag 的 manifest 文件名带 `-beta` 后缀,跟 Stable manifest 在 GitHub // Release assets 里物理分离)。 +fn effective_update_channel( + requested: Option, + prefs: &UserPreferences, + app_version: &str, +) -> UpdateChannel { + requested.unwrap_or_else(|| { + if prefs.update_channel_explicit { + prefs.update_channel + } else if app_version.contains('-') { + UpdateChannel::Beta + } else { + UpdateChannel::Stable + } + }) +} + +fn select_update_channel(prefs: &mut UserPreferences, channel: UpdateChannel) -> bool { + let changed = prefs.update_channel != channel || !prefs.update_channel_explicit; + prefs.update_channel = channel; + prefs.update_channel_explicit = true; + changed +} + +fn preserve_update_channel_preferences(incoming: &mut UserPreferences, current: &UserPreferences) { + incoming.update_channel = current.update_channel; + incoming.update_channel_explicit = current.update_channel_explicit; +} + #[tauri::command] pub fn get_update_channel(core: CoreState<'_>) -> UpdateChannel { - core.get_preferences().update_channel + let prefs = core.get_preferences(); + effective_update_channel(None, &prefs, env!("CARGO_PKG_VERSION")) } #[tauri::command] @@ -318,12 +439,13 @@ pub fn set_update_channel( coord: CoordinatorState<'_>, channel: UpdateChannel, ) -> Result<(), String> { + // 渠道读取和持久化必须同属一个写临界区,避免反向覆盖并发常规设置。 + let _host_guard = coord.lock_settings_host(); let mut prefs = coord.backend().get_preferences(); - if prefs.update_channel == channel { + if !select_update_channel(&mut prefs, channel) { return Ok(()); } - prefs.update_channel = channel; - persist_settings(&*coord, prefs)?; + persist_settings_with_host_lock_held(&*coord, prefs)?; Ok(()) } @@ -461,7 +583,7 @@ pub struct AppUpdateMetadata { /// 决定 manifest 来源后走 plugin-updater 的标准 check 流程。 /// 渠道:显式传入 `channel` 时用它(关于页固定查 Stable、高级页 Beta 区查 Beta); -/// 不传则回落到 `prefs.update_channel`(后台 AutoUpdateGate 自动检查走这条)。 +/// 不传则使用用户明确选择的渠道;尚未选择时跟随当前构建类型。 /// 返回 None = 当前是最新;Some(metadata) = 有新版可装。 #[tauri::command] #[cfg(not(mobile))] @@ -473,7 +595,8 @@ pub async fn app_check_update_with_channel( ) -> Result, String> { use tauri_plugin_updater::UpdaterExt; - let channel = channel.unwrap_or_else(|| coord.backend().get_preferences().update_channel); + let prefs = coord.backend().get_preferences(); + let channel = effective_update_channel(channel, &prefs, env!("CARGO_PKG_VERSION")); let mut builder = webview.updater_builder(); if let Some(ms) = timeout_ms { builder = builder.timeout(std::time::Duration::from_millis(ms)); @@ -537,7 +660,8 @@ pub async fn app_check_update_with_channel( ) -> Result, String> { #[cfg(target_os = "android")] { - let channel = channel.unwrap_or_else(|| coord.backend().get_preferences().update_channel); + let prefs = coord.backend().get_preferences(); + let channel = effective_update_channel(channel, &prefs, env!("CARGO_PKG_VERSION")); return crate::android::updater::check_update(channel).await; } #[cfg(not(target_os = "android"))] diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index dca42a50..8e3a278a 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -383,9 +383,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() { @@ -401,6 +420,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() @@ -2386,6 +2409,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(); @@ -2695,6 +2741,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/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index c91f6542..907d8e96 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -19,9 +19,11 @@ import { logClientError, openExternal, restartApp, + setUpdateChannel, type AppUpdateMetadata, type UpdateChannel, } from '../lib/ipc'; +import { isStableChannelSwitch } from '../lib/appVersion'; import { Btn } from '../pages/_atoms'; const UPDATE_CHECK_TIMEOUT_MS = 15_000; @@ -50,6 +52,7 @@ export type CheckUpdateOptions = { export interface UseAutoUpdate { status: UpdateStatus; + currentVersion: string; version: string; progress: number | null; downloaded: number; @@ -72,6 +75,7 @@ export function useAutoUpdate(): UseAutoUpdate { const updateRef = useRef(null); const androidUpdateRef = useRef(null); const [status, setStatus] = useState('idle'); + const [currentVersion, setCurrentVersion] = useState(''); const [version, setVersion] = useState(''); const [downloaded, setDownloaded] = useState(0); const [contentLength, setContentLength] = useState(null); @@ -143,6 +147,7 @@ export function useAutoUpdate(): UseAutoUpdate { const checkForUpdates = async (channel?: UpdateChannel, options?: CheckUpdateOptions) => { setStatus('checking'); + setCurrentVersion(''); setVersion(''); setErrorMessage(null); resetProgress(); @@ -157,6 +162,7 @@ export function useAutoUpdate(): UseAutoUpdate { setStatus('none'); return; } + setCurrentVersion(metadata.currentVersion); if (isAndroid()) { storeAndroidMetadata(metadata); setVersion(metadata.version); @@ -205,12 +211,18 @@ export function useAutoUpdate(): UseAutoUpdate { }; const installUpdate = async () => { + const persistStableChannelSwitch = () => + isStableChannelSwitch(currentVersion, version) + ? setUpdateChannel('stable') + : Promise.resolve(); + if (isAndroid()) { const payload = androidUpdateRef.current; if (!payload) return; resetProgress(); setStatus('downloading'); try { + await persistStableChannelSwitch(); await appDownloadAndInstallAndroidUpdate(payload); androidUpdateRef.current = null; setStatus('downloaded'); @@ -229,6 +241,7 @@ export function useAutoUpdate(): UseAutoUpdate { resetProgress(); setStatus('downloading'); try { + await persistStableChannelSwitch(); await update.download((event: DownloadEvent) => { if (event.event === 'Started') { resetProgress(); @@ -257,12 +270,14 @@ export function useAutoUpdate(): UseAutoUpdate { if (busy) return; await closeUpdate(); setStatus('idle'); + setCurrentVersion(''); setVersion(''); resetProgress(); }; return { status, + currentVersion, version, progress, downloaded, @@ -290,6 +305,7 @@ export function isDialogStatus( export function UpdateDialog({ status, + currentVersion, version, progress, downloaded, @@ -299,6 +315,7 @@ export function UpdateDialog({ onClose, }: { status: 'available' | 'downloading' | 'installing' | 'downloaded' | 'installError'; + currentVersion: string; version: string; progress: number | null; downloaded: number; @@ -312,6 +329,8 @@ export function UpdateDialog({ const installing = status === 'installing'; const installError = status === 'installError'; const androidInstalled = isAndroid() && status === 'downloaded'; + const switchingToStable = + status === 'available' && isStableChannelSwitch(currentVersion, version); // Portal 到 document.body:WindowChrome / 设置弹窗带常驻 transform + will-change, // 会创建 containing block——`position: fixed` 的遮罩会相对设置面板定位,只压暗 // 白色内容区(侧边栏深色看不出,形成「内容变灰、断层感」,见 Modal.tsx 同款注释)。 @@ -339,7 +358,9 @@ export function UpdateDialog({ }} >
- {t(`settings.about.updateDialog.${status}.title`)} + {t( + `settings.about.updateDialog.${switchingToStable ? 'stableChannelSwitch' : status}.title`, + )}
{(downloading || installing || status === 'downloaded') && (
diff --git a/openless-all/app/src/components/AutoUpdateGate.tsx b/openless-all/app/src/components/AutoUpdateGate.tsx index 0e349d88..441657f8 100644 --- a/openless-all/app/src/components/AutoUpdateGate.tsx +++ b/openless-all/app/src/components/AutoUpdateGate.tsx @@ -55,6 +55,7 @@ export function AutoUpdateGate() { return ( + currentVersion.includes('-') && !targetVersion.includes('-'); diff --git a/openless-all/app/src/lib/ipc/asr-credentials.ts b/openless-all/app/src/lib/ipc/asr-credentials.ts index 51ef6f15..4beed199 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; @@ -15,7 +16,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 { @@ -31,7 +35,11 @@ export function setActiveOmniProvider(provider: string): Promise { } export function readCredential(account: string, provider?: string): Promise { - return invokeOrMock('read_credential', { account, provider }, () => null); + return invokeOrMock( + 'read_credential', + { account, provider }, + () => mockCredentialValues.get(`${provider ?? ''}:${account}`) ?? null, + ); } /** `channelId` 省略时测当前生效的渠道;卡片上的「测试连通」会带上那张卡片的 id。 */ diff --git a/openless-all/app/src/lib/ipc/channels.ts b/openless-all/app/src/lib/ipc/channels.ts index 4ed20e31..35c1b2fd 100644 --- a/openless-all/app/src/lib/ipc/channels.ts +++ b/openless-all/app/src/lib/ipc/channels.ts @@ -2,6 +2,7 @@ // Core 选择排序后的第一个启用渠道;凭据通过 readCredential/setCredential 按渠道 ID 读写。 import { invokeOrMock } from './shared'; +import { mockCredentialValues } from './mock-data'; export type ChannelKind = 'llm' | 'asr'; @@ -82,13 +83,33 @@ 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; +} + +export function invalidateMockChannelTests(kind: ChannelKind): void { + for (const channel of mockChannels[kind]) channel.lastTest = null; +} + /** 返回后端分配的渠道 id。 */ export function createChannel( kind: ChannelKind, providerType: string, name: string, ): Promise { - return invokeOrMock('create_channel', { kind, providerType, name }, () => providerType); + return invokeOrMock('create_channel', { kind, providerType, name }, () => { + const id = `${providerType}-${Date.now()}-${mockChannels[kind].length}`; + mockChannels[kind].push({ + id, + name, + providerType, + enabled: true, + order: mockChannels[kind].length, + lastTest: null, + }); + return id; + }); } /** 在已建好的草稿卡片上换供应商(单弹窗添加流程的常规操作)。 */ @@ -97,12 +118,31 @@ export function setChannelProviderType( id: string, providerType: string, ): Promise { - return invokeOrMock('set_channel_provider_type', { kind, id, providerType }, () => undefined); + return invokeOrMock('set_channel_provider_type', { kind, id, providerType }, () => { + 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`); + } + }); } /** 关闭添加弹窗时回收没填任何东西的草稿;返回是否真的删了。 */ 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(kind: ChannelKind, id: string, name: string): Promise { @@ -110,7 +150,11 @@ export function renameChannel(kind: ChannelKind, id: string, name: string): Prom } 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(kind: ChannelKind, id: string, enabled: boolean): Promise { @@ -142,5 +186,8 @@ export function recordChannelTest( latencyMs: number | null, error: string | null, ): Promise { - return invokeOrMock('record_channel_test', { kind, id, ok, latencyMs, error }, () => undefined); + return invokeOrMock('record_channel_test', { kind, id, ok, latencyMs, error }, () => { + 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 9e7fd952..66498311 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, @@ -109,6 +110,7 @@ export let mockSettings: UserPreferences = { startMinimized: false, themeMode: 'system', updateChannel: 'stable', + updateChannelExplicit: false, streamingInsert: true, streamingInsertDefaultMigrated: true, streamingInsertSaveClipboard: true, diff --git a/openless-all/app/src/lib/ipc/provider-descriptors.generated.json b/openless-all/app/src/lib/ipc/provider-descriptors.generated.json index ef652780..5a628788 100644 --- a/openless-all/app/src/lib/ipc/provider-descriptors.generated.json +++ b/openless-all/app/src/lib/ipc/provider-descriptors.generated.json @@ -5,28 +5,33 @@ "authRequirement": "volcengine", "defaultEndpoint": null, "defaultModel": null, + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrVolcengine", "providerType": "volcengine", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "asr_silence_allows_no_final" }, { "authRequirement": "api_key", "defaultEndpoint": "https://api.elevenlabs.io/v1", "defaultModel": "scribe_v2", + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrElevenLabs", "providerType": "elevenlabs", "staticModels": [ "scribe_v2" ], + "supportedRequestFormats": [], "validationProbe": "asr_silence" }, { "authRequirement": "api_key", "defaultEndpoint": "wss://dashscope.aliyuncs.com/api-ws/v1/inference/", "defaultModel": "fun-asr-realtime", + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrBailian", "providerType": "bailian", @@ -46,12 +51,14 @@ "fun-asr-mtl-2025-08-25", "paraformer-v2" ], + "supportedRequestFormats": [], "validationProbe": "asr_silence" }, { "authRequirement": "api_key", "defaultEndpoint": "wss://dashscope.aliyuncs.com/api-ws/v1/realtime", "defaultModel": "qwen3-asr-flash-realtime", + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrBailianQwen3", "providerType": "bailian-qwen3-realtime", @@ -60,12 +67,14 @@ "qwen3-asr-flash-realtime-2026-02-10", "qwen3-asr-flash-realtime-2025-10-27" ], + "supportedRequestFormats": [], "validationProbe": "asr_silence" }, { "authRequirement": "api_key", "defaultEndpoint": "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", "defaultModel": "fun-asr-flash-2026-06-15", + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrBailianFunAsrFlash", "providerType": "bailian-fun-asr-flash", @@ -73,178 +82,213 @@ "qwen-audio-3.0-asr-flash", "fun-asr-flash-2026-06-15" ], + "supportedRequestFormats": [], "validationProbe": "asr_non_silent" }, { "authRequirement": "api_key", "defaultEndpoint": "https://api.siliconflow.cn/v1", "defaultModel": "FunAudioLLM/SenseVoiceSmall", + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrSiliconflow", "providerType": "siliconflow", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "asr_silence" }, { "authRequirement": "api_key", "defaultEndpoint": "https://api.stepfun.com/v1", "defaultModel": "stepaudio-2.5-asr", + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrStepfun", "providerType": "stepfun", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "stepfun_no_speech" }, { "authRequirement": "api_key", "defaultEndpoint": "https://open.bigmodel.cn/api/paas/v4", "defaultModel": "glm-asr-2512", + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrZhipu", "providerType": "zhipu", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "asr_silence" }, { "authRequirement": "api_key", "defaultEndpoint": "https://api.groq.com/openai/v1", "defaultModel": "whisper-large-v3-turbo", + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrGroq", "providerType": "groq", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "asr_silence" }, { "authRequirement": "api_key", "defaultEndpoint": "https://api.openai.com/v1", "defaultModel": "whisper-1", + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrWhisper", "providerType": "whisper", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "asr_silence" }, { "authRequirement": "api_key", "defaultEndpoint": "https://openrouter.ai/api/v1", "defaultModel": "openai/whisper-large-v3-turbo", + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrOpenrouter", "providerType": "openrouter", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "asr_silence" }, { "authRequirement": "api_key", "defaultEndpoint": "https://zenmux.ai/api/v1", "defaultModel": "qwen/qwen3-asr-flash", + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrZenmux", "providerType": "zenmux", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "asr_silence" }, { "authRequirement": "endpoint_model_optional_api_key", "defaultEndpoint": null, "defaultModel": null, + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrOpenAiCompatible", "providerType": "openai-compatible", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "asr_silence" }, { "authRequirement": "api_key", "defaultEndpoint": "https://api.xiaomimimo.com/v1", "defaultModel": "mimo-v2.5-asr", + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrXiaomiMimo", "providerType": "xiaomi-mimo-asr", "staticModels": [ "mimo-v2.5-asr" ], + "supportedRequestFormats": [], "validationProbe": "asr_silence" }, { "authRequirement": "xfyun", "defaultEndpoint": null, "defaultModel": null, + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrIflytek", "providerType": "iflytek", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "asr_silence_allows_no_final" }, { "authRequirement": "none", "defaultEndpoint": null, "defaultModel": null, + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrFoundryLocalWhisper", "providerType": "foundry-local-whisper", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "unsupported" }, { "authRequirement": "none", "defaultEndpoint": null, "defaultModel": null, + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrLocalWhisper", "providerType": "local-whisper", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "unsupported" }, { "authRequirement": "none", "defaultEndpoint": null, "defaultModel": null, + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrSherpaOnnxLocal", "providerType": "sherpa-onnx-local", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "unsupported" }, { "authRequirement": "none", "defaultEndpoint": null, "defaultModel": null, + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrLocalQwen3Mlx", "providerType": "local-qwen3-mlx", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "unsupported" }, { "authRequirement": "none", "defaultEndpoint": null, "defaultModel": null, + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrLocalQwen3C", "providerType": "local-qwen3-c", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "unsupported" }, { "authRequirement": "none", "defaultEndpoint": null, "defaultModel": null, + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrLocalQwen3", "providerType": "local-qwen3", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "unsupported" }, { "authRequirement": "none", "defaultEndpoint": null, "defaultModel": null, + "defaultRequestFormat": null, "kind": "asr", "labelKey": "asrAppleSpeech", "providerType": "apple-speech", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "unsupported" } ], @@ -253,66 +297,99 @@ "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": "https://ark.cn-beijing.volces.com/api/v3", "defaultModel": "deepseek-v3-2", + "defaultRequestFormat": "chat_completions", "kind": "llm", "labelKey": "ark", "providerType": "ark", "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], "validationProbe": "llm_text" }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": "https://api.deepseek.com/v1", "defaultModel": "deepseek-v4-flash", + "defaultRequestFormat": "chat_completions", "kind": "llm", "labelKey": "deepseek", "providerType": "deepseek", "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], "validationProbe": "llm_text" }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": "https://api.siliconflow.cn/v1", "defaultModel": "Qwen/Qwen2.5-7B-Instruct", + "defaultRequestFormat": "chat_completions", "kind": "llm", "labelKey": "siliconflow", "providerType": "siliconflow", "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], "validationProbe": "llm_text" }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": "https://api.atlascloud.ai/v1", "defaultModel": "qwen/qwen3.5-flash", + "defaultRequestFormat": "chat_completions", "kind": "llm", "labelKey": "atlascloud", "providerType": "atlascloud", "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], "validationProbe": "llm_text" }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": "https://api.openai.com/v1", "defaultModel": "gpt-4o", + "defaultRequestFormat": "chat_completions", "kind": "llm", "labelKey": "openai", "providerType": "openai", "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], "validationProbe": "llm_text" }, { "authRequirement": "api_key", "defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta", "defaultModel": "gemini-2.5-flash", + "defaultRequestFormat": null, "kind": "llm", "labelKey": "gemini", "providerType": "gemini", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "llm_text" }, { "authRequirement": "o_auth", "defaultEndpoint": null, "defaultModel": "gpt-5.5", + "defaultRequestFormat": null, "kind": "llm", "labelKey": "codexOAuth", "providerType": "codex_oauth", @@ -322,86 +399,183 @@ "gpt-5.4", "gpt-5.5" ], + "supportedRequestFormats": [], "validationProbe": "llm_text" }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": "https://api.xiaomimimo.com/v1", "defaultModel": "xiaomi/mimo-v2-flash", + "defaultRequestFormat": "chat_completions", "kind": "llm", "labelKey": "mimo", "providerType": "mimo", "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], "validationProbe": "llm_text" }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": "https://api.cometapi.com/v1", "defaultModel": "gpt-4o", + "defaultRequestFormat": "chat_completions", "kind": "llm", "labelKey": "cometapi", "providerType": "cometapi", "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], "validationProbe": "llm_text" }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": "https://openrouter.ai/api/v1", "defaultModel": "qwen/qwen3-coder:free", + "defaultRequestFormat": "chat_completions", "kind": "llm", "labelKey": "openrouterFree", "providerType": "openrouterFree", "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], "validationProbe": "llm_text" }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": "https://coding-intl.dashscope.aliyuncs.com/v1", "defaultModel": "qwen3-coder-plus", + "defaultRequestFormat": "chat_completions", "kind": "llm", "labelKey": "alibabaCoding", "providerType": "alibabaCoding", "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], "validationProbe": "llm_text" }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": "https://api.codingplanx.ai/v1", "defaultModel": "gpt-5-mini", + "defaultRequestFormat": "chat_completions", "kind": "llm", "labelKey": "codingPlanX", "providerType": "codingPlanX", "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], "validationProbe": "llm_text" }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": "https://api.minimaxi.com/v1", "defaultModel": "MiniMax-M3", + "defaultRequestFormat": "chat_completions", "kind": "llm", "labelKey": "minimax", "providerType": "minimax", "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], "validationProbe": "llm_text" }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": "https://api.stepfun.com/v1", "defaultModel": "step-1o-turbo-vision", + "defaultRequestFormat": "chat_completions", "kind": "llm", "labelKey": "stepfun", "providerType": "stepfun", "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], + "validationProbe": "llm_text" + }, + { + "authRequirement": "api_key_unless_custom_endpoint", + "defaultEndpoint": "https://opencode.ai/zen/v1", + "defaultModel": "deepseek-v4-flash", + "defaultRequestFormat": "chat_completions", + "kind": "llm", + "labelKey": "opencode", + "providerType": "opencode", + "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], "validationProbe": "llm_text" }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": null, "defaultModel": null, + "defaultRequestFormat": "chat_completions", "kind": "llm", - "labelKey": "custom", + "labelKey": "customChatCompletions", "providerType": "custom", "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], + "validationProbe": "llm_text" + }, + { + "authRequirement": "api_key_unless_custom_endpoint", + "defaultEndpoint": null, + "defaultModel": null, + "defaultRequestFormat": "responses", + "kind": "llm", + "labelKey": "customResponses", + "providerType": "custom_responses", + "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], + "validationProbe": "llm_text" + }, + { + "authRequirement": "api_key_unless_custom_endpoint", + "defaultEndpoint": null, + "defaultModel": null, + "defaultRequestFormat": "messages", + "kind": "llm", + "labelKey": "customMessages", + "providerType": "custom_messages", + "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], "validationProbe": "llm_text" } ], @@ -410,40 +584,48 @@ "authRequirement": "api_key", "defaultEndpoint": "https://api.openai.com/v1", "defaultModel": "gpt-4o-audio-preview", + "defaultRequestFormat": null, "kind": "omni", "labelKey": "omniOpenai", "providerType": "openai", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "omni_text" }, { "authRequirement": "api_key", "defaultEndpoint": "https://generativelanguage.googleapis.com/v1beta", "defaultModel": "gemini-2.5-flash", + "defaultRequestFormat": null, "kind": "omni", "labelKey": "omniGemini", "providerType": "gemini", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "omni_text" }, { "authRequirement": "api_key", "defaultEndpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1", "defaultModel": "qwen3-omni-flash", + "defaultRequestFormat": null, "kind": "omni", "labelKey": "omniDashscope", "providerType": "dashscope-omni", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "omni_text" }, { "authRequirement": "api_key", "defaultEndpoint": null, "defaultModel": null, + "defaultRequestFormat": null, "kind": "omni", "labelKey": "custom", "providerType": "custom", "staticModels": [], + "supportedRequestFormats": [], "validationProbe": "omni_text" } ] diff --git a/openless-all/app/src/lib/ipc/providers.ts b/openless-all/app/src/lib/ipc/providers.ts index 21ca4125..5f202a2d 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, isTauri } from './shared'; export type ProviderKind = 'asr' | 'llm' | 'omni'; +export type LlmRequestFormat = 'chat_completions' | 'responses' | 'messages'; export type AuthRequirement = | 'none' @@ -20,6 +21,8 @@ export interface ProviderDescriptor { authRequirement: AuthRequirement; validationProbe: string; staticModels: string[]; + defaultRequestFormat: LlmRequestFormat | null; + supportedRequestFormats: LlmRequestFormat[]; } /** Core owns protocol, defaults, and credential requirements. */ diff --git a/openless-all/app/src/lib/ipc/settings.ts b/openless-all/app/src/lib/ipc/settings.ts index 603d3631..32b3b455 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/lib/types.ts b/openless-all/app/src/lib/types.ts index fc6ec18b..f7c6dc89 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -245,8 +245,7 @@ export interface WindowsImeStatus { dllPath: string | null; } -/** 后台自动更新渠道。stable = 查正式版 manifest(默认);beta = 查 - * latest-android-{arch}-beta.json。手动「检查正式版/Beta 更新」按钮不受此字段影响。 */ +/** 后台自动更新渠道。未明确选择时跟随构建类型;手动检查按钮不受此字段影响。 */ export type UpdateChannel = 'stable' | 'beta'; export type ThemeMode = 'system' | 'light' | 'dark'; @@ -471,9 +470,11 @@ export interface UserPreferences { startMinimized: boolean; /** UI theme preference: follow OS, light, or dark. */ themeMode: ThemeMode; - /** 后台自动更新渠道。stable(默认)= AutoUpdateGate 查正式版 manifest; - * beta = 查 Beta manifest。About / Advanced 的手动检查按钮各自固定 stable/beta。 */ + /** 后台自动更新渠道。用户未明确选择时跟随当前构建类型; + * About / Advanced 的手动检查按钮各自固定 stable/beta。 */ updateChannel: UpdateChannel; + /** 是否由用户明确选择过更新渠道;缺失时由当前构建类型决定默认渠道。 */ + updateChannelExplicit?: boolean; /** 流式输入:润色 SSE 一边到达一边逐字模拟键盘事件输出到当前焦点。开启后用户感知到 * 的处理时延显著降低。v1 限定 macOS + OpenAI-compatible provider,其他配置自动回落 * 到原一次性插入。默认 true。 */ diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 3e322ed4..86565b36 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -63,6 +63,7 @@ const LLM_NAME_KEY_BY_ID: Record = { codingPlanX: 'codingPlanX', minimax: 'minimax', stepfun: 'stepfun', + opencode: 'opencode', custom: 'custom', }; diff --git a/openless-all/app/src/pages/settings/BetaChannelSection.tsx b/openless-all/app/src/pages/settings/BetaChannelSection.tsx index 7f706d18..d6985af7 100644 --- a/openless-all/app/src/pages/settings/BetaChannelSection.tsx +++ b/openless-all/app/src/pages/settings/BetaChannelSection.tsx @@ -18,6 +18,7 @@ import { CheckUpdateButton } from './CheckUpdateButton'; export function BetaChannelSection() { const { t } = useTranslation(); const [channel, setChannel] = useState('stable'); + const [autoCheckChannel, setAutoCheckChannel] = useState(null); const [platformCaps, setPlatformCaps] = useState(null); useEffect(() => { @@ -45,7 +46,9 @@ export function BetaChannelSection() { await setUpdateChannel(target); } catch { setChannel(target === 'beta' ? 'stable' : 'beta'); + return; } + setAutoCheckChannel(target); }; if (platformCaps?.supportsAutoUpdate !== true) return null; @@ -60,7 +63,7 @@ export function BetaChannelSection() {
- +
); diff --git a/openless-all/app/src/pages/settings/ChannelList.test.ts b/openless-all/app/src/pages/settings/ChannelList.test.ts index d26528bb..3a5c9e96 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 8b71c3f9..baa159bb 100644 --- a/openless-all/app/src/pages/settings/ChannelList.tsx +++ b/openless-all/app/src/pages/settings/ChannelList.tsx @@ -63,6 +63,8 @@ interface PresetOption { defaultModel?: string; authRequirement?: ProviderDescriptor['authRequirement']; staticModels?: string[]; + defaultRequestFormat?: ProviderDescriptor['defaultRequestFormat']; + supportedRequestFormats?: ProviderDescriptor['supportedRequestFormats']; } /** 「添加渠道」下拉里的供应商清单。本地引擎与 Codex OAuth 也在其中 —— 它们不是预置的 @@ -81,6 +83,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; @@ -905,6 +909,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 dialogRef = useRef(null); const nameId = useId(); @@ -1013,15 +1018,18 @@ 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); } }; @@ -1090,6 +1098,7 @@ function ChannelModal({ void changeProvider(next)} options={presets.map((p) => ({ value: p.id, @@ -1115,15 +1124,17 @@ function ChannelModal({ {/* 模型列表、供应商特有字段与验证结果都留在同一个滚动区。 */} - void onChanged()} - onUserMutation={onUserMutation} - /> + {!changingProvider && ( + void onChanged()} + onUserMutation={onUserMutation} + /> + )} {isLocalEngine && (

{t('settings.channels.localEngineModelHint')}

)} diff --git a/openless-all/app/src/pages/settings/CheckUpdateButton.tsx b/openless-all/app/src/pages/settings/CheckUpdateButton.tsx index bfea8b2b..ce6e7f88 100644 --- a/openless-all/app/src/pages/settings/CheckUpdateButton.tsx +++ b/openless-all/app/src/pages/settings/CheckUpdateButton.tsx @@ -11,9 +11,11 @@ import type { UpdateChannel } from '../../lib/ipc'; export function CheckUpdateButton({ channel, compact = false, + autoCheckChannel, }: { channel: UpdateChannel; compact?: boolean; + autoCheckChannel?: UpdateChannel | null; }) { const { t } = useTranslation(); const updater = useAutoUpdate(); @@ -40,6 +42,12 @@ export function CheckUpdateButton({ : 'settings.about.checkStableUpdateBtn'; const label = checking ? t('settings.about.checkingUpdate') : t(labelKey); + useEffect(() => { + if (autoCheckChannel) void updater.checkForUpdates(autoCheckChannel); + // checkForUpdates changes with updater state; this effect is driven only by a channel switch. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoCheckChannel]); + return ( <>