model_selector.rs

  1use std::{cmp::Reverse, rc::Rc, sync::Arc};
  2
  3use acp_thread::{AgentModelInfo, AgentModelList, AgentModelSelector};
  4use agent_client_protocol::ModelId;
  5use agent_servers::AgentServer;
  6use agent_settings::AgentSettings;
  7use anyhow::Result;
  8use collections::{HashSet, IndexMap};
  9use fs::Fs;
 10use futures::FutureExt;
 11use fuzzy::{StringMatchCandidate, match_strings};
 12use gpui::{
 13    Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Task, WeakEntity,
 14};
 15use itertools::Itertools;
 16use language_model::IconOrSvg;
 17use ordered_float::OrderedFloat;
 18use picker::{Picker, PickerDelegate};
 19use settings::Settings;
 20use ui::{DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, prelude::*};
 21use util::ResultExt;
 22use zed_actions::agent::OpenSettings;
 23
 24use crate::ui::{HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem};
 25
 26pub type AcpModelSelector = Picker<AcpModelPickerDelegate>;
 27
 28pub fn acp_model_selector(
 29    selector: Rc<dyn AgentModelSelector>,
 30    agent_server: Rc<dyn AgentServer>,
 31    fs: Arc<dyn Fs>,
 32    focus_handle: FocusHandle,
 33    window: &mut Window,
 34    cx: &mut Context<AcpModelSelector>,
 35) -> AcpModelSelector {
 36    let delegate =
 37        AcpModelPickerDelegate::new(selector, agent_server, fs, focus_handle, window, cx);
 38    Picker::list(delegate, window, cx)
 39        .show_scrollbar(true)
 40        .width(rems(20.))
 41        .max_height(Some(rems(20.).into()))
 42}
 43
 44enum AcpModelPickerEntry {
 45    Separator(SharedString),
 46    Model(AgentModelInfo, bool),
 47}
 48
 49pub struct AcpModelPickerDelegate {
 50    selector: Rc<dyn AgentModelSelector>,
 51    agent_server: Rc<dyn AgentServer>,
 52    fs: Arc<dyn Fs>,
 53    filtered_entries: Vec<AcpModelPickerEntry>,
 54    models: Option<AgentModelList>,
 55    selected_index: usize,
 56    selected_description: Option<(usize, SharedString, bool)>,
 57    selected_model: Option<AgentModelInfo>,
 58    _refresh_models_task: Task<()>,
 59    focus_handle: FocusHandle,
 60}
 61
 62impl AcpModelPickerDelegate {
 63    fn new(
 64        selector: Rc<dyn AgentModelSelector>,
 65        agent_server: Rc<dyn AgentServer>,
 66        fs: Arc<dyn Fs>,
 67        focus_handle: FocusHandle,
 68        window: &mut Window,
 69        cx: &mut Context<AcpModelSelector>,
 70    ) -> Self {
 71        let rx = selector.watch(cx);
 72        let refresh_models_task = {
 73            cx.spawn_in(window, {
 74                async move |this, cx| {
 75                    async fn refresh(
 76                        this: &WeakEntity<Picker<AcpModelPickerDelegate>>,
 77                        cx: &mut AsyncWindowContext,
 78                    ) -> Result<()> {
 79                        let (models_task, selected_model_task) = this.update(cx, |this, cx| {
 80                            (
 81                                this.delegate.selector.list_models(cx),
 82                                this.delegate.selector.selected_model(cx),
 83                            )
 84                        })?;
 85
 86                        let (models, selected_model) =
 87                            futures::join!(models_task, selected_model_task);
 88
 89                        this.update_in(cx, |this, window, cx| {
 90                            this.delegate.models = models.ok();
 91                            this.delegate.selected_model = selected_model.ok();
 92                            this.refresh(window, cx)
 93                        })
 94                    }
 95
 96                    refresh(&this, cx).await.log_err();
 97                    if let Some(mut rx) = rx {
 98                        while let Ok(()) = rx.recv().await {
 99                            refresh(&this, cx).await.log_err();
100                        }
101                    }
102                }
103            })
104        };
105
106        Self {
107            selector,
108            agent_server,
109            fs,
110            filtered_entries: Vec::new(),
111            models: None,
112            selected_model: None,
113            selected_index: 0,
114            selected_description: None,
115            _refresh_models_task: refresh_models_task,
116            focus_handle,
117        }
118    }
119
120    pub fn active_model(&self) -> Option<&AgentModelInfo> {
121        self.selected_model.as_ref()
122    }
123
124    pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
125        if !self.selector.supports_favorites() {
126            return;
127        }
128
129        let favorites = AgentSettings::get_global(cx).favorite_model_ids();
130
131        if favorites.is_empty() {
132            return;
133        }
134
135        let Some(models) = self.models.clone() else {
136            return;
137        };
138
139        let all_models: Vec<AgentModelInfo> = match models {
140            AgentModelList::Flat(list) => list,
141            AgentModelList::Grouped(index_map) => index_map
142                .into_values()
143                .flatten()
144                .collect::<Vec<AgentModelInfo>>(),
145        };
146
147        let favorite_models = all_models
148            .iter()
149            .filter(|model| favorites.contains(&model.id))
150            .unique_by(|model| &model.id)
151            .cloned()
152            .collect::<Vec<_>>();
153
154        let current_id = self.selected_model.as_ref().map(|m| m.id.clone());
155
156        let current_index_in_favorites = current_id
157            .as_ref()
158            .and_then(|id| favorite_models.iter().position(|m| &m.id == id))
159            .unwrap_or(usize::MAX);
160
161        let next_index = if current_index_in_favorites == usize::MAX {
162            0
163        } else {
164            (current_index_in_favorites + 1) % favorite_models.len()
165        };
166
167        let next_model = favorite_models[next_index].clone();
168
169        self.selector
170            .select_model(next_model.id.clone(), cx)
171            .detach_and_log_err(cx);
172
173        self.selected_model = Some(next_model);
174
175        // Keep the picker selection aligned with the newly-selected model
176        if let Some(new_index) = self.filtered_entries.iter().position(|entry| {
177            matches!(entry, AcpModelPickerEntry::Model(model_info, _) if self.selected_model.as_ref().is_some_and(|selected| model_info.id == selected.id))
178        }) {
179            self.set_selected_index(new_index, window, cx);
180        } else {
181            cx.notify();
182        }
183    }
184}
185
186impl PickerDelegate for AcpModelPickerDelegate {
187    type ListItem = AnyElement;
188
189    fn match_count(&self) -> usize {
190        self.filtered_entries.len()
191    }
192
193    fn selected_index(&self) -> usize {
194        self.selected_index
195    }
196
197    fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
198        self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
199        cx.notify();
200    }
201
202    fn can_select(
203        &mut self,
204        ix: usize,
205        _window: &mut Window,
206        _cx: &mut Context<Picker<Self>>,
207    ) -> bool {
208        match self.filtered_entries.get(ix) {
209            Some(AcpModelPickerEntry::Model(_, _)) => true,
210            Some(AcpModelPickerEntry::Separator(_)) | None => false,
211        }
212    }
213
214    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
215        "Select a model…".into()
216    }
217
218    fn update_matches(
219        &mut self,
220        query: String,
221        window: &mut Window,
222        cx: &mut Context<Picker<Self>>,
223    ) -> Task<()> {
224        let favorites = if self.selector.supports_favorites() {
225            AgentSettings::get_global(cx).favorite_model_ids()
226        } else {
227            Default::default()
228        };
229
230        cx.spawn_in(window, async move |this, cx| {
231            let filtered_models = match this
232                .read_with(cx, |this, cx| {
233                    this.delegate.models.clone().map(move |models| {
234                        fuzzy_search(models, query, cx.background_executor().clone())
235                    })
236                })
237                .ok()
238                .flatten()
239            {
240                Some(task) => task.await,
241                None => AgentModelList::Flat(vec![]),
242            };
243
244            this.update_in(cx, |this, window, cx| {
245                this.delegate.filtered_entries =
246                    info_list_to_picker_entries(filtered_models, &favorites);
247                // Finds the currently selected model in the list
248                let new_index = this
249                    .delegate
250                    .selected_model
251                    .as_ref()
252                    .and_then(|selected| {
253                        this.delegate.filtered_entries.iter().position(|entry| {
254                            if let AcpModelPickerEntry::Model(model_info, _) = entry {
255                                model_info.id == selected.id
256                            } else {
257                                false
258                            }
259                        })
260                    })
261                    .unwrap_or(0);
262                this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx);
263                cx.notify();
264            })
265            .ok();
266        })
267    }
268
269    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
270        if let Some(AcpModelPickerEntry::Model(model_info, _)) =
271            self.filtered_entries.get(self.selected_index)
272        {
273            if window.modifiers().secondary() {
274                let default_model = self.agent_server.default_model(cx);
275                let is_default = default_model.as_ref() == Some(&model_info.id);
276
277                self.agent_server.set_default_model(
278                    if is_default {
279                        None
280                    } else {
281                        Some(model_info.id.clone())
282                    },
283                    self.fs.clone(),
284                    cx,
285                );
286            }
287
288            self.selector
289                .select_model(model_info.id.clone(), cx)
290                .detach_and_log_err(cx);
291            self.selected_model = Some(model_info.clone());
292            let current_index = self.selected_index;
293            self.set_selected_index(current_index, window, cx);
294
295            cx.emit(DismissEvent);
296        }
297    }
298
299    fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
300        cx.defer_in(window, |picker, window, cx| {
301            picker.set_query("", window, cx);
302        });
303    }
304
305    fn render_match(
306        &self,
307        ix: usize,
308        selected: bool,
309        _: &mut Window,
310        cx: &mut Context<Picker<Self>>,
311    ) -> Option<Self::ListItem> {
312        match self.filtered_entries.get(ix)? {
313            AcpModelPickerEntry::Separator(title) => {
314                Some(ModelSelectorHeader::new(title, ix > 1).into_any_element())
315            }
316            AcpModelPickerEntry::Model(model_info, is_favorite) => {
317                let is_selected = Some(model_info) == self.selected_model.as_ref();
318                let default_model = self.agent_server.default_model(cx);
319                let is_default = default_model.as_ref() == Some(&model_info.id);
320
321                let supports_favorites = self.selector.supports_favorites();
322
323                let is_favorite = *is_favorite;
324                let handle_action_click = {
325                    let model_id = model_info.id.clone();
326                    let fs = self.fs.clone();
327
328                    move |cx: &App| {
329                        crate::favorite_models::toggle_model_id_in_settings(
330                            model_id.clone(),
331                            !is_favorite,
332                            fs.clone(),
333                            cx,
334                        );
335                    }
336                };
337
338                Some(
339                    div()
340                        .id(("model-picker-menu-child", ix))
341                        .when_some(model_info.description.clone(), |this, description| {
342                            this.on_hover(cx.listener(move |menu, hovered, _, cx| {
343                                if *hovered {
344                                    menu.delegate.selected_description =
345                                        Some((ix, description.clone(), is_default));
346                                } else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix) {
347                                    menu.delegate.selected_description = None;
348                                }
349                                cx.notify();
350                            }))
351                        })
352                        .child(
353                            ModelSelectorListItem::new(ix, model_info.name.clone())
354                                .map(|this| match &model_info.icon {
355                                    Some(IconOrSvg::Svg(path)) => this.icon_path(path.clone()),
356                                    Some(IconOrSvg::Icon(icon)) => this.icon(*icon),
357                                    None => this,
358                                })
359                                .is_selected(is_selected)
360                                .is_focused(selected)
361                                .when(supports_favorites, |this| {
362                                    this.is_favorite(is_favorite)
363                                        .on_toggle_favorite(handle_action_click)
364                                }),
365                        )
366                        .into_any_element(),
367                )
368            }
369        }
370    }
371
372    fn documentation_aside(
373        &self,
374        _window: &mut Window,
375        _cx: &mut Context<Picker<Self>>,
376    ) -> Option<ui::DocumentationAside> {
377        self.selected_description
378            .as_ref()
379            .map(|(_, description, is_default)| {
380                let description = description.clone();
381                let is_default = *is_default;
382
383                DocumentationAside::new(
384                    DocumentationSide::Left,
385                    DocumentationEdge::Top,
386                    Rc::new(move |_| {
387                        v_flex()
388                            .gap_1()
389                            .child(Label::new(description.clone()))
390                            .child(HoldForDefault::new(is_default))
391                            .into_any_element()
392                    }),
393                )
394            })
395    }
396
397    fn render_footer(
398        &self,
399        _window: &mut Window,
400        _cx: &mut Context<Picker<Self>>,
401    ) -> Option<AnyElement> {
402        let focus_handle = self.focus_handle.clone();
403
404        if !self.selector.should_render_footer() {
405            return None;
406        }
407
408        Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element())
409    }
410}
411
412fn info_list_to_picker_entries(
413    model_list: AgentModelList,
414    favorites: &HashSet<ModelId>,
415) -> Vec<AcpModelPickerEntry> {
416    let mut entries = Vec::new();
417
418    let all_models: Vec<_> = match &model_list {
419        AgentModelList::Flat(list) => list.iter().collect(),
420        AgentModelList::Grouped(index_map) => index_map.values().flatten().collect(),
421    };
422
423    let favorite_models: Vec<_> = all_models
424        .iter()
425        .filter(|m| favorites.contains(&m.id))
426        .unique_by(|m| &m.id)
427        .collect();
428
429    let has_favorites = !favorite_models.is_empty();
430    if has_favorites {
431        entries.push(AcpModelPickerEntry::Separator("Favorite".into()));
432        for model in favorite_models {
433            entries.push(AcpModelPickerEntry::Model((*model).clone(), true));
434        }
435    }
436
437    match model_list {
438        AgentModelList::Flat(list) => {
439            if has_favorites {
440                entries.push(AcpModelPickerEntry::Separator("All".into()));
441            }
442            for model in list {
443                let is_favorite = favorites.contains(&model.id);
444                entries.push(AcpModelPickerEntry::Model(model, is_favorite));
445            }
446        }
447        AgentModelList::Grouped(index_map) => {
448            for (group_name, models) in index_map {
449                entries.push(AcpModelPickerEntry::Separator(group_name.0));
450                for model in models {
451                    let is_favorite = favorites.contains(&model.id);
452                    entries.push(AcpModelPickerEntry::Model(model, is_favorite));
453                }
454            }
455        }
456    }
457
458    entries
459}
460
461async fn fuzzy_search(
462    model_list: AgentModelList,
463    query: String,
464    executor: BackgroundExecutor,
465) -> AgentModelList {
466    async fn fuzzy_search_list(
467        model_list: Vec<AgentModelInfo>,
468        query: &str,
469        executor: BackgroundExecutor,
470    ) -> Vec<AgentModelInfo> {
471        let candidates = model_list
472            .iter()
473            .enumerate()
474            .map(|(ix, model)| StringMatchCandidate::new(ix, model.name.as_ref()))
475            .collect::<Vec<_>>();
476        let mut matches = match_strings(
477            &candidates,
478            query,
479            false,
480            true,
481            100,
482            &Default::default(),
483            executor,
484        )
485        .await;
486
487        matches.sort_unstable_by_key(|mat| {
488            let candidate = &candidates[mat.candidate_id];
489            (Reverse(OrderedFloat(mat.score)), candidate.id)
490        });
491
492        matches
493            .into_iter()
494            .map(|mat| model_list[mat.candidate_id].clone())
495            .collect()
496    }
497
498    match model_list {
499        AgentModelList::Flat(model_list) => {
500            AgentModelList::Flat(fuzzy_search_list(model_list, &query, executor).await)
501        }
502        AgentModelList::Grouped(index_map) => {
503            let groups =
504                futures::future::join_all(index_map.into_iter().map(|(group_name, models)| {
505                    fuzzy_search_list(models, &query, executor.clone())
506                        .map(|results| (group_name, results))
507                }))
508                .await;
509            AgentModelList::Grouped(IndexMap::from_iter(
510                groups
511                    .into_iter()
512                    .filter(|(_, results)| !results.is_empty()),
513            ))
514        }
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use agent_client_protocol as acp;
521    use gpui::TestAppContext;
522
523    use super::*;
524
525    fn create_model_list(grouped_models: Vec<(&str, Vec<&str>)>) -> AgentModelList {
526        AgentModelList::Grouped(IndexMap::from_iter(grouped_models.into_iter().map(
527            |(group, models)| {
528                (
529                    acp_thread::AgentModelGroupName(group.to_string().into()),
530                    models
531                        .into_iter()
532                        .map(|model| acp_thread::AgentModelInfo {
533                            id: acp::ModelId::new(model.to_string()),
534                            name: model.to_string().into(),
535                            description: None,
536                            icon: None,
537                        })
538                        .collect::<Vec<_>>(),
539                )
540            },
541        )))
542    }
543
544    fn assert_models_eq(result: AgentModelList, expected: Vec<(&str, Vec<&str>)>) {
545        let AgentModelList::Grouped(groups) = result else {
546            panic!("Expected LanguageModelInfoList::Grouped, got {:?}", result);
547        };
548
549        assert_eq!(
550            groups.len(),
551            expected.len(),
552            "Number of groups doesn't match"
553        );
554
555        for (i, (expected_group, expected_models)) in expected.iter().enumerate() {
556            let (actual_group, actual_models) = groups.get_index(i).unwrap();
557            assert_eq!(
558                actual_group.0.as_ref(),
559                *expected_group,
560                "Group at position {} doesn't match expected group",
561                i
562            );
563            assert_eq!(
564                actual_models.len(),
565                expected_models.len(),
566                "Number of models in group {} doesn't match",
567                expected_group
568            );
569
570            for (j, expected_model_name) in expected_models.iter().enumerate() {
571                assert_eq!(
572                    actual_models[j].name, *expected_model_name,
573                    "Model at position {} in group {} doesn't match expected model",
574                    j, expected_group
575                );
576            }
577        }
578    }
579
580    fn create_favorites(models: Vec<&str>) -> HashSet<ModelId> {
581        models
582            .into_iter()
583            .map(|m| ModelId::new(m.to_string()))
584            .collect()
585    }
586
587    fn get_entry_model_ids(entries: &[AcpModelPickerEntry]) -> Vec<&str> {
588        entries
589            .iter()
590            .filter_map(|entry| match entry {
591                AcpModelPickerEntry::Model(info, _) => Some(info.id.0.as_ref()),
592                _ => None,
593            })
594            .collect()
595    }
596
597    fn get_entry_labels(entries: &[AcpModelPickerEntry]) -> Vec<&str> {
598        entries
599            .iter()
600            .map(|entry| match entry {
601                AcpModelPickerEntry::Model(info, _) => info.id.0.as_ref(),
602                AcpModelPickerEntry::Separator(s) => &s,
603            })
604            .collect()
605    }
606
607    #[gpui::test]
608    fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) {
609        let models = create_model_list(vec![
610            ("zed", vec!["zed/claude", "zed/gemini"]),
611            ("openai", vec!["openai/gpt-5"]),
612        ]);
613        let favorites = create_favorites(vec!["zed/gemini"]);
614
615        let entries = info_list_to_picker_entries(models, &favorites);
616
617        assert!(matches!(
618            entries.first(),
619            Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite"
620        ));
621
622        let model_ids = get_entry_model_ids(&entries);
623        assert_eq!(model_ids[0], "zed/gemini");
624    }
625
626    #[gpui::test]
627    fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) {
628        let models = create_model_list(vec![("zed", vec!["zed/claude", "zed/gemini"])]);
629        let favorites = create_favorites(vec![]);
630
631        let entries = info_list_to_picker_entries(models, &favorites);
632
633        assert!(matches!(
634            entries.first(),
635            Some(AcpModelPickerEntry::Separator(s)) if s == "zed"
636        ));
637    }
638
639    #[gpui::test]
640    fn test_models_have_correct_actions(_cx: &mut TestAppContext) {
641        let models = create_model_list(vec![
642            ("zed", vec!["zed/claude", "zed/gemini"]),
643            ("openai", vec!["openai/gpt-5"]),
644        ]);
645        let favorites = create_favorites(vec!["zed/claude"]);
646
647        let entries = info_list_to_picker_entries(models, &favorites);
648
649        for entry in &entries {
650            if let AcpModelPickerEntry::Model(info, is_favorite) = entry {
651                if info.id.0.as_ref() == "zed/claude" {
652                    assert!(is_favorite, "zed/claude should be a favorite");
653                } else {
654                    assert!(!is_favorite, "{} should not be a favorite", info.id.0);
655                }
656            }
657        }
658    }
659
660    #[gpui::test]
661    fn test_favorites_appear_in_both_sections(_cx: &mut TestAppContext) {
662        let models = create_model_list(vec![
663            ("zed", vec!["zed/claude", "zed/gemini"]),
664            ("openai", vec!["openai/gpt-5", "openai/gpt-4"]),
665        ]);
666        let favorites = create_favorites(vec!["zed/gemini", "openai/gpt-5"]);
667
668        let entries = info_list_to_picker_entries(models, &favorites);
669        let model_ids = get_entry_model_ids(&entries);
670
671        assert_eq!(model_ids[0], "zed/gemini");
672        assert_eq!(model_ids[1], "openai/gpt-5");
673
674        assert!(model_ids[2..].contains(&"zed/gemini"));
675        assert!(model_ids[2..].contains(&"openai/gpt-5"));
676    }
677
678    #[gpui::test]
679    fn test_favorites_are_not_duplicated_when_repeated_in_other_sections(_cx: &mut TestAppContext) {
680        let models = create_model_list(vec![
681            ("Recommended", vec!["zed/claude", "anthropic/claude"]),
682            ("Zed", vec!["zed/claude", "zed/gpt-5"]),
683            ("Antropic", vec!["anthropic/claude"]),
684            ("OpenAI", vec!["openai/gpt-5"]),
685        ]);
686
687        let favorites = create_favorites(vec!["zed/claude"]);
688
689        let entries = info_list_to_picker_entries(models, &favorites);
690        let labels = get_entry_labels(&entries);
691
692        assert_eq!(
693            labels,
694            vec![
695                "Favorite",
696                "zed/claude",
697                "Recommended",
698                "zed/claude",
699                "anthropic/claude",
700                "Zed",
701                "zed/claude",
702                "zed/gpt-5",
703                "Antropic",
704                "anthropic/claude",
705                "OpenAI",
706                "openai/gpt-5"
707            ]
708        );
709    }
710
711    #[gpui::test]
712    fn test_flat_model_list_with_favorites(_cx: &mut TestAppContext) {
713        let models = AgentModelList::Flat(vec![
714            acp_thread::AgentModelInfo {
715                id: acp::ModelId::new("zed/claude".to_string()),
716                name: "Claude".into(),
717                description: None,
718                icon: None,
719            },
720            acp_thread::AgentModelInfo {
721                id: acp::ModelId::new("zed/gemini".to_string()),
722                name: "Gemini".into(),
723                description: None,
724                icon: None,
725            },
726        ]);
727        let favorites = create_favorites(vec!["zed/gemini"]);
728
729        let entries = info_list_to_picker_entries(models, &favorites);
730
731        assert!(matches!(
732            entries.first(),
733            Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite"
734        ));
735
736        assert!(entries.iter().any(|e| matches!(
737            e,
738            AcpModelPickerEntry::Separator(s) if s == "All"
739        )));
740    }
741
742    #[gpui::test]
743    async fn test_fuzzy_match(cx: &mut TestAppContext) {
744        let models = create_model_list(vec![
745            (
746                "zed",
747                vec![
748                    "Claude 3.7 Sonnet",
749                    "Claude 3.7 Sonnet Thinking",
750                    "gpt-4.1",
751                    "gpt-4.1-nano",
752                ],
753            ),
754            ("openai", vec!["gpt-3.5-turbo", "gpt-4.1", "gpt-4.1-nano"]),
755            ("ollama", vec!["mistral", "deepseek"]),
756        ]);
757
758        // Results should preserve models order whenever possible.
759        // In the case below, `zed/gpt-4.1` and `openai/gpt-4.1` have identical
760        // similarity scores, but `zed/gpt-4.1` was higher in the models list,
761        // so it should appear first in the results.
762        let results = fuzzy_search(models.clone(), "41".into(), cx.executor()).await;
763        assert_models_eq(
764            results,
765            vec![
766                ("zed", vec!["gpt-4.1", "gpt-4.1-nano"]),
767                ("openai", vec!["gpt-4.1", "gpt-4.1-nano"]),
768            ],
769        );
770
771        // Fuzzy search
772        let results = fuzzy_search(models.clone(), "4n".into(), cx.executor()).await;
773        assert_models_eq(
774            results,
775            vec![
776                ("zed", vec!["gpt-4.1-nano"]),
777                ("openai", vec!["gpt-4.1-nano"]),
778            ],
779        );
780    }
781}