fix(llm): 通过 LM Studio preset 控制思考 (#1009)
* fix (polish): LM Studio默认对Gemma 4 12B开启思考,需要显式关闭它 摘要 通过 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 防误伤用例即被打破 - 证据路径:本地终端输出,未落盘到仓库 * fix(llm): control LM Studio thinking through an explicit preset --------- Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
1 parent
83be689c19
commit
ef78a091a9
18 files changed
+415
-26
No files matched your search
@@ -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<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::default());
|
||||
|
||||
@@ -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> {
|
||||
@@ -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]
|
||||
|
||||
@@ -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<ThinkingControl> {
|
||||
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]
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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é…',
|
||||
|
||||
@@ -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: 'カスタムモデル…',
|
||||
|
||||
@@ -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: '사용자 정의 모델…',
|
||||
|
||||
@@ -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: '自定义模型…',
|
||||
|
||||
@@ -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: '自訂模型…',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
<>
|
||||
<CredentialField
|
||||
key={`${channelId}:api_key`}
|
||||
label={t('settings.providers.apiKeyLabel')}
|
||||
label={t(
|
||||
descriptor.authRequirement === 'endpoint_model_optional_api_key'
|
||||
? 'settings.providers.apiKeyOptionalLabel'
|
||||
: 'settings.providers.apiKeyLabel',
|
||||
)}
|
||||
account="ark.api_key"
|
||||
provider={channelId}
|
||||
mono
|
||||
|
||||
Reference in new issue
Block a user