lmstudio.rs

  1use anyhow::{Result, anyhow};
  2use collections::HashMap;
  3use futures::Stream;
  4use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream};
  5use gpui::{AnyView, App, AsyncApp, Context, Subscription, Task};
  6use http_client::HttpClient;
  7use language_model::{
  8    AuthenticateError, LanguageModelCompletionError, LanguageModelCompletionEvent,
  9    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
 10    StopReason, TokenUsage,
 11};
 12use language_model::{
 13    LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider,
 14    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
 15    LanguageModelRequest, RateLimiter, Role,
 16};
 17use lmstudio::{ModelType, get_models};
 18use schemars::JsonSchema;
 19use serde::{Deserialize, Serialize};
 20use settings::{Settings, SettingsStore};
 21use std::pin::Pin;
 22use std::str::FromStr;
 23use std::{collections::BTreeMap, sync::Arc};
 24use ui::{ButtonLike, Indicator, List, prelude::*};
 25use util::ResultExt;
 26
 27use crate::AllLanguageModelSettings;
 28use crate::ui::InstructionListItem;
 29
 30const LMSTUDIO_DOWNLOAD_URL: &str = "https://lmstudio.ai/download";
 31const LMSTUDIO_CATALOG_URL: &str = "https://lmstudio.ai/models";
 32const LMSTUDIO_SITE: &str = "https://lmstudio.ai/";
 33
 34const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("lmstudio");
 35const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("LM Studio");
 36
 37#[derive(Default, Debug, Clone, PartialEq)]
 38pub struct LmStudioSettings {
 39    pub api_url: String,
 40    pub available_models: Vec<AvailableModel>,
 41}
 42
 43#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
 44pub struct AvailableModel {
 45    pub name: String,
 46    pub display_name: Option<String>,
 47    pub max_tokens: u64,
 48    pub supports_tool_calls: bool,
 49    pub supports_images: bool,
 50}
 51
 52pub struct LmStudioLanguageModelProvider {
 53    http_client: Arc<dyn HttpClient>,
 54    state: gpui::Entity<State>,
 55}
 56
 57pub struct State {
 58    http_client: Arc<dyn HttpClient>,
 59    available_models: Vec<lmstudio::Model>,
 60    fetch_model_task: Option<Task<Result<()>>>,
 61    _subscription: Subscription,
 62}
 63
 64impl State {
 65    fn is_authenticated(&self) -> bool {
 66        !self.available_models.is_empty()
 67    }
 68
 69    fn fetch_models(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
 70        let settings = &AllLanguageModelSettings::get_global(cx).lmstudio;
 71        let http_client = self.http_client.clone();
 72        let api_url = settings.api_url.clone();
 73
 74        // As a proxy for the server being "authenticated", we'll check if its up by fetching the models
 75        cx.spawn(async move |this, cx| {
 76            let models = get_models(http_client.as_ref(), &api_url, None).await?;
 77
 78            let mut models: Vec<lmstudio::Model> = models
 79                .into_iter()
 80                .filter(|model| model.r#type != ModelType::Embeddings)
 81                .map(|model| {
 82                    lmstudio::Model::new(
 83                        &model.id,
 84                        None,
 85                        model
 86                            .loaded_context_length
 87                            .or_else(|| model.max_context_length),
 88                        model.capabilities.supports_tool_calls(),
 89                        model.capabilities.supports_images() || model.r#type == ModelType::Vlm,
 90                    )
 91                })
 92                .collect();
 93
 94            models.sort_by(|a, b| a.name.cmp(&b.name));
 95
 96            this.update(cx, |this, cx| {
 97                this.available_models = models;
 98                cx.notify();
 99            })
100        })
101    }
102
103    fn restart_fetch_models_task(&mut self, cx: &mut Context<Self>) {
104        let task = self.fetch_models(cx);
105        self.fetch_model_task.replace(task);
106    }
107
108    fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
109        if self.is_authenticated() {
110            return Task::ready(Ok(()));
111        }
112
113        let fetch_models_task = self.fetch_models(cx);
114        cx.spawn(async move |_this, _cx| Ok(fetch_models_task.await?))
115    }
116}
117
118impl LmStudioLanguageModelProvider {
119    pub fn new(http_client: Arc<dyn HttpClient>, cx: &mut App) -> Self {
120        let this = Self {
121            http_client: http_client.clone(),
122            state: cx.new(|cx| {
123                let subscription = cx.observe_global::<SettingsStore>({
124                    let mut settings = AllLanguageModelSettings::get_global(cx).lmstudio.clone();
125                    move |this: &mut State, cx| {
126                        let new_settings = &AllLanguageModelSettings::get_global(cx).lmstudio;
127                        if &settings != new_settings {
128                            settings = new_settings.clone();
129                            this.restart_fetch_models_task(cx);
130                            cx.notify();
131                        }
132                    }
133                });
134
135                State {
136                    http_client,
137                    available_models: Default::default(),
138                    fetch_model_task: None,
139                    _subscription: subscription,
140                }
141            }),
142        };
143        this.state
144            .update(cx, |state, cx| state.restart_fetch_models_task(cx));
145        this
146    }
147}
148
149impl LanguageModelProviderState for LmStudioLanguageModelProvider {
150    type ObservableEntity = State;
151
152    fn observable_entity(&self) -> Option<gpui::Entity<Self::ObservableEntity>> {
153        Some(self.state.clone())
154    }
155}
156
157impl LanguageModelProvider for LmStudioLanguageModelProvider {
158    fn id(&self) -> LanguageModelProviderId {
159        PROVIDER_ID
160    }
161
162    fn name(&self) -> LanguageModelProviderName {
163        PROVIDER_NAME
164    }
165
166    fn icon(&self) -> IconName {
167        IconName::AiLmStudio
168    }
169
170    fn default_model(&self, _: &App) -> Option<Arc<dyn LanguageModel>> {
171        // We shouldn't try to select default model, because it might lead to a load call for an unloaded model.
172        // In a constrained environment where user might not have enough resources it'll be a bad UX to select something
173        // to load by default.
174        None
175    }
176
177    fn default_fast_model(&self, _: &App) -> Option<Arc<dyn LanguageModel>> {
178        // See explanation for default_model.
179        None
180    }
181
182    fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>> {
183        let mut models: BTreeMap<String, lmstudio::Model> = BTreeMap::default();
184
185        // Add models from the LM Studio API
186        for model in self.state.read(cx).available_models.iter() {
187            models.insert(model.name.clone(), model.clone());
188        }
189
190        // Override with available models from settings
191        for model in AllLanguageModelSettings::get_global(cx)
192            .lmstudio
193            .available_models
194            .iter()
195        {
196            models.insert(
197                model.name.clone(),
198                lmstudio::Model {
199                    name: model.name.clone(),
200                    display_name: model.display_name.clone(),
201                    max_tokens: model.max_tokens,
202                    supports_tool_calls: model.supports_tool_calls,
203                    supports_images: model.supports_images,
204                },
205            );
206        }
207
208        models
209            .into_values()
210            .map(|model| {
211                Arc::new(LmStudioLanguageModel {
212                    id: LanguageModelId::from(model.name.clone()),
213                    model: model.clone(),
214                    http_client: self.http_client.clone(),
215                    request_limiter: RateLimiter::new(4),
216                }) as Arc<dyn LanguageModel>
217            })
218            .collect()
219    }
220
221    fn is_authenticated(&self, cx: &App) -> bool {
222        self.state.read(cx).is_authenticated()
223    }
224
225    fn authenticate(&self, cx: &mut App) -> Task<Result<(), AuthenticateError>> {
226        self.state.update(cx, |state, cx| state.authenticate(cx))
227    }
228
229    fn configuration_view(&self, _window: &mut Window, cx: &mut App) -> AnyView {
230        let state = self.state.clone();
231        cx.new(|cx| ConfigurationView::new(state, cx)).into()
232    }
233
234    fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>> {
235        self.state.update(cx, |state, cx| state.fetch_models(cx))
236    }
237}
238
239pub struct LmStudioLanguageModel {
240    id: LanguageModelId,
241    model: lmstudio::Model,
242    http_client: Arc<dyn HttpClient>,
243    request_limiter: RateLimiter,
244}
245
246impl LmStudioLanguageModel {
247    fn to_lmstudio_request(
248        &self,
249        request: LanguageModelRequest,
250    ) -> lmstudio::ChatCompletionRequest {
251        let mut messages = Vec::new();
252
253        for message in request.messages {
254            for content in message.content {
255                match content {
256                    MessageContent::Text(text) => add_message_content_part(
257                        lmstudio::MessagePart::Text { text },
258                        message.role,
259                        &mut messages,
260                    ),
261                    MessageContent::Thinking { .. } => {}
262                    MessageContent::RedactedThinking(_) => {}
263                    MessageContent::Image(image) => {
264                        add_message_content_part(
265                            lmstudio::MessagePart::Image {
266                                image_url: lmstudio::ImageUrl {
267                                    url: image.to_base64_url(),
268                                    detail: None,
269                                },
270                            },
271                            message.role,
272                            &mut messages,
273                        );
274                    }
275                    MessageContent::ToolUse(tool_use) => {
276                        let tool_call = lmstudio::ToolCall {
277                            id: tool_use.id.to_string(),
278                            content: lmstudio::ToolCallContent::Function {
279                                function: lmstudio::FunctionContent {
280                                    name: tool_use.name.to_string(),
281                                    arguments: serde_json::to_string(&tool_use.input)
282                                        .unwrap_or_default(),
283                                },
284                            },
285                        };
286
287                        if let Some(lmstudio::ChatMessage::Assistant { tool_calls, .. }) =
288                            messages.last_mut()
289                        {
290                            tool_calls.push(tool_call);
291                        } else {
292                            messages.push(lmstudio::ChatMessage::Assistant {
293                                content: None,
294                                tool_calls: vec![tool_call],
295                            });
296                        }
297                    }
298                    MessageContent::ToolResult(tool_result) => {
299                        let content = match &tool_result.content {
300                            LanguageModelToolResultContent::Text(text) => {
301                                vec![lmstudio::MessagePart::Text {
302                                    text: text.to_string(),
303                                }]
304                            }
305                            LanguageModelToolResultContent::Image(image) => {
306                                vec![lmstudio::MessagePart::Image {
307                                    image_url: lmstudio::ImageUrl {
308                                        url: image.to_base64_url(),
309                                        detail: None,
310                                    },
311                                }]
312                            }
313                        };
314
315                        messages.push(lmstudio::ChatMessage::Tool {
316                            content: content.into(),
317                            tool_call_id: tool_result.tool_use_id.to_string(),
318                        });
319                    }
320                }
321            }
322        }
323
324        lmstudio::ChatCompletionRequest {
325            model: self.model.name.clone(),
326            messages,
327            stream: true,
328            max_tokens: Some(-1),
329            stop: Some(request.stop),
330            // In LM Studio you can configure specific settings you'd like to use for your model.
331            // For example Qwen3 is recommended to be used with 0.7 temperature.
332            // It would be a bad UX to silently override these settings from Zed, so we pass no temperature as a default.
333            temperature: request.temperature.or(None),
334            tools: request
335                .tools
336                .into_iter()
337                .map(|tool| lmstudio::ToolDefinition::Function {
338                    function: lmstudio::FunctionDefinition {
339                        name: tool.name,
340                        description: Some(tool.description),
341                        parameters: Some(tool.input_schema),
342                    },
343                })
344                .collect(),
345            tool_choice: request.tool_choice.map(|choice| match choice {
346                LanguageModelToolChoice::Auto => lmstudio::ToolChoice::Auto,
347                LanguageModelToolChoice::Any => lmstudio::ToolChoice::Required,
348                LanguageModelToolChoice::None => lmstudio::ToolChoice::None,
349            }),
350        }
351    }
352
353    fn stream_completion(
354        &self,
355        request: lmstudio::ChatCompletionRequest,
356        cx: &AsyncApp,
357    ) -> BoxFuture<
358        'static,
359        Result<futures::stream::BoxStream<'static, Result<lmstudio::ResponseStreamEvent>>>,
360    > {
361        let http_client = self.http_client.clone();
362        let Ok(api_url) = cx.update(|cx| {
363            let settings = &AllLanguageModelSettings::get_global(cx).lmstudio;
364            settings.api_url.clone()
365        }) else {
366            return futures::future::ready(Err(anyhow!("App state dropped"))).boxed();
367        };
368
369        let future = self.request_limiter.stream(async move {
370            let request = lmstudio::stream_chat_completion(http_client.as_ref(), &api_url, request);
371            let response = request.await?;
372            Ok(response)
373        });
374
375        async move { Ok(future.await?.boxed()) }.boxed()
376    }
377}
378
379impl LanguageModel for LmStudioLanguageModel {
380    fn id(&self) -> LanguageModelId {
381        self.id.clone()
382    }
383
384    fn name(&self) -> LanguageModelName {
385        LanguageModelName::from(self.model.display_name().to_string())
386    }
387
388    fn provider_id(&self) -> LanguageModelProviderId {
389        PROVIDER_ID
390    }
391
392    fn provider_name(&self) -> LanguageModelProviderName {
393        PROVIDER_NAME
394    }
395
396    fn supports_tools(&self) -> bool {
397        self.model.supports_tool_calls()
398    }
399
400    fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool {
401        self.supports_tools()
402            && match choice {
403                LanguageModelToolChoice::Auto => true,
404                LanguageModelToolChoice::Any => true,
405                LanguageModelToolChoice::None => true,
406            }
407    }
408
409    fn supports_images(&self) -> bool {
410        self.model.supports_images
411    }
412
413    fn telemetry_id(&self) -> String {
414        format!("lmstudio/{}", self.model.id())
415    }
416
417    fn max_token_count(&self) -> u64 {
418        self.model.max_token_count()
419    }
420
421    fn count_tokens(
422        &self,
423        request: LanguageModelRequest,
424        _cx: &App,
425    ) -> BoxFuture<'static, Result<u64>> {
426        // Endpoint for this is coming soon. In the meantime, hacky estimation
427        let token_count = request
428            .messages
429            .iter()
430            .map(|msg| msg.string_contents().split_whitespace().count())
431            .sum::<usize>();
432
433        let estimated_tokens = (token_count as f64 * 0.75) as u64;
434        async move { Ok(estimated_tokens) }.boxed()
435    }
436
437    fn stream_completion(
438        &self,
439        request: LanguageModelRequest,
440        cx: &AsyncApp,
441    ) -> BoxFuture<
442        'static,
443        Result<
444            BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
445            LanguageModelCompletionError,
446        >,
447    > {
448        let request = self.to_lmstudio_request(request);
449        let completions = self.stream_completion(request, cx);
450        async move {
451            let mapper = LmStudioEventMapper::new();
452            Ok(mapper.map_stream(completions.await?).boxed())
453        }
454        .boxed()
455    }
456}
457
458struct LmStudioEventMapper {
459    tool_calls_by_index: HashMap<usize, RawToolCall>,
460}
461
462impl LmStudioEventMapper {
463    fn new() -> Self {
464        Self {
465            tool_calls_by_index: HashMap::default(),
466        }
467    }
468
469    pub fn map_stream(
470        mut self,
471        events: Pin<Box<dyn Send + Stream<Item = Result<lmstudio::ResponseStreamEvent>>>>,
472    ) -> impl Stream<Item = Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>
473    {
474        events.flat_map(move |event| {
475            futures::stream::iter(match event {
476                Ok(event) => self.map_event(event),
477                Err(error) => vec![Err(LanguageModelCompletionError::from(error))],
478            })
479        })
480    }
481
482    pub fn map_event(
483        &mut self,
484        event: lmstudio::ResponseStreamEvent,
485    ) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
486        let Some(choice) = event.choices.into_iter().next() else {
487            return vec![Err(LanguageModelCompletionError::from(anyhow!(
488                "Response contained no choices"
489            )))];
490        };
491
492        let mut events = Vec::new();
493        if let Some(content) = choice.delta.content {
494            events.push(Ok(LanguageModelCompletionEvent::Text(content)));
495        }
496
497        if let Some(reasoning_content) = choice.delta.reasoning_content {
498            events.push(Ok(LanguageModelCompletionEvent::Thinking {
499                text: reasoning_content,
500                signature: None,
501            }));
502        }
503
504        if let Some(tool_calls) = choice.delta.tool_calls {
505            for tool_call in tool_calls {
506                let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
507
508                if let Some(tool_id) = tool_call.id {
509                    entry.id = tool_id;
510                }
511
512                if let Some(function) = tool_call.function {
513                    if let Some(name) = function.name {
514                        // At the time of writing this code LM Studio (0.3.15) is incompatible with the OpenAI API:
515                        // 1. It sends function name in the first chunk
516                        // 2. It sends empty string in the function name field in all subsequent chunks for arguments
517                        // According to https://platform.openai.com/docs/guides/function-calling?api-mode=responses#streaming
518                        // function name field should be sent only inside the first chunk.
519                        if !name.is_empty() {
520                            entry.name = name;
521                        }
522                    }
523
524                    if let Some(arguments) = function.arguments {
525                        entry.arguments.push_str(&arguments);
526                    }
527                }
528            }
529        }
530
531        if let Some(usage) = event.usage {
532            events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage {
533                input_tokens: usage.prompt_tokens,
534                output_tokens: usage.completion_tokens,
535                cache_creation_input_tokens: 0,
536                cache_read_input_tokens: 0,
537            })));
538        }
539
540        match choice.finish_reason.as_deref() {
541            Some("stop") => {
542                events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
543            }
544            Some("tool_calls") => {
545                events.extend(self.tool_calls_by_index.drain().map(|(_, tool_call)| {
546                    match serde_json::Value::from_str(&tool_call.arguments) {
547                        Ok(input) => Ok(LanguageModelCompletionEvent::ToolUse(
548                            LanguageModelToolUse {
549                                id: tool_call.id.into(),
550                                name: tool_call.name.into(),
551                                is_input_complete: true,
552                                input,
553                                raw_input: tool_call.arguments,
554                            },
555                        )),
556                        Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
557                            id: tool_call.id.into(),
558                            tool_name: tool_call.name.into(),
559                            raw_input: tool_call.arguments.into(),
560                            json_parse_error: error.to_string(),
561                        }),
562                    }
563                }));
564
565                events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse)));
566            }
567            Some(stop_reason) => {
568                log::error!("Unexpected LMStudio stop_reason: {stop_reason:?}",);
569                events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)));
570            }
571            None => {}
572        }
573
574        events
575    }
576}
577
578#[derive(Default)]
579struct RawToolCall {
580    id: String,
581    name: String,
582    arguments: String,
583}
584
585fn add_message_content_part(
586    new_part: lmstudio::MessagePart,
587    role: Role,
588    messages: &mut Vec<lmstudio::ChatMessage>,
589) {
590    match (role, messages.last_mut()) {
591        (Role::User, Some(lmstudio::ChatMessage::User { content }))
592        | (
593            Role::Assistant,
594            Some(lmstudio::ChatMessage::Assistant {
595                content: Some(content),
596                ..
597            }),
598        )
599        | (Role::System, Some(lmstudio::ChatMessage::System { content })) => {
600            content.push_part(new_part);
601        }
602        _ => {
603            messages.push(match role {
604                Role::User => lmstudio::ChatMessage::User {
605                    content: lmstudio::MessageContent::from(vec![new_part]),
606                },
607                Role::Assistant => lmstudio::ChatMessage::Assistant {
608                    content: Some(lmstudio::MessageContent::from(vec![new_part])),
609                    tool_calls: Vec::new(),
610                },
611                Role::System => lmstudio::ChatMessage::System {
612                    content: lmstudio::MessageContent::from(vec![new_part]),
613                },
614            });
615        }
616    }
617}
618
619struct ConfigurationView {
620    state: gpui::Entity<State>,
621    loading_models_task: Option<Task<()>>,
622}
623
624impl ConfigurationView {
625    pub fn new(state: gpui::Entity<State>, cx: &mut Context<Self>) -> Self {
626        let loading_models_task = Some(cx.spawn({
627            let state = state.clone();
628            async move |this, cx| {
629                if let Some(task) = state
630                    .update(cx, |state, cx| state.authenticate(cx))
631                    .log_err()
632                {
633                    task.await.log_err();
634                }
635                this.update(cx, |this, cx| {
636                    this.loading_models_task = None;
637                    cx.notify();
638                })
639                .log_err();
640            }
641        }));
642
643        Self {
644            state,
645            loading_models_task,
646        }
647    }
648
649    fn retry_connection(&self, cx: &mut App) {
650        self.state
651            .update(cx, |state, cx| state.fetch_models(cx))
652            .detach_and_log_err(cx);
653    }
654}
655
656impl Render for ConfigurationView {
657    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
658        let is_authenticated = self.state.read(cx).is_authenticated();
659
660        let lmstudio_intro = "Run local LLMs like Llama, Phi, and Qwen.";
661
662        if self.loading_models_task.is_some() {
663            div().child(Label::new("Loading models...")).into_any()
664        } else {
665            v_flex()
666                .gap_2()
667                .child(
668                    v_flex().gap_1().child(Label::new(lmstudio_intro)).child(
669                        List::new()
670                            .child(InstructionListItem::text_only(
671                                "LM Studio needs to be running with at least one model downloaded.",
672                            ))
673                            .child(InstructionListItem::text_only(
674                                "To get your first model, try running `lms get qwen2.5-coder-7b`",
675                            )),
676                    ),
677                )
678                .child(
679                    h_flex()
680                        .w_full()
681                        .justify_between()
682                        .gap_2()
683                        .child(
684                            h_flex()
685                                .w_full()
686                                .gap_2()
687                                .map(|this| {
688                                    if is_authenticated {
689                                        this.child(
690                                            Button::new("lmstudio-site", "LM Studio")
691                                                .style(ButtonStyle::Subtle)
692                                                .icon(IconName::ArrowUpRight)
693                                                .icon_size(IconSize::Small)
694                                                .icon_color(Color::Muted)
695                                                .on_click(move |_, _window, cx| {
696                                                    cx.open_url(LMSTUDIO_SITE)
697                                                })
698                                                .into_any_element(),
699                                        )
700                                    } else {
701                                        this.child(
702                                            Button::new(
703                                                "download_lmstudio_button",
704                                                "Download LM Studio",
705                                            )
706                                            .style(ButtonStyle::Subtle)
707                                            .icon(IconName::ArrowUpRight)
708                                            .icon_size(IconSize::Small)
709                                            .icon_color(Color::Muted)
710                                            .on_click(move |_, _window, cx| {
711                                                cx.open_url(LMSTUDIO_DOWNLOAD_URL)
712                                            })
713                                            .into_any_element(),
714                                        )
715                                    }
716                                })
717                                .child(
718                                    Button::new("view-models", "Model Catalog")
719                                        .style(ButtonStyle::Subtle)
720                                        .icon(IconName::ArrowUpRight)
721                                        .icon_size(IconSize::Small)
722                                        .icon_color(Color::Muted)
723                                        .on_click(move |_, _window, cx| {
724                                            cx.open_url(LMSTUDIO_CATALOG_URL)
725                                        }),
726                                ),
727                        )
728                        .map(|this| {
729                            if is_authenticated {
730                                this.child(
731                                    ButtonLike::new("connected")
732                                        .disabled(true)
733                                        .cursor_style(gpui::CursorStyle::Arrow)
734                                        .child(
735                                            h_flex()
736                                                .gap_2()
737                                                .child(Indicator::dot().color(Color::Success))
738                                                .child(Label::new("Connected"))
739                                                .into_any_element(),
740                                        ),
741                                )
742                            } else {
743                                this.child(
744                                    Button::new("retry_lmstudio_models", "Connect")
745                                        .icon_position(IconPosition::Start)
746                                        .icon_size(IconSize::XSmall)
747                                        .icon(IconName::PlayFilled)
748                                        .on_click(cx.listener(move |this, _, _window, cx| {
749                                            this.retry_connection(cx)
750                                        })),
751                                )
752                            }
753                        }),
754                )
755                .into_any()
756        }
757    }
758}