From 3cc69a1bc1642af5f8eb65bdb18b9fce2f694a80 Mon Sep 17 00:00:00 2001 From: lededev <30518126+lededev@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:10:35 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix=20(polish):=20LM=20Studio=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=AF=B9Gemma=204=2012B=E5=BC=80=E5=90=AF=E6=80=9D?= =?UTF-8?q?=E8=80=83=EF=BC=8C=E9=9C=80=E8=A6=81=E6=98=BE=E5=BC=8F=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E5=AE=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 摘要 通过 LM Studio 的 OpenAI 兼容接口接入 Gemma 4 12B 时,openless 默认「关闭思考」,但模型仍输出长思维链——UI 开关无效。根因是 LM Studio(llama.cpp 系服务)里 Gemma 4 等思考模型的 chat template 默认 enable_thinking=true:不下发参数不会关,必须显式下发关闭参数才生效;而现有 thinking 控制策略只按厂商域名关键字识别(minimax/deepseek/openrouter/dashscope/stepfun),本地服务无厂商域名,落入 None 分支一个 thinking 参数都不发,行为完全由模型侧默认值决定——恰好是「开」。 本 PR 新增 LmStudioThinking 策略:按 LM Studio 默认端口 1234(另登记 provider_id "lmstudio")识别该场景,显式下发 chat_template_kwargs.enable_thinking=false 关闭思考,并附两个 OpenAI 风格字段多重兜底。 修复 / 新增 / 改进 - 新增 ThinkingControl::LmStudioThinking:关闭时(openless 默认态)下发 chat_template_kwargs.enable_thinking=false + reasoning_effort="none" + reasoning.type="disabled" 三件套显式关掉 Gemma 4 12B 的默认思考(LM Studio/llama.cpp 均接受,多重兜底);用户手动开启时只显式发 enable_thinking=true,不附带后两个关闭字段。 - 识别逻辑:base_url 提取的 host 以 :1234 结尾即命中(LM Studio 默认服务端口,带冒号前缀匹配避免误伤 12345/12340 等端口);同时在已知列表登记 provider_id "lmstudio",将来若新增专属 preset 自动生效。 - doc 注释更新:openai_compatible_thinking_control_for_base_url 命中策略说明改为「厂商关键字 + 本地服务按端口」,并写明自定义端口无法自动识别的局限。 - 测试:4 个新回归用例(默认端口关闭时三字段齐全、开启时仅 enable_thinking=true、尾斜杠//v1 后缀/LAN IP 变体均命中、端口 12345 不误命中),另在既有 unknown-provider 用例补断言 chat_template_kwargs 不存在,防新策略误触发。 - 未改动:各厂商(MiniMax/DeepSeek/OpenRouter/DashScope/StepFun)thinking 参数逻辑、chat body 结构、超时预算。 兼容 - 不包含:不新增 "lmstudio" preset;用户修改 LM Studio 端口后无法自动识别(已写入注释),此时需自行确认模型侧思考默认值。 - 对现有用户 / 本地环境 / 构建流程的影响:无。仅对命中 :1234 的请求多带三个 thinking 控制字段,非思考模型忽略即可;其余 provider 行为不变。 - 改动量:单文件 openless-all/app/src-tauri/src/polish.rs,生产代码净增约 24 行(含注释/文档),测试净增 87 行。 测试计划 - 命令:cargo test --manifest-path src-tauri/Cargo.toml --lib(在 openless-all/app 下运行) - 结果:5/5通过 - 反向验证:移除 :1234 分支后默认端口及变体用例如期失败;把关闭态三字段减为仅 enable_thinking 则三字段断言失败;若匹配放宽到不含冒号前缀,12345 防误伤用例即被打破 - 证据路径:本地终端输出,未落盘到仓库 --- openless-all/app/src-tauri/src/polish.rs | 113 ++++++++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index d868641fc..52192d81c 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -1860,6 +1860,19 @@ pub(crate) fn apply_openai_compatible_thinking_control( "type": if thinking_enabled { "adaptive" } else { "disabled" }, }); } + // LM Studio(llama.cpp 系本地服务,默认端口 1234):Gemma 4 等思考模型的 + // chat template 默认 enable_thinking=true,不显式下发关闭参数时 openless + // 「关闭思考」设置无效。关闭时在 `chat_template_kwargs.enable_thinking=false` + // 之外同时下发 OpenAI 风格 `reasoning_effort="none"` / + // `reasoning.type="disabled"`(LM Studio 均接受,多重兜底);开启时只显式 + // 发 enable_thinking=true,不附带后两个字段。 + Some(ThinkingControl::LmStudioThinking) => { + body["chat_template_kwargs"] = json!({ "enable_thinking": thinking_enabled }); + if !thinking_enabled { + body["reasoning_effort"] = json!("none"); + body["reasoning"] = json!({ "type": "disabled" }); + } + } None => {} } } @@ -1871,6 +1884,7 @@ pub(crate) enum ThinkingControl { OpenRouterReasoning, DeepSeekThinking, MiniMaxThinking, + LmStudioThinking, } pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option { @@ -1883,6 +1897,10 @@ pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option Some(ThinkingControl::ReasoningEffort), + // LM Studio:本地服务无厂商域名可匹配,主要靠 base_url 兜底按默认端口 + // 1234 识别(见下方 for_base_url);这里登记 provider_id 以便将来若新增 + // "lmstudio" preset 时自动生效。 + "lmstudio" => Some(ThinkingControl::LmStudioThinking), // custom / 其他未声明 provider 走 base_url 兜底识别——用户用自定义 // endpoint 接入 MiniMax 时,根据 base_url 命中即下发官方 thinking 参数。 _ => None, @@ -1893,7 +1911,8 @@ pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option Option { @@ -1923,6 +1942,11 @@ pub(crate) fn openai_compatible_thinking_control_for_base_url( if host.contains("stepfun") { return Some(ThinkingControl::ReasoningEffort); } + // LM Studio 默认服务端口是 1234(host 提取结果形如 "localhost:1234", + // 带 ":" 前缀匹配可避免误伤 12345 等其它端口)。 + if host.ends_with(":1234") { + return Some(ThinkingControl::LmStudioThinking); + } None } @@ -3743,6 +3767,92 @@ mod tests { assert_eq!(body["reasoning_effort"], "low"); } + #[test] + fn openai_chat_body_disables_lmstudio_thinking_by_default_port() { + // LM Studio 默认端口 1234 + "custom" preset:Gemma 4 等思考模型的 chat + // template 默认 enable_thinking=true,openless 默认「关闭思考」时必须 + // 显式下发关闭参数,否则 UI 开关无效。三个字段与实测可用的请求一致。 + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "custom", + "Custom", + "http://localhost:1234/v1", + "lm-studio", + "google/gemma-4-12b-qat", + ) + .with_thinking_enabled(false), + ); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["chat_template_kwargs"]["enable_thinking"], false); + assert_eq!(body["reasoning_effort"], "none"); + assert_eq!(body["reasoning"]["type"], "disabled"); + } + + #[test] + fn openai_chat_body_enables_lmstudio_thinking_explicitly() { + // 开启思考时只显式下发 enable_thinking=true,不附带关闭用的两个字段。 + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "custom", + "Custom", + "http://localhost:1234/v1", + "lm-studio", + "google/gemma-4-12b-qat", + ) + .with_thinking_enabled(true), + ); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert_eq!(body["chat_template_kwargs"]["enable_thinking"], true); + assert!(body.get("reasoning_effort").is_none()); + assert!(body.get("reasoning").is_none()); + } + + #[test] + fn openai_chat_body_lmstudio_fallback_handles_slash_path_and_lan_ip() { + // base_url 尾斜杠 / /v1 后缀 / LAN IP 接入都要能命中端口识别。 + for base_url in [ + "http://localhost:1234/v1", + "http://localhost:1234/v1/", + "http://127.0.0.1:1234", + "http://192.168.1.50:1234/v1", + ] { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new("custom", "Custom", base_url, "lm-studio", "m") + .with_thinking_enabled(false), + ); + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + assert_eq!( + body["chat_template_kwargs"]["enable_thinking"], false, + "base_url={base_url} should trigger LM Studio thinking control" + ); + } + } + + #[test] + fn openai_chat_body_omits_lmstudio_control_for_other_local_ports() { + // 端口 12345 等不能误命中 LM Studio 默认端口 1234。 + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new( + "custom", + "Custom", + "http://localhost:12345/v1", + "lm-studio", + "m", + ) + .with_thinking_enabled(false), + ); + + let body = provider.chat_body(false, vec![json!({ "role": "user", "content": "hi" })]); + + assert!(body.get("chat_template_kwargs").is_none()); + assert!(body.get("reasoning_effort").is_none()); + assert!(body.get("reasoning").is_none()); + } + #[test] fn openai_chat_body_omits_thinking_control_for_unknown_provider() { let provider = OpenAICompatibleLLMProvider::new( @@ -3761,6 +3871,7 @@ mod tests { assert!(body.get("reasoning_effort").is_none()); assert!(body.get("enable_thinking").is_none()); assert!(body.get("reasoning").is_none()); + assert!(body.get("chat_template_kwargs").is_none()); } #[test] From f3fc84da645c5e10e4a329189a33bc1ed77b772f Mon Sep 17 00:00:00 2001 From: Chris233 Date: Sun, 13 Sep 2026 19:09:15 +0800 Subject: [PATCH 2/2] fix(llm): control LM Studio thinking through an explicit preset --- .../openless-core/src/cloud_providers.rs | 13 ++ .../crates/openless-core/src/llm_protocol.rs | 25 ++- .../app/crates/openless-core/src/polish.rs | 98 +++++++++- .../openless-core/src/provider_rules.rs | 31 +++ .../openless-core/src/provider_service.rs | 181 +++++++++++++++++- openless-all/app/src/i18n/de.ts | 2 + openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/es.ts | 2 + openless-all/app/src/i18n/fr.ts | 2 + openless-all/app/src/i18n/ja.ts | 2 + openless-all/app/src/i18n/ko.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + .../lib/ipc/mock-provider-descriptors.json | 12 ++ .../ipc/provider-descriptors.generated.json | 12 ++ .../app/src/lib/ipc/providers.test.ts | 15 ++ .../pages/settings/LlmProtocolFields.test.ts | 31 +++ .../src/pages/settings/ProvidersSection.tsx | 7 +- 18 files changed, 415 insertions(+), 26 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/cloud_providers.rs b/openless-all/app/crates/openless-core/src/cloud_providers.rs index d997cbe4c..f2470a11a 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -84,6 +84,7 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[ "stepfun", "opencode", "tencentTokenHub", + "lmstudio", "custom", "custom_responses", "custom_messages", @@ -2562,6 +2563,18 @@ mod tests { } } + #[tokio::test] + async fn lmstudio_generation_still_requires_a_model() { + let context = DictationContext { + llm: ProviderInvocation::new("lmstudio-channel", "lmstudio"), + ..DictationContext::default() + }; + match build_cloud_polisher_provider(&InMemoryCredentialStore::default(), &context).await { + Err(error) => assert_eq!(error.message, "LLM model is not configured"), + Ok(_) => panic!("LM Studio must not generate without a selected model"), + } + } + #[tokio::test] async fn cloud_asr_rejects_unknown_protocol_instead_of_falling_back_to_volcengine() { let credentials: Arc = Arc::new(InMemoryCredentialStore::default()); diff --git a/openless-all/app/crates/openless-core/src/llm_protocol.rs b/openless-all/app/crates/openless-core/src/llm_protocol.rs index 47ebd14f6..3ba509524 100644 --- a/openless-all/app/crates/openless-core/src/llm_protocol.rs +++ b/openless-all/app/crates/openless-core/src/llm_protocol.rs @@ -39,7 +39,10 @@ impl LlmRequestFormat { } pub fn selectable(provider: &str) -> bool { - !matches!(provider, "gemini" | "codex_oauth" | "tencentTokenHub") + !matches!( + provider, + "gemini" | "codex_oauth" | "tencentTokenHub" | "lmstudio" + ) } pub fn parse(value: &str) -> Result { @@ -544,7 +547,7 @@ mod tests { } #[tokio::test] - async fn tokenhub_is_fixed_to_chat_completions() { + async fn tokenhub_and_lmstudio_are_fixed_to_chat_completions() { let store = InMemoryCredentialStore::default(); store .write( @@ -559,14 +562,16 @@ mod tests { .await .unwrap(); - assert!(!LlmRequestFormat::selectable("tencentTokenHub")); - assert_eq!( - LlmProtocolConfig::load(&store, "tokenhub", "tencentTokenHub") - .await - .unwrap() - .format, - LlmRequestFormat::ChatCompletions - ); + for provider in ["tencentTokenHub", "lmstudio"] { + assert!(!LlmRequestFormat::selectable(provider)); + assert_eq!( + LlmProtocolConfig::load(&store, "tokenhub", provider) + .await + .unwrap() + .format, + LlmRequestFormat::ChatCompletions + ); + } } #[test] diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 8836bfd90..f119e3131 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -1869,6 +1869,14 @@ pub(crate) fn apply_openai_compatible_thinking_control( "type": if thinking_enabled { "adaptive" } else { "disabled" }, }); } + // 仅显式选择 LM Studio 预设时下发,不根据地址或端口推断本地服务。 + Some(ThinkingControl::LmStudioThinking) => { + body["chat_template_kwargs"] = json!({ "enable_thinking": thinking_enabled }); + if !thinking_enabled { + body["reasoning_effort"] = json!("none"); + body["reasoning"] = json!({ "type": "disabled" }); + } + } None => {} } } @@ -1905,10 +1913,12 @@ pub(crate) enum ThinkingControl { OpenRouterReasoning, DeepSeekThinking, MiniMaxThinking, + LmStudioThinking, } pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option { match provider_id.trim() { + "lmstudio" => Some(ThinkingControl::LmStudioThinking), "deepseek" => Some(ThinkingControl::DeepSeekThinking), // provider_id 预设(见 ProvidersSection.tsx::LLM_PRESETS)。 "minimax" => Some(ThinkingControl::MiniMaxThinking), @@ -2331,14 +2341,28 @@ mod tests { #[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)) - }) { + for (format, preset, prefix, thinking_enabled, api_key) in LlmRequestFormat::ALL + .into_iter() + .flat_map(|format| { + [ + ("custom", "/gateway/v1"), + ("opencode", "/zen/v1"), + ("opencode", "/zen/go/v1"), + ] + .map(|(preset, prefix)| (format, preset, prefix, false, "fixture-key")) + }) + .chain([false, true].into_iter().flat_map(|enabled| { + ["", "fixture-key"].map(|key| { + ( + LlmRequestFormat::ChatCompletions, + "lmstudio", + "/gateway/v1", + enabled, + key, + ) + }) + })) + { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let server = thread::spawn(move || { @@ -2365,7 +2389,26 @@ mod tests { 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_eq!( + headers.contains("authorization: bearer fixture-key"), + !api_key.is_empty() + ); + if api_key.is_empty() { + assert!(!headers.contains("authorization:")); + } + } + if preset == "lmstudio" { + assert_eq!( + body["chat_template_kwargs"]["enable_thinking"], + thinking_enabled + ); + if thinking_enabled { + assert!(body.get("reasoning_effort").is_none()); + assert!(body.get("reasoning").is_none()); + } else { + assert_eq!(body["reasoning_effort"], "none"); + assert_eq!(body["reasoning"]["type"], "disabled"); + } } assert!(!headers.contains("chatgpt-account-id")); let messages = if format == LlmRequestFormat::Responses { @@ -2410,10 +2453,11 @@ mod tests { preset, "test", format!("http://{address}{prefix}/chat/completions?tenant=1"), - "fixture-key", + api_key, "test", ) .with_temperature(Some(0.7)) + .with_thinking_enabled(thinking_enabled) .with_protocol(LlmProtocolConfig { format, ..Default::default() @@ -3629,6 +3673,39 @@ mod tests { assert_eq!(body["reasoning_effort"], "low"); } + #[test] + fn lmstudio_thinking_control_uses_only_the_preset() { + for endpoint in [ + "http://localhost:1234/v1", + "http://127.0.0.1:8080/v1/", + "http://192.168.1.50:12345/v1", + "https://gateway.example/v1", + ] { + for enabled in [false, true] { + for preset in ["lmstudio", "custom"] { + let provider = OpenAICompatibleLLMProvider::new( + OpenAICompatibleConfig::new(preset, preset, endpoint, "", "model") + .with_thinking_enabled(enabled), + ); + let body = + provider.chat_body(false, vec![json!({"role": "user", "content": "hi"})]); + if preset == "lmstudio" { + assert_eq!(body["chat_template_kwargs"]["enable_thinking"], enabled); + if !enabled { + assert_eq!(body["reasoning_effort"], "none"); + assert_eq!(body["reasoning"]["type"], "disabled"); + continue; + } + } else { + assert!(body.get("chat_template_kwargs").is_none()); + } + assert!(body.get("reasoning_effort").is_none()); + assert!(body.get("reasoning").is_none()); + } + } + } + } + #[test] fn openai_chat_body_omits_thinking_control_for_unknown_provider() { let provider = OpenAICompatibleLLMProvider::new( @@ -3647,6 +3724,7 @@ mod tests { assert!(body.get("reasoning_effort").is_none()); assert!(body.get("enable_thinking").is_none()); assert!(body.get("reasoning").is_none()); + assert!(body.get("chat_template_kwargs").is_none()); } #[test] 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 e5ed5db38..7ed16b35f 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -68,6 +68,7 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[ ("stepfun", "stepfun"), ("opencode", "opencode"), ("tencentTokenHub", "tencentTokenHub"), + ("lmstudio", "lmstudio"), ("custom", "customChatCompletions"), ("custom_responses", "customResponses"), ("custom_messages", "customMessages"), @@ -256,6 +257,7 @@ fn provider_descriptor_with_label( match id.as_str() { crate::polish::CODEX_OAUTH_PROVIDER_ID => AuthRequirement::OAuth, "gemini" => AuthRequirement::ApiKey, + "lmstudio" => AuthRequirement::EndpointModelOptionalApiKey, _ => AuthRequirement::ApiKeyUnlessCustomEndpoint, }, ValidationProbe::LlmText, @@ -601,6 +603,7 @@ pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> { "minimax" => Some("https://api.minimaxi.com/v1"), "stepfun" => Some("https://api.stepfun.com/v1"), "tencentTokenHub" => Some("https://tokenhub.tencentmaas.com/v1"), + "lmstudio" => Some("http://localhost:1234/v1"), _ => None, } } @@ -1396,6 +1399,34 @@ mod tests { assert_eq!(fixture, actual); } + #[test] + fn lmstudio_requires_a_model_but_not_an_api_key() { + let descriptor = provider_descriptor(ProviderKind::Llm, "lmstudio").unwrap(); + assert_eq!( + descriptor.default_endpoint.as_deref(), + Some("http://localhost:1234/v1") + ); + assert!(descriptor.default_model.is_none()); + assert_eq!( + descriptor.auth_requirement, + AuthRequirement::EndpointModelOptionalApiKey + ); + assert_eq!(descriptor.validation_probe, ValidationProbe::LlmText); + assert!(crate::cloud_providers::SHARED_CLOUD_LLM_PROVIDER_TYPES.contains(&"lmstudio")); + assert!(provider_descriptor(ProviderKind::Omni, "lmstudio").is_none()); + for endpoint in [ + None, + Some("http://localhost:1234/v1"), + Some("https://gateway.example/v1"), + ] { + assert!(!api_key_required(ProviderKind::Llm, "lmstudio", endpoint)); + } + let mut configuration = CredentialConfiguration::default(); + assert!(!llm_configured("lmstudio", &configuration)); + configuration.llm_model = true; + assert!(llm_configured("lmstudio", &configuration)); + } + #[test] fn secret_like_volc_resource_ids_are_not_attributed() { assert_eq!( 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 997c4c6b5..47ab7db4b 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -227,7 +227,7 @@ impl ProviderService { return Err(cancelled_request()); } ensure_supported_kind(&resolved)?; - validate_configuration(&resolved)?; + validate_configuration(&resolved, true)?; let probe = validation_probe_for( resolved.kind, &resolved.provider_type, @@ -333,7 +333,7 @@ impl ProviderService { self.validate_resolved(resolved, cancellation).await?; return Ok(ProviderModelsResult { models }); } - validate_configuration(&resolved)?; + validate_configuration(&resolved, false)?; let models = fetch_models(&resolved, Arc::clone(&self.transport), cancellation).await?; Ok(ProviderModelsResult { models }) } @@ -437,7 +437,10 @@ fn ensure_supported_kind(resolved: &ResolvedProvider) -> Result<(), BackendError } } -fn validate_configuration(resolved: &ResolvedProvider) -> Result<(), BackendError> { +fn validate_configuration( + resolved: &ResolvedProvider, + require_model: bool, +) -> Result<(), BackendError> { let descriptor = provider_descriptor(resolved.kind, &resolved.provider_type) .ok_or_else(|| provider_error("provider descriptor is not configured"))?; let api_key = resolved.api_key.as_deref().unwrap_or_default(); @@ -463,7 +466,8 @@ fn validate_configuration(resolved: &ResolvedProvider) -> Result<(), BackendErro .as_deref() .filter(|value| !value.trim().is_empty()) .or(descriptor.default_model.as_deref()); - if model.is_none() + if require_model + && model.is_none() && !matches!( descriptor.auth_requirement, AuthRequirement::None @@ -1023,7 +1027,7 @@ mod tests { }) .await .unwrap(); - let result = validate_configuration(&resolved); + let result = validate_configuration(&resolved, true); if !endpoint.starts_with("http://127.0.0.1") && key.is_none_or(|value| value.trim().is_empty()) { @@ -1037,6 +1041,173 @@ mod tests { } } + #[tokio::test] + async fn lmstudio_model_listing_allows_an_empty_model_and_optional_key() { + for api_key in ["", "fixture-key"] { + 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, + "lmstudio", + &[ + (LLM_ENDPOINT_ACCOUNT, &endpoint), + (LLM_API_KEY_ACCOUNT, api_key), + ], + ) + .await; + let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)); + let parameters = ProviderRequest { + kind: ProviderKind::Llm, + channel_id: Some(channel), + thinking_enabled: false, + }; + assert_eq!( + service + .list_models(parameters.clone()) + .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 ")); + assert_eq!( + request.contains("authorization: bearer fixture-key"), + !api_key.is_empty() + ); + if api_key.is_empty() { + assert!(!request.contains("authorization:")); + } + assert_eq!( + service.validate(parameters).await.unwrap_err().message, + "provider model is not configured" + ); + } + + // Listing skips only the model requirement, not endpoint or authentication checks. + for (preset, endpoint, expected) in [ + ("lmstudio", "file:///models", "provider endpoint is invalid"), + ( + "openai", + "https://api.openai.com/v1", + "LLM API key is not configured", + ), + ] { + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = create_channel_with_values( + &credentials, + ChannelKind::Llm, + preset, + &[(LLM_ENDPOINT_ACCOUNT, endpoint)], + ) + .await; + let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)); + let error = service + .list_models(ProviderRequest { + kind: ProviderKind::Llm, + channel_id: Some(channel), + thinking_enabled: false, + }) + .await + .unwrap_err(); + assert_eq!(error.message, expected); + } + } + + #[tokio::test] + async fn lmstudio_validation_preserves_channel_values_and_uses_its_thinking_control() { + for enabled in [false, true] { + for api_key in ["", "fixture-key"] { + let (endpoint, request) = spawn_http_response( + "200 OK", + "text/event-stream", + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n", + ); + let credentials = Arc::new(InMemoryCredentialStore::default()); + let values = [ + (LLM_ENDPOINT_ACCOUNT, endpoint.as_str()), + (LLM_MODEL_ACCOUNT, "test-model"), + (LLM_API_KEY_ACCOUNT, api_key), + ]; + let channel = create_channel_with_values( + &credentials, + ChannelKind::Llm, + "custom_responses", + &values, + ) + .await; + credentials + .mutate_channel(ChannelMutation::SetProviderType { + kind: ChannelKind::Llm, + id: channel.clone(), + provider_type: "lmstudio".into(), + }) + .await + .unwrap(); + // Even a stale format written after the switch must not override the fixed protocol. + credentials + .write( + CredentialKey::new( + CredentialNamespace::Llm, + Some(channel.clone()), + crate::llm_protocol::REQUEST_FORMAT_ACCOUNT, + ) + .unwrap(), + SecretValue::new("messages"), + ) + .await + .unwrap(); + let service = + ProviderService::new(credentials.clone(), Arc::new(crate::TokioTaskSpawner)); + for (account, value) in values { + assert_eq!( + service + .read(CredentialNamespace::Llm, &channel, account) + .await + .unwrap() + .as_deref(), + Some(value) + ); + } + assert_eq!( + credentials.list_channels(ChannelKind::Llm).await.unwrap()[0].provider_type, + "lmstudio" + ); + service + .validate(ProviderRequest { + kind: ProviderKind::Llm, + channel_id: Some(channel), + thinking_enabled: enabled, + }) + .await + .unwrap(); + let request = + String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()) + .unwrap(); + assert!(request.starts_with("POST /v1/chat/completions ")); + let (headers, body) = request.split_once("\r\n\r\n").unwrap(); + assert_eq!( + headers.to_ascii_lowercase().contains("authorization:"), + !api_key.is_empty() + ); + let body: serde_json::Value = serde_json::from_str(body).unwrap(); + assert_eq!(body["model"], "test-model"); + assert_eq!(body["chat_template_kwargs"]["enable_thinking"], enabled); + if enabled { + assert!(body.get("reasoning_effort").is_none()); + assert!(body.get("reasoning").is_none()); + } else { + assert_eq!(body["reasoning_effort"], "none"); + assert_eq!(body["reasoning"]["type"], "disabled"); + } + } + } + } + #[tokio::test] async fn validation_and_model_lists_use_channel_protocol_and_thinking() { use crate::llm_protocol::*; diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 970f007b8..1a08f208b 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1287,6 +1287,7 @@ export const de: typeof zhCN = { pipelineIsolationNotice: 'Die beiden Modi speichern ihre Zugangsdaten vollständig getrennt. Beim Wechsel bleiben die Daten des anderen Modus gespeichert und werden beim Zurückwechseln wieder verwendet.', presets: { + lmstudio: 'LM Studio', opencode: 'OpenCode Zen', tencentTokenHub: 'Tencent Cloud TokenHub', customChatCompletions: 'Benutzerdefiniert · Chat Completions', @@ -1399,6 +1400,7 @@ export const de: typeof zhCN = { fillDefault: 'Standardwert eintragen', readFailed: 'Lesen fehlgeschlagen', apiKeyLabel: 'API-Schlüssel', + apiKeyOptionalLabel: 'API-Schlüssel (optional)', baseUrlLabel: 'Basis-URL', modelLabel: 'Modell', customModelLabel: 'Eigenes Modell…', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 0faa7f173..0f9dd26c0 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1271,6 +1271,7 @@ export const en: typeof zhCN = { stepfun: 'StepFun', opencode: 'OpenCode Zen', tencentTokenHub: 'Tencent Cloud TokenHub', + lmstudio: 'LM Studio', customChatCompletions: 'Custom · Chat Completions', customResponses: 'Custom · Responses', customMessages: 'Custom · Messages', @@ -1364,6 +1365,7 @@ export const en: typeof zhCN = { fillDefault: 'Fill default value', readFailed: 'Read failed', apiKeyLabel: 'API Key', + apiKeyOptionalLabel: 'API Key (optional)', baseUrlLabel: 'Base URL', modelLabel: 'Model', customModelLabel: 'Custom model\u2026', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index d0fd0ba3f..75772b22a 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1279,6 +1279,7 @@ export const es: typeof zhCN = { pipelineIsolationNotice: 'Cada modo conserva sus propias credenciales. Al cambiar, las del otro modo se guardan sin usarse y se restauran cuando vuelves.', presets: { + lmstudio: 'LM Studio', opencode: 'OpenCode Zen', tencentTokenHub: 'TokenHub de Tencent Cloud', customChatCompletions: 'Personalizado · Chat Completions', @@ -1390,6 +1391,7 @@ export const es: typeof zhCN = { fillDefault: 'Usar valor predeterminado', readFailed: 'No se pudo leer', apiKeyLabel: 'Clave API', + apiKeyOptionalLabel: 'Clave API (opcional)', baseUrlLabel: 'URL base', modelLabel: 'Modelo', customModelLabel: 'Modelo personalizado…', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 2228695ee..10d526bba 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1296,6 +1296,7 @@ export const fr: typeof zhCN = { pipelineIsolationNotice: 'Les deux modes conservent des identifiants distincts. Changer de mode garde l’autre configuration sans l’utiliser ; elle est restaurée à votre retour.', presets: { + lmstudio: 'LM Studio', opencode: 'OpenCode Zen', tencentTokenHub: 'TokenHub Tencent Cloud', customChatCompletions: 'Personnalisé · Chat Completions', @@ -1407,6 +1408,7 @@ export const fr: typeof zhCN = { fillDefault: 'Renseigner la valeur par défaut', readFailed: 'Échec de la lecture', apiKeyLabel: 'Clé API', + apiKeyOptionalLabel: 'Clé API (facultative)', baseUrlLabel: 'URL de base', modelLabel: 'Modèle', customModelLabel: 'Modèle personnalisé…', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 96a6e34db..bcaddb5a5 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1241,6 +1241,7 @@ export const ja: typeof zhCN = { pipelineIsolationNotice: '2つのモードは完全に独立した認証情報を使用します。切り替えてももう一方の設定は削除されず、切り戻せば復元されます。', presets: { + lmstudio: 'LM Studio', ark: 'ARK(Volcengine Ark)', deepseek: 'DeepSeek', siliconflow: 'SiliconFlow', @@ -1351,6 +1352,7 @@ export const ja: typeof zhCN = { fillDefault: 'デフォルト値を入力', readFailed: '読み込み失敗', apiKeyLabel: 'API キー', + apiKeyOptionalLabel: 'API キー(任意)', baseUrlLabel: 'エンドポイント', modelLabel: 'モデル', customModelLabel: 'カスタムモデル…', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index cfde12b03..28c4e14c1 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1233,6 +1233,7 @@ export const ko: typeof zhCN = { pipelineIsolationNotice: '두 모드는 완전히 분리된 자격 증명을 사용합니다. 전환해도 다른 쪽 설정은 삭제되지 않으며, 다시 전환하면 복원됩니다.', presets: { + lmstudio: 'LM Studio', ark: 'ARK (Volcengine Ark)', deepseek: 'DeepSeek', siliconflow: 'SiliconFlow', @@ -1342,6 +1343,7 @@ export const ko: typeof zhCN = { fillDefault: '기본값 입력', readFailed: '읽기 실패', apiKeyLabel: 'API 키', + apiKeyOptionalLabel: 'API 키 (선택 사항)', baseUrlLabel: '엔드포인트', modelLabel: '모델', customModelLabel: '사용자 정의 모델…', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 171cf74a9..3a6a0107f 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1210,6 +1210,7 @@ export const zhCN = { stepfun: 'StepFun(阶跃星辰)', opencode: 'OpenCode Zen', tencentTokenHub: '腾讯云 TokenHub', + lmstudio: 'LM Studio', customChatCompletions: '自定义 · Chat Completions', customResponses: '自定义 · Responses', customMessages: '自定义 · Messages', @@ -1298,6 +1299,7 @@ export const zhCN = { fillDefault: '填入默认值', readFailed: '读取失败', apiKeyLabel: 'API 密钥', + apiKeyOptionalLabel: 'API 密钥(可选)', baseUrlLabel: '接口地址', modelLabel: '模型', customModelLabel: '自定义模型…', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 8489eb853..a3712e30e 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1195,6 +1195,7 @@ export const zhTW: typeof zhCN = { pipelineIsolationNotice: '兩種模式使用完全獨立的憑證設定。切換模式不會刪除另一套設定,只是暫時停用;切回即恢復。', presets: { + lmstudio: 'LM Studio', ark: 'ARK(火山方舟)', deepseek: 'DeepSeek', siliconflow: '硅基流動', @@ -1300,6 +1301,7 @@ export const zhTW: typeof zhCN = { fillDefault: '填入默認值', readFailed: '讀取失敗', apiKeyLabel: 'API 密鑰', + apiKeyOptionalLabel: 'API 密鑰(選填)', baseUrlLabel: '接口地址', modelLabel: '模型', customModelLabel: '自訂模型…', diff --git a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json index 5d0559a39..d47ce5360 100644 --- a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json +++ b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json @@ -215,6 +215,18 @@ "defaultRequestFormat": null, "supportedRequestFormats": [] }, + { + "kind": "llm", + "providerType": "lmstudio", + "labelKey": "lmstudio", + "defaultEndpoint": "http://localhost:1234/v1", + "defaultModel": null, + "authRequirement": "endpoint_model_optional_api_key", + "validationProbe": "llm_text", + "staticModels": [], + "defaultRequestFormat": null, + "supportedRequestFormats": [] + }, { "kind": "llm", "providerType": "custom", 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 311745856..dda36caaa 100644 --- a/openless-all/app/src/lib/ipc/provider-descriptors.generated.json +++ b/openless-all/app/src/lib/ipc/provider-descriptors.generated.json @@ -558,6 +558,18 @@ ], "validationProbe": "llm_text" }, + { + "authRequirement": "endpoint_model_optional_api_key", + "defaultEndpoint": "http://localhost:1234/v1", + "defaultModel": null, + "defaultRequestFormat": null, + "kind": "llm", + "labelKey": "lmstudio", + "providerType": "lmstudio", + "staticModels": [], + "supportedRequestFormats": [], + "validationProbe": "llm_text" + }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": null, diff --git a/openless-all/app/src/lib/ipc/providers.test.ts b/openless-all/app/src/lib/ipc/providers.test.ts index ab529d79c..aa4c46804 100644 --- a/openless-all/app/src/lib/ipc/providers.test.ts +++ b/openless-all/app/src/lib/ipc/providers.test.ts @@ -1,4 +1,19 @@ import { listProviderDescriptors, type ProviderKind } from './providers'; +import generated from './provider-descriptors.generated.json'; + +const preview = generated.llm.find((descriptor) => descriptor.providerType === 'lmstudio'); +const lmstudio = (await listProviderDescriptors('llm')).find( + (descriptor) => descriptor.providerType === 'lmstudio', +); +if ( + !preview || + !lmstudio || + Object.entries(lmstudio).some( + ([key, value]) => + JSON.stringify(value) !== JSON.stringify(preview[key as keyof typeof preview]), + ) +) + throw new Error('LM Studio preview snapshots must agree'); for (const kind of ['asr', 'llm', 'omni'] as ProviderKind[]) { const descriptors = await listProviderDescriptors(kind); diff --git a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts index 4fe2dc9df..3a913a35f 100644 --- a/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts +++ b/openless-all/app/src/pages/settings/LlmProtocolFields.test.ts @@ -81,6 +81,20 @@ for (const id of ['opencode', 'custom', 'custom_responses', 'custom_messages']) ); } const tokenhub = presets.find((p) => p.id === 'tencentTokenHub'); +const lmstudio = presets.find((p) => p.id === 'lmstudio'); +assert( + lmstudio && + lmstudio.defaultEndpoint === 'http://localhost:1234/v1' && + lmstudio.defaultModel === undefined && + lmstudio.authRequirement === 'endpoint_model_optional_api_key' && + lmstudio.defaultRequestFormat == null && + lmstudio.supportedRequestFormats?.length === 0, + 'LM Studio must allow a custom endpoint and optional key with fixed Chat Completions', +); +assert( + !(await listProviderDescriptors('omni')).some((item) => item.providerType === 'lmstudio'), + 'LM Studio preset is limited to LLM channels', +); assert( tokenhub && tokenhub.defaultRequestFormat == null && @@ -144,6 +158,23 @@ assert( (await readCredential('ark.api_key', first)) === 'fixture-key', 'Changing preset preserves the key', ); +await setCredential('ark.request_format', 'responses', first); +await setChannelProviderType('llm', first, 'lmstudio'); +assert( + (await listChannels('llm')).find((channel) => channel.id === first)?.providerType === 'lmstudio', + 'LM Studio selection must survive reopening the channel', +); +for (const [account, expected] of [ + ['ark.endpoint', 'https://opencode.ai/zen/go/v1'], + ['ark.model_id', 'new-model'], + ['ark.api_key', 'fixture-key'], + ['ark.request_format', null], +] as const) { + assert( + (await readCredential(account, first)) === expected, + `Switching to LM Studio must preserve credentials and clear the protocol override: ${account}`, + ); +} await recordChannelTest('llm', first, true, 1, null); const settings = await getSettings(); const asrTestAt = (await listChannels('asr'))[0].lastTest?.at; diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 6c9c54f34..a9caf8ca3 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -144,6 +144,7 @@ export const LLM_LABELS = [ ['stepfun', 'stepfun'], ['opencode', 'opencode'], ['tencentTokenHub', 'tencentTokenHub'], + ['lmstudio', 'lmstudio'], ['custom', 'customChatCompletions'], ['custom_responses', 'customResponses'], ['custom_messages', 'customMessages'], @@ -338,7 +339,11 @@ export function ChannelCredentialFields({ <>