Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions openless-all/app/crates/openless-core/src/cloud_providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[
"stepfun",
"opencode",
"tencentTokenHub",
"lmstudio",
"custom",
"custom_responses",
"custom_messages",
Expand Down Expand Up @@ -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<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::default());
Expand Down
25 changes: 15 additions & 10 deletions openless-all/app/crates/openless-core/src/llm_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, BackendError> {
Expand Down Expand Up @@ -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(
Expand All @@ -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]
Expand Down
98 changes: 88 additions & 10 deletions openless-all/app/crates/openless-core/src/polish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {}
}
}
Expand Down Expand Up @@ -1905,10 +1913,12 @@ pub(crate) enum ThinkingControl {
OpenRouterReasoning,
DeepSeekThinking,
MiniMaxThinking,
LmStudioThinking,
}

pub(crate) fn openai_compatible_thinking_control(provider_id: &str) -> Option<ThinkingControl> {
match provider_id.trim() {
"lmstudio" => Some(ThinkingControl::LmStudioThinking),
"deepseek" => Some(ThinkingControl::DeepSeekThinking),
// provider_id 预设(见 ProvidersSection.tsx::LLM_PRESETS)。
"minimax" => Some(ThinkingControl::MiniMaxThinking),
Expand Down Expand Up @@ -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 || {
Expand All @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand All @@ -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]
Expand Down
31 changes: 31 additions & 0 deletions openless-all/app/crates/openless-core/src/provider_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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!(
Expand Down
Loading
Loading