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}