Skip to main content

vtcode_llm/providers/
lmstudio.rs

1//! LM Studio provider implementation
2//!
3//! Architecture inspired by [openai/codex] `codex-rs/lmstudio` (Apache-2.0).
4//! Copyright 2025 OpenAI. See the repository `THIRD-PARTY-NOTICES` file for
5//! full attribution.
6//!
7//! [openai/codex]: https://github.com/openai/codex
8
9use super::OpenAIProvider;
10use super::common::resolve_model;
11use super::local_readiness::resolve_local_model;
12use super::local_server::LocalProvider;
13use crate::client::LLMClient;
14use crate::error_display;
15use crate::provider::{LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, Message};
16use crate::providers::common::override_base_url;
17use anyhow::Result;
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20use vtcode_config::TimeoutsConfig;
21use vtcode_config::constants::{env_vars, models, urls};
22use vtcode_config::core::{AnthropicConfig, ModelConfig, PromptCachingConfig};
23
24pub mod client;
25
26pub(crate) use client::LMStudioClient;
27
28#[derive(Debug, Deserialize, Serialize)]
29struct LmStudioModelsResponse {
30    data: Vec<LmStudioModel>,
31}
32
33#[derive(Debug, Deserialize, Serialize)]
34struct LmStudioModel {
35    id: String,
36    #[serde(default)]
37    object: Option<String>,
38    #[serde(default)]
39    created: Option<u64>,
40    #[serde(default)]
41    owned_by: Option<String>,
42}
43
44pub(crate) use client::LMSTUDIO_CONNECTION_ERROR;
45
46/// Derives the server root URL by stripping the `/v1` suffix from the API base.
47///
48/// `LMSTUDIO_API_BASE` is `http://localhost:1234/v1`. The native REST API
49/// lives at `/api/v0/*` on the server root, so we need `http://localhost:1234`.
50fn server_root_from_api_base(api_base: &str) -> String {
51    let trimmed = api_base.trim_end_matches('/');
52    trimmed.strip_suffix("/v1").unwrap_or(trimmed).to_string()
53}
54
55/// Fetches available models from the LM Studio API endpoint
56///
57/// Uses OpenAI-compatible `/v1/models` endpoint by default.
58/// Set `LMSTUDIO_USE_NATIVE_API=true` to use native REST API at `/api/v0/models`.
59pub async fn fetch_lmstudio_models(base_url: Option<String>) -> Result<Vec<String>, anyhow::Error> {
60    let resolved_base_url = override_base_url(urls::LMSTUDIO_API_BASE, base_url, Some(env_vars::LMSTUDIO_BASE_URL));
61
62    let use_native_api = std::env::var("LMSTUDIO_USE_NATIVE_API")
63        .ok()
64        .and_then(|v| v.parse::<bool>().ok())
65        .unwrap_or(false);
66
67    // LMSTUDIO_API_BASE already includes `/v1`, so for the OpenAI-compatible
68    // endpoint we append `/models` directly. For the native REST API we need
69    // the server root (without `/v1`) and then append `/api/v0/models`.
70    let models_url = if use_native_api {
71        let root = server_root_from_api_base(&resolved_base_url);
72        format!("{root}/api/v0/models")
73    } else {
74        format!("{}/models", resolved_base_url.trim_end_matches('/'))
75    };
76
77    // Create HTTP client with connection timeout
78    let client = vtcode_commons::http::create_client_with_timeout(std::time::Duration::from_secs(5));
79
80    // Make GET request to fetch models
81    let response = client
82        .get(&models_url)
83        .header("Content-Type", "application/json")
84        .send()
85        .await
86        .map_err(|e| {
87            // Connection refused is expected when LM Studio isn't running —
88            // debug-level to keep startup quiet. Error is still propagated.
89            tracing::debug!("Failed to connect to LM Studio server: {e:?}");
90            anyhow::anyhow!(LMSTUDIO_CONNECTION_ERROR)
91        })?;
92
93    if !response.status().is_success() {
94        return Err(anyhow::anyhow!(
95            "Failed to fetch LM Studio models: HTTP {}. {}",
96            response.status(),
97            if response.status() == reqwest::StatusCode::NOT_FOUND {
98                "Ensure LM Studio server is running with 'lms server start'."
99            } else {
100                ""
101            }
102        ));
103    }
104
105    // Parse the response
106    let models_response: LmStudioModelsResponse = response
107        .json()
108        .await
109        .map_err(|e| anyhow::anyhow!("Failed to parse LM Studio models response: {e}"))?;
110
111    // Extract model IDs
112    let model_ids: Vec<String> = models_response.data.into_iter().map(|model| model.id).collect();
113
114    Ok(model_ids)
115}
116
117pub struct LmStudioProvider {
118    /// Concrete OpenAI-compatible inner provider (see `LlamaCppProvider` for
119    /// why this is stored concretely: static dispatch for delegated calls,
120    /// one `Box<dyn LLMProvider>` layer instead of two).
121    inner: OpenAIProvider,
122    model_id: String,
123}
124
125impl LmStudioProvider {
126    fn resolve_base_url(base_url: Option<String>) -> String {
127        override_base_url(urls::LMSTUDIO_API_BASE, base_url, Some(env_vars::LMSTUDIO_BASE_URL))
128    }
129
130    fn build_inner(
131        api_key: Option<String>,
132        model: Option<String>,
133        base_url: Option<String>,
134        prompt_cache: Option<PromptCachingConfig>,
135        timeouts: Option<TimeoutsConfig>,
136        anthropic: Option<AnthropicConfig>,
137        model_behavior: Option<ModelConfig>,
138    ) -> (OpenAIProvider, String) {
139        let resolved_model = resolve_model(model, models::lmstudio::DEFAULT_MODEL);
140        let resolved_base = Self::resolve_base_url(base_url);
141        let inner = OpenAIProvider::from_config(
142            api_key,
143            None,
144            Some(resolved_model.clone()),
145            Some(resolved_base),
146            prompt_cache,
147            timeouts,
148            anthropic,
149            None,
150            model_behavior,
151        );
152        (inner, resolved_model)
153    }
154
155    pub fn new(api_key: String) -> Self {
156        Self::with_model(api_key, models::lmstudio::DEFAULT_MODEL.to_string())
157    }
158
159    fn with_model(api_key: String, model: String) -> Self {
160        Self::with_model_internal(Some(api_key), Some(model), None, None, None)
161    }
162
163    pub fn new_with_client(
164        model: String,
165        http_client: reqwest::Client,
166        base_url: String,
167        timeouts: TimeoutsConfig,
168    ) -> Self {
169        let inner = OpenAIProvider::new_with_client(
170            "lm-studio".to_string(), // Dummy API key
171            None,
172            model.clone(),
173            http_client,
174            base_url,
175            timeouts,
176        );
177        Self { inner, model_id: model }
178    }
179
180    pub fn from_config(
181        api_key: Option<String>,
182        model: Option<String>,
183        base_url: Option<String>,
184        prompt_cache: Option<PromptCachingConfig>,
185        timeouts: Option<TimeoutsConfig>,
186        anthropic: Option<AnthropicConfig>,
187        model_behavior: Option<ModelConfig>,
188    ) -> Self {
189        let (inner, model_id) =
190            Self::build_inner(api_key, model, base_url, prompt_cache, timeouts, anthropic, model_behavior);
191        Self { inner, model_id }
192    }
193
194    fn with_model_internal(
195        api_key: Option<String>,
196        model: Option<String>,
197        base_url: Option<String>,
198        prompt_cache: Option<PromptCachingConfig>,
199        model_behavior: Option<ModelConfig>,
200    ) -> Self {
201        let (inner, model_id) = Self::build_inner(api_key, model, base_url, prompt_cache, None, None, model_behavior);
202        Self { inner, model_id }
203    }
204
205    /// Verify the LM Studio server is up and the requested model is loaded
206    /// before generating. Returns the (possibly substituted) model id or a
207    /// structured error with a recovery command (`lms load <model>` /
208    /// `/local start lmstudio`).
209    async fn ensure_ready(&self, requested: &str) -> Result<String, LLMError> {
210        match resolve_local_model(LocalProvider::LmStudio, requested, None).await {
211            Ok(model) => Ok(model),
212            Err(err) => Err(err.to_llm_error("LM Studio")),
213        }
214    }
215}
216
217#[async_trait]
218impl LLMProvider for LmStudioProvider {
219    fn name(&self) -> &str {
220        "lmstudio"
221    }
222
223    fn supports_streaming(&self) -> bool {
224        self.inner.supports_streaming()
225    }
226
227    fn supports_non_streaming(&self, model: &str) -> bool {
228        // Delegated (fail-safe): the stream-timeout fallback keys on this.
229        self.inner.supports_non_streaming(model)
230    }
231
232    fn supports_reasoning(&self, model: &str) -> bool {
233        self.inner.supports_reasoning(model)
234    }
235
236    fn supports_reasoning_effort(&self, model: &str) -> bool {
237        self.inner.supports_reasoning_effort(model)
238    }
239
240    fn supports_tools(&self, model: &str) -> bool {
241        self.inner.supports_tools(model)
242    }
243
244    fn supports_parallel_tool_config(&self, model: &str) -> bool {
245        self.inner.supports_parallel_tool_config(model)
246    }
247
248    async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
249        let resolved = self.ensure_ready(&request.model).await?;
250        if !resolved.is_empty() {
251            request.model = resolved;
252        }
253        self.inner.generate(request).await
254    }
255
256    async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
257        let resolved = self.ensure_ready(&request.model).await?;
258        if !resolved.is_empty() {
259            request.model = resolved;
260        }
261        self.inner.stream(request).await
262    }
263
264    fn supported_models(&self) -> Vec<String> {
265        // Hardcoded models prevent expensive network calls. Future enhancements:
266        // 1. Lazy initialization via once_cell to fetch models at startup
267        // 2. Dynamic fetching with proper caching to avoid repeated network calls
268        models::lmstudio::SUPPORTED_MODELS
269            .iter()
270            .map(|model| model.to_string())
271            .collect()
272    }
273
274    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
275        if request.messages.is_empty() {
276            let formatted_error = error_display::format_llm_error("LM Studio", "Messages cannot be empty");
277            return Err(LLMError::InvalidRequest { message: formatted_error, metadata: None });
278        }
279
280        // Validate messages against provider's requirements
281        for message in request.messages.iter() {
282            if let Err(err) = message.validate_for_provider("openai") {
283                let formatted = error_display::format_llm_error("LM Studio", &err);
284                return Err(LLMError::InvalidRequest { message: formatted, metadata: None });
285            }
286        }
287
288        Ok(())
289    }
290}
291
292#[async_trait]
293impl LLMClient for LmStudioProvider {
294    async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
295        LLMProvider::generate(
296            self,
297            LLMRequest {
298                messages: std::sync::Arc::new(vec![Message::user(prompt.to_string())]),
299                model: self.model_id.clone(),
300                ..Default::default()
301            },
302        )
303        .await
304    }
305
306    fn model_id(&self) -> &str {
307        &self.model_id
308    }
309}