model_selector.rs

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