Skip to content

Commit 56f3d5f

Browse files
authored
feat(providers): add first-class LM Studio provider (#286)
* feat(providers): add first-class LM Studio provider LM Studio is a popular local LLM app that serves an OpenAI-compatible API on port 1234 by default. Previously users had to use [providers.custom-llamacpp] with a dummy api_key and manual /v1 suffix. Add lmstudio as a first-class provider with zero-friction setup: - Auto-detects at http://127.0.0.1:1234/v1 - No API key required (same pattern as Ollama) - Model discovery via /v1/models - Works with llama.cpp or any OpenAI-compatible local server * refactor(providers): replace name checks with struct fields Add `requires_api_key` and `local_only` fields to `OpenAiCompatDef` instead of matching on provider name strings in three places. The next local provider won't need any special-case branches.
1 parent b47682d commit 56f3d5f

3 files changed

Lines changed: 58 additions & 6 deletions

File tree

crates/config/src/validate.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ const KNOWN_PROVIDER_NAMES: &[&str] = &[
9898
"moonshot",
9999
"venice",
100100
"ollama",
101+
"lmstudio",
101102
];
102103

103104
/// Static metadata keys allowed directly under `[providers]`.

crates/providers/src/lib.rs

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,15 @@ struct OpenAiCompatDef {
788788
/// this to `false` so the static catalog is used without a noisy warning.
789789
/// Users can still override via `fetch_models = true` in config.
790790
supports_model_discovery: bool,
791+
/// When `false`, a dummy API key (the provider name) is used if none is
792+
/// configured. Intended for local servers that don't authenticate.
793+
requires_api_key: bool,
794+
/// Local-only providers (Ollama, LM Studio) are skipped unless the user
795+
/// has an explicit `[providers.<name>]` entry, a `_BASE_URL` env var, or
796+
/// configured models. This avoids probing localhost when nothing is running.
797+
/// Also ensures model discovery is always attempted (never short-circuited
798+
/// by the empty-catalog heuristic).
799+
local_only: bool,
791800
}
792801

793802
const OPENAI_COMPAT_PROVIDERS: &[OpenAiCompatDef] = &[
@@ -798,6 +807,8 @@ const OPENAI_COMPAT_PROVIDERS: &[OpenAiCompatDef] = &[
798807
default_base_url: "https://api.mistral.ai/v1",
799808
models: MISTRAL_MODELS,
800809
supports_model_discovery: true,
810+
requires_api_key: true,
811+
local_only: false,
801812
},
802813
OpenAiCompatDef {
803814
config_name: "openrouter",
@@ -806,6 +817,8 @@ const OPENAI_COMPAT_PROVIDERS: &[OpenAiCompatDef] = &[
806817
default_base_url: "https://openrouter.ai/api/v1",
807818
models: &[],
808819
supports_model_discovery: true,
820+
requires_api_key: true,
821+
local_only: false,
809822
},
810823
OpenAiCompatDef {
811824
config_name: "cerebras",
@@ -814,6 +827,8 @@ const OPENAI_COMPAT_PROVIDERS: &[OpenAiCompatDef] = &[
814827
default_base_url: "https://api.cerebras.ai/v1",
815828
models: CEREBRAS_MODELS,
816829
supports_model_discovery: true,
830+
requires_api_key: true,
831+
local_only: false,
817832
},
818833
OpenAiCompatDef {
819834
config_name: "minimax",
@@ -823,6 +838,8 @@ const OPENAI_COMPAT_PROVIDERS: &[OpenAiCompatDef] = &[
823838
models: MINIMAX_MODELS,
824839
// MiniMax API does not expose a /models endpoint (returns 404).
825840
supports_model_discovery: false,
841+
requires_api_key: true,
842+
local_only: false,
826843
},
827844
OpenAiCompatDef {
828845
config_name: "moonshot",
@@ -831,6 +848,8 @@ const OPENAI_COMPAT_PROVIDERS: &[OpenAiCompatDef] = &[
831848
default_base_url: "https://api.moonshot.ai/v1",
832849
models: MOONSHOT_MODELS,
833850
supports_model_discovery: true,
851+
requires_api_key: true,
852+
local_only: false,
834853
},
835854
OpenAiCompatDef {
836855
config_name: "zai",
@@ -839,6 +858,8 @@ const OPENAI_COMPAT_PROVIDERS: &[OpenAiCompatDef] = &[
839858
default_base_url: "https://api.z.ai/api/paas/v4",
840859
models: ZAI_MODELS,
841860
supports_model_discovery: true,
861+
requires_api_key: true,
862+
local_only: false,
842863
},
843864
OpenAiCompatDef {
844865
config_name: "venice",
@@ -847,6 +868,8 @@ const OPENAI_COMPAT_PROVIDERS: &[OpenAiCompatDef] = &[
847868
default_base_url: "https://api.venice.ai/api/v1",
848869
models: &[],
849870
supports_model_discovery: true,
871+
requires_api_key: true,
872+
local_only: false,
850873
},
851874
OpenAiCompatDef {
852875
config_name: "deepseek",
@@ -855,6 +878,8 @@ const OPENAI_COMPAT_PROVIDERS: &[OpenAiCompatDef] = &[
855878
default_base_url: "https://api.deepseek.com",
856879
models: DEEPSEEK_MODELS,
857880
supports_model_discovery: true,
881+
requires_api_key: true,
882+
local_only: false,
858883
},
859884
OpenAiCompatDef {
860885
config_name: "ollama",
@@ -863,6 +888,18 @@ const OPENAI_COMPAT_PROVIDERS: &[OpenAiCompatDef] = &[
863888
default_base_url: "http://127.0.0.1:11434/v1",
864889
models: &[],
865890
supports_model_discovery: true,
891+
requires_api_key: false,
892+
local_only: true,
893+
},
894+
OpenAiCompatDef {
895+
config_name: "lmstudio",
896+
env_key: "LMSTUDIO_API_KEY",
897+
env_base_url_key: "LMSTUDIO_BASE_URL",
898+
default_base_url: "http://127.0.0.1:1234/v1",
899+
models: &[],
900+
supports_model_discovery: true,
901+
requires_api_key: false,
902+
local_only: true,
866903
},
867904
OpenAiCompatDef {
868905
config_name: "gemini",
@@ -871,6 +908,8 @@ const OPENAI_COMPAT_PROVIDERS: &[OpenAiCompatDef] = &[
871908
default_base_url: "https://generativelanguage.googleapis.com/v1beta/openai",
872909
models: GEMINI_MODELS,
873910
supports_model_discovery: true,
911+
requires_api_key: true,
912+
local_only: false,
874913
},
875914
];
876915

@@ -1675,10 +1714,10 @@ impl ProviderRegistry {
16751714

16761715
let key = resolve_api_key(config, def.config_name, def.env_key, env_overrides);
16771716

1678-
// Ollama doesn't require an API key — use a dummy value.
1717+
// Local providers don't require an API key — use a dummy value.
16791718
// Gemini accepts both GEMINI_API_KEY and GOOGLE_API_KEY.
1680-
let key = if def.config_name == "ollama" {
1681-
key.or_else(|| Some(secrecy::Secret::new("ollama".into())))
1719+
let key = if !def.requires_api_key {
1720+
key.or_else(|| Some(secrecy::Secret::new(def.config_name.into())))
16821721
} else if def.config_name == "gemini" {
16831722
key.or_else(|| env_value(env_overrides, "GOOGLE_API_KEY").map(secrecy::Secret::new))
16841723
} else {
@@ -1703,8 +1742,8 @@ impl ProviderRegistry {
17031742
.map(|entry| entry.stream_transport)
17041743
.unwrap_or(ProviderStreamTransport::Sse);
17051744
let preferred = configured_models_for_provider(config, def.config_name);
1706-
if def.config_name == "ollama" {
1707-
let has_explicit_entry = config.get("ollama").is_some();
1745+
if def.local_only {
1746+
let has_explicit_entry = config.get(def.config_name).is_some();
17081747
let has_env_base_url = env_value(env_overrides, def.env_base_url_key).is_some();
17091748
if !has_explicit_entry && !has_env_base_url && preferred.is_empty() {
17101749
continue;
@@ -1715,7 +1754,7 @@ impl ProviderRegistry {
17151754
// OpenRouter supports `/models`, so we discover dynamically.
17161755
let skip_discovery = def.models.is_empty()
17171756
&& preferred.is_empty()
1718-
&& def.config_name != "ollama"
1757+
&& !def.local_only
17191758
&& (def.config_name == "venice" || cfg!(test));
17201759
// Respect `supports_model_discovery`: providers whose API lacks a
17211760
// /models endpoint (e.g. MiniMax) skip live fetch unless the user

docs/src/providers.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Configure providers through the web UI or directly in configuration files.
3535
| Provider | Config Name | Notes |
3636
|----------|-------------|-------|
3737
| **Ollama** | `ollama` | Local or remote Ollama instance |
38+
| **LM Studio** | `lmstudio` | Local LM Studio or any OpenAI-compatible server |
3839
| **Local LLM** | `local-llm` | Runs GGUF models directly on your machine |
3940

4041
### Custom OpenAI-Compatible
@@ -186,6 +187,17 @@ enabled = true
186187
# base_url = "http://127.0.0.1:11434/v1" # Override for remote Ollama
187188
```
188189

190+
### LM Studio
191+
192+
LM Studio auto-detects when running at `http://127.0.0.1:1234`. No API key needed.
193+
Also works with llama.cpp or any OpenAI-compatible local server.
194+
195+
```toml
196+
[providers.lmstudio]
197+
enabled = true
198+
# base_url = "http://127.0.0.1:1234/v1" # Override for different port/host
199+
```
200+
189201
### Local LLM
190202

191203
Local LLM runs GGUF models directly on your machine.

0 commit comments

Comments
 (0)