From 336ac9a11ca54e21ea843746bf31a28bd1a40656 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Thu, 7 May 2026 18:49:38 +0800 Subject: [PATCH] Broaden low-cost LLM provider choices Issue #323 asks for easier switching to more OpenAI-compatible LLM providers instead of forcing users through a single custom slot. The settings page already stores credentials per active LLM provider id, so the narrow fix is to add practical presets and make provider switching seed the model field only when it is empty. Constraint: Keep provider storage in the existing system credential vault schema Rejected: Build named custom profile management | larger UI and persistence surface than needed for the first issue slice Confidence: medium Scope-risk: narrow Directive: New LLM preset ids rely on the generic active LLM credential routing; do not special-case them in backend code unless the provider stops being OpenAI-compatible Tested: npm run build Tested: git diff --check Related: #323 --- openless-all/app/src-tauri/src/commands.rs | 68 +++++++++++++++++--- openless-all/app/src/i18n/en.ts | 5 ++ openless-all/app/src/i18n/ja.ts | 5 ++ openless-all/app/src/i18n/ko.ts | 5 ++ openless-all/app/src/i18n/zh-CN.ts | 5 ++ openless-all/app/src/i18n/zh-TW.ts | 5 ++ openless-all/app/src/pages/Overview.tsx | 5 ++ openless-all/app/src/pages/Settings.tsx | 73 ++++++++++++++++++++-- 8 files changed, 158 insertions(+), 13 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 6ee090ba..a7dd8ece 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -234,7 +234,7 @@ pub fn get_credentials() -> CredentialsStatus { let active_llm_provider = CredentialsVault::get_active_llm(); let volcengine_configured = volcengine_configured(&snap); let asr_configured = asr_configured_for_provider(&active_asr_provider, &snap); - let llm_configured = llm_configured_for_snapshot(&snap); + let llm_configured = llm_configured_for_provider(&active_llm_provider, &snap); CredentialsStatus { active_asr_provider, active_llm_provider, @@ -262,8 +262,43 @@ fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bo configured(&snap.asr_endpoint) && configured(&snap.asr_model) } -fn llm_configured_for_snapshot(snap: &CredentialsSnapshot) -> bool { - configured(&snap.ark_endpoint) && configured(&snap.ark_model_id) +fn llm_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bool { + let endpoint = snap.ark_endpoint.as_deref().unwrap_or_default(); + let endpoint_and_model = configured(&snap.ark_endpoint) && configured(&snap.ark_model_id); + if endpoint_and_model + && llm_provider_default_endpoint(provider) + .map(|default| same_llm_endpoint(endpoint, default)) + .unwrap_or(false) + { + return configured(&snap.ark_api_key); + } + endpoint_and_model +} + +fn llm_provider_default_endpoint(provider: &str) -> Option<&'static str> { + match provider { + "ark" => Some("https://ark.cn-beijing.volces.com/api/v3"), + "deepseek" => Some("https://api.deepseek.com/v1"), + "siliconflow" => Some("https://api.siliconflow.cn/v1"), + "openai" => Some("https://api.openai.com/v1"), + "mimo" => Some("https://api.xiaomimimo.com/v1"), + "cometapi" => Some("https://api.cometapi.com/v1"), + "openrouterFree" => Some("https://openrouter.ai/api/v1"), + "alibabaCoding" => Some("https://coding-intl.dashscope.aliyuncs.com/v1"), + "codingPlanX" => Some("https://api.codingplanx.ai/v1"), + _ => None, + } +} + +fn same_llm_endpoint(a: &str, b: &str) -> bool { + fn normalize(value: &str) -> &str { + value + .trim() + .trim_end_matches('/') + .trim_end_matches("/chat/completions") + .trim_end_matches('/') + } + normalize(a).eq_ignore_ascii_case(normalize(b)) } fn configured(field: &Option) -> bool { @@ -1554,7 +1589,7 @@ mod tests { use super::{ active_asr_is_keyless_for_validation, active_foundry_model_from_prefs, asr_configured_for_provider, asr_transcriptions_url, fetch_provider_models, - llm_configured_for_snapshot, local_asr_release_plan_for_provider, models_url, + llm_configured_for_provider, local_asr_release_plan_for_provider, models_url, normalize_foundry_language_hint, parse_model_ids, persist_settings, release_foundry_runtime_if_inactive, validate_foundry_model_alias, ProviderConfig, SettingsWriter, @@ -1719,26 +1754,43 @@ mod tests { } #[test] - fn credentials_status_accepts_keyless_llm_with_endpoint_and_model() { + fn credentials_status_accepts_keyless_custom_llm_only() { let keyless_ready = CredentialsSnapshot { ark_endpoint: Some("http://localhost:11434/v1".into()), ark_model_id: Some("qwen".into()), ..snapshot() }; - assert!(llm_configured_for_snapshot(&keyless_ready)); + assert!(llm_configured_for_provider("custom", &keyless_ready)); + assert!(llm_configured_for_provider("self-hosted", &keyless_ready)); + assert!(llm_configured_for_provider("openrouterFree", &keyless_ready)); + + let hosted_keyless = CredentialsSnapshot { + ark_endpoint: Some("https://openrouter.ai/api/v1".into()), + ark_model_id: Some("qwen/qwen3-coder:free".into()), + ..snapshot() + }; + assert!(!llm_configured_for_provider("openrouterFree", &hosted_keyless)); + + let hosted_ready = CredentialsSnapshot { + ark_api_key: Some("key".into()), + ark_endpoint: Some("https://openrouter.ai/api/v1/chat/completions".into()), + ark_model_id: Some("qwen/qwen3-coder:free".into()), + ..snapshot() + }; + assert!(llm_configured_for_provider("openrouterFree", &hosted_ready)); let key_without_endpoint = CredentialsSnapshot { ark_api_key: Some("key".into()), ark_model_id: Some("qwen".into()), ..snapshot() }; - assert!(!llm_configured_for_snapshot(&key_without_endpoint)); + assert!(!llm_configured_for_provider("custom", &key_without_endpoint)); let endpoint_without_model = CredentialsSnapshot { ark_endpoint: Some("http://localhost:11434/v1".into()), ..snapshot() }; - assert!(!llm_configured_for_snapshot(&endpoint_without_model)); + assert!(!llm_configured_for_provider("custom", &endpoint_without_model)); } impl SettingsWriter for FakeSettingsWriter { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 2a8b4153..52c156ad 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -317,6 +317,11 @@ export const en: typeof zhCN = { deepseek: 'DeepSeek', siliconflow: 'SiliconFlow', openai: 'OpenAI', + mimo: 'Xiaomi MiMo', + cometapi: 'CometAPI', + openrouterFree: 'OpenRouter (free models)', + alibabaCoding: 'Alibaba Cloud Coding Plan', + codingPlanX: 'CodingPlanX', custom: 'Custom', asrVolcengine: 'Volcengine bigasr', asrSiliconflow: 'SiliconFlow SenseVoice', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 93852631..b11bf9f2 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -319,6 +319,11 @@ export const ja: typeof zhCN = { deepseek: 'DeepSeek', siliconflow: 'SiliconFlow', openai: 'OpenAI', + mimo: 'Xiaomi MiMo', + cometapi: 'CometAPI', + openrouterFree: 'OpenRouter(無料モデル)', + alibabaCoding: 'Alibaba Cloud Coding Plan', + codingPlanX: 'CodingPlanX', custom: 'カスタム', asrVolcengine: 'Volcengine bigasr', asrSiliconflow: 'SiliconFlow SenseVoice', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 2bbc9628..4d042413 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -319,6 +319,11 @@ export const ko: typeof zhCN = { deepseek: 'DeepSeek', siliconflow: 'SiliconFlow', openai: 'OpenAI', + mimo: 'Xiaomi MiMo', + cometapi: 'CometAPI', + openrouterFree: 'OpenRouter(무료 모델)', + alibabaCoding: 'Alibaba Cloud Coding Plan', + codingPlanX: 'CodingPlanX', custom: '사용자 정의', asrVolcengine: 'Volcengine bigasr', asrSiliconflow: 'SiliconFlow SenseVoice', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index bdc97244..ea1653ea 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -315,6 +315,11 @@ export const zhCN = { deepseek: 'DeepSeek', siliconflow: '硅基流动', openai: 'OpenAI', + mimo: '小米 MiMo', + cometapi: 'CometAPI', + openrouterFree: 'OpenRouter(免费模型)', + alibabaCoding: '阿里云 Coding Plan', + codingPlanX: 'CodingPlanX', custom: '自定义', asrVolcengine: '火山引擎 bigasr', asrSiliconflow: '硅基流动 SenseVoice', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 7c352e40..9a9f2a90 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -317,6 +317,11 @@ export const zhTW: typeof zhCN = { deepseek: 'DeepSeek', siliconflow: '硅基流動', openai: 'OpenAI', + mimo: '小米 MiMo', + cometapi: 'CometAPI', + openrouterFree: 'OpenRouter(免費模型)', + alibabaCoding: '阿里雲 Coding Plan', + codingPlanX: 'CodingPlanX', custom: '自定義', asrVolcengine: '火山引擎 bigasr', asrSiliconflow: '硅基流動 SenseVoice', diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index c81846d1..30b7c87f 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -38,6 +38,11 @@ const LLM_NAME_KEY_BY_ID: Record = { deepseek: 'deepseek', siliconflow: 'siliconflow', openai: 'openai', + mimo: 'mimo', + cometapi: 'cometapi', + openrouterFree: 'openrouterFree', + alibabaCoding: 'alibabaCoding', + codingPlanX: 'codingPlanX', custom: 'custom', }; diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 14edcf9c..2146c641 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -1033,11 +1033,66 @@ function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolean) => v } const LLM_PRESETS = [ - { id: 'ark', nameKey: 'ark', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', modelPlaceholder: 'deepseek-v3-2' }, - { id: 'deepseek', nameKey: 'deepseek', baseUrl: 'https://api.deepseek.com/v1', modelPlaceholder: 'deepseek-v4-flash' }, - { id: 'siliconflow', nameKey: 'siliconflow', baseUrl: 'https://api.siliconflow.cn/v1', modelPlaceholder: 'Qwen/Qwen2.5-7B-Instruct' }, - { id: 'openai', nameKey: 'openai', baseUrl: 'https://api.openai.com/v1', modelPlaceholder: 'gpt-4o' }, - { id: 'custom', nameKey: 'custom', baseUrl: '', modelPlaceholder: '' }, + { + id: 'ark', + nameKey: 'ark', + baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', + modelPlaceholder: 'deepseek-v3-2', + }, + { + id: 'deepseek', + nameKey: 'deepseek', + baseUrl: 'https://api.deepseek.com/v1', + modelPlaceholder: 'deepseek-v4-flash', + }, + { + id: 'siliconflow', + nameKey: 'siliconflow', + baseUrl: 'https://api.siliconflow.cn/v1', + modelPlaceholder: 'Qwen/Qwen2.5-7B-Instruct', + }, + { + id: 'openai', + nameKey: 'openai', + baseUrl: 'https://api.openai.com/v1', + modelPlaceholder: 'gpt-4o', + }, + { + id: 'mimo', + nameKey: 'mimo', + baseUrl: 'https://api.xiaomimimo.com/v1', + modelPlaceholder: 'xiaomi/mimo-v2-flash', + }, + { + id: 'cometapi', + nameKey: 'cometapi', + baseUrl: 'https://api.cometapi.com/v1', + modelPlaceholder: 'gpt-4o', + }, + { + id: 'openrouterFree', + nameKey: 'openrouterFree', + baseUrl: 'https://openrouter.ai/api/v1', + modelPlaceholder: 'qwen/qwen3-coder:free', + }, + { + id: 'alibabaCoding', + nameKey: 'alibabaCoding', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + modelPlaceholder: 'qwen3-coder-plus', + }, + { + id: 'codingPlanX', + nameKey: 'codingPlanX', + baseUrl: 'https://api.codingplanx.ai/v1', + modelPlaceholder: 'gpt-5-mini', + }, + { + id: 'custom', + nameKey: 'custom', + baseUrl: '', + modelPlaceholder: '', + }, ] as const; type LlmPresetId = typeof LLM_PRESETS[number]['id']; @@ -1122,6 +1177,14 @@ function ProvidersSection() { if (seq !== llmSwitchSeqRef.current) return; } } + if (preset?.modelPlaceholder) { + const existing = await readCredential('ark.model_id'); + if (seq !== llmSwitchSeqRef.current) return; + if (!existing) { + await setCredential('ark.model_id', preset.modelPlaceholder); + if (seq !== llmSwitchSeqRef.current) return; + } + } setCommittedLlmProvider(id); };