1use 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
46fn 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
55pub 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 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 let client = vtcode_commons::http::create_client_with_timeout(std::time::Duration::from_secs(5));
79
80 let response = client
82 .get(&models_url)
83 .header("Content-Type", "application/json")
84 .send()
85 .await
86 .map_err(|e| {
87 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 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 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 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(), 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 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 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 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 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}