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({ <>