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:
lededevandChris233 authored and GitHub committed 2026-09-13 19:59:15 +08:00
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::*;
+2
View File
@@ -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…',
+2
View File
@@ -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',
+2
View File
@@ -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…',
+2
View File
@@ -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é…',
+2
View File
@@ -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: 'カスタムモデル…',
+2
View File
@@ -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: '사용자 정의 모델…',
+2
View File
@@ -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: '自定义模型…',
+2
View File
@@ -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