1use std::{cmp::Reverse, sync::Arc};
2
3use collections::{HashSet, IndexMap};
4use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
5use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task};
6use language_model::{
7 AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId,
8 LanguageModelRegistry,
9};
10use ordered_float::OrderedFloat;
11use picker::{Picker, PickerDelegate};
12use ui::{ListItem, ListItemSpacing, prelude::*};
13
14type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
15type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
16
17pub type LanguageModelSelector = Picker<LanguageModelPickerDelegate>;
18
19pub fn language_model_selector(
20 get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
21 on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
22 popover_styles: bool,
23 window: &mut Window,
24 cx: &mut Context<LanguageModelSelector>,
25) -> LanguageModelSelector {
26 let delegate = LanguageModelPickerDelegate::new(
27 get_active_model,
28 on_model_changed,
29 popover_styles,
30 window,
31 cx,
32 );
33
34 if popover_styles {
35 Picker::list(delegate, window, cx)
36 .show_scrollbar(true)
37 .width(rems(20.))
38 .max_height(Some(rems(20.).into()))
39 } else {
40 Picker::list(delegate, window, cx).show_scrollbar(true)
41 }
42}
43
44fn all_models(cx: &App) -> GroupedModels {
45 let providers = LanguageModelRegistry::global(cx).read(cx).providers();
46
47 let recommended = providers
48 .iter()
49 .flat_map(|provider| {
50 provider
51 .recommended_models(cx)
52 .into_iter()
53 .map(|model| ModelInfo {
54 model,
55 icon: provider.icon(),
56 })
57 })
58 .collect();
59
60 let other = providers
61 .iter()
62 .flat_map(|provider| {
63 provider
64 .provided_models(cx)
65 .into_iter()
66 .map(|model| ModelInfo {
67 model,
68 icon: provider.icon(),
69 })
70 })
71 .collect();
72
73 GroupedModels::new(other, recommended)
74}
75
76#[derive(Clone)]
77struct ModelInfo {
78 model: Arc<dyn LanguageModel>,
79 icon: IconName,
80}
81
82pub struct LanguageModelPickerDelegate {
83 on_model_changed: OnModelChanged,
84 get_active_model: GetActiveModel,
85 all_models: Arc<GroupedModels>,
86 filtered_entries: Vec<LanguageModelPickerEntry>,
87 selected_index: usize,
88 _authenticate_all_providers_task: Task<()>,
89 _subscriptions: Vec<Subscription>,
90 popover_styles: bool,
91}
92
93impl LanguageModelPickerDelegate {
94 fn new(
95 get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
96 on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
97 popover_styles: bool,
98 window: &mut Window,
99 cx: &mut Context<Picker<Self>>,
100 ) -> Self {
101 let on_model_changed = Arc::new(on_model_changed);
102 let models = all_models(cx);
103 let entries = models.entries();
104
105 Self {
106 on_model_changed,
107 all_models: Arc::new(models),
108 selected_index: Self::get_active_model_index(&entries, get_active_model(cx)),
109 filtered_entries: entries,
110 get_active_model: Arc::new(get_active_model),
111 _authenticate_all_providers_task: Self::authenticate_all_providers(cx),
112 _subscriptions: vec![cx.subscribe_in(
113 &LanguageModelRegistry::global(cx),
114 window,
115 |picker, _, event, window, cx| {
116 match event {
117 language_model::Event::ProviderStateChanged(_)
118 | language_model::Event::AddedProvider(_)
119 | language_model::Event::RemovedProvider(_) => {
120 let query = picker.query(cx);
121 picker.delegate.all_models = Arc::new(all_models(cx));
122 // Update matches will automatically drop the previous task
123 // if we get a provider event again
124 picker.update_matches(query, window, cx)
125 }
126 _ => {}
127 }
128 },
129 )],
130 popover_styles,
131 }
132 }
133
134 fn get_active_model_index(
135 entries: &[LanguageModelPickerEntry],
136 active_model: Option<ConfiguredModel>,
137 ) -> usize {
138 entries
139 .iter()
140 .position(|entry| {
141 if let LanguageModelPickerEntry::Model(model) = entry {
142 active_model
143 .as_ref()
144 .map(|active_model| {
145 active_model.model.id() == model.model.id()
146 && active_model.provider.id() == model.model.provider_id()
147 })
148 .unwrap_or_default()
149 } else {
150 false
151 }
152 })
153 .unwrap_or(0)
154 }
155
156 /// Authenticates all providers in the [`LanguageModelRegistry`].
157 ///
158 /// We do this so that we can populate the language selector with all of the
159 /// models from the configured providers.
160 fn authenticate_all_providers(cx: &mut App) -> Task<()> {
161 let authenticate_all_providers = LanguageModelRegistry::global(cx)
162 .read(cx)
163 .providers()
164 .iter()
165 .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
166 .collect::<Vec<_>>();
167
168 cx.spawn(async move |_cx| {
169 for (provider_id, provider_name, authenticate_task) in authenticate_all_providers {
170 if let Err(err) = authenticate_task.await {
171 if matches!(err, AuthenticateError::CredentialsNotFound) {
172 // Since we're authenticating these providers in the
173 // background for the purposes of populating the
174 // language selector, we don't care about providers
175 // where the credentials are not found.
176 } else {
177 // Some providers have noisy failure states that we
178 // don't want to spam the logs with every time the
179 // language model selector is initialized.
180 //
181 // Ideally these should have more clear failure modes
182 // that we know are safe to ignore here, like what we do
183 // with `CredentialsNotFound` above.
184 match provider_id.0.as_ref() {
185 "lmstudio" | "ollama" => {
186 // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated".
187 //
188 // These fail noisily, so we don't log them.
189 }
190 "copilot_chat" => {
191 // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors.
192 }
193 _ => {
194 log::error!(
195 "Failed to authenticate provider: {}: {err:#}",
196 provider_name.0
197 );
198 }
199 }
200 }
201 }
202 }
203 })
204 }
205
206 pub fn active_model(&self, cx: &App) -> Option<ConfiguredModel> {
207 (self.get_active_model)(cx)
208 }
209}
210
211struct GroupedModels {
212 recommended: Vec<ModelInfo>,
213 other: IndexMap<LanguageModelProviderId, Vec<ModelInfo>>,
214}
215
216impl GroupedModels {
217 pub fn new(other: Vec<ModelInfo>, recommended: Vec<ModelInfo>) -> Self {
218 let recommended_ids = recommended
219 .iter()
220 .map(|info| (info.model.provider_id(), info.model.id()))
221 .collect::<HashSet<_>>();
222
223 let mut other_by_provider: IndexMap<_, Vec<ModelInfo>> = IndexMap::default();
224 for model in other {
225 if recommended_ids.contains(&(model.model.provider_id(), model.model.id())) {
226 continue;
227 }
228
229 let provider = model.model.provider_id();
230 if let Some(models) = other_by_provider.get_mut(&provider) {
231 models.push(model);
232 } else {
233 other_by_provider.insert(provider, vec![model]);
234 }
235 }
236
237 Self {
238 recommended,
239 other: other_by_provider,
240 }
241 }
242
243 fn entries(&self) -> Vec<LanguageModelPickerEntry> {
244 let mut entries = Vec::new();
245
246 if !self.recommended.is_empty() {
247 entries.push(LanguageModelPickerEntry::Separator("Recommended".into()));
248 entries.extend(
249 self.recommended
250 .iter()
251 .map(|info| LanguageModelPickerEntry::Model(info.clone())),
252 );
253 }
254
255 for models in self.other.values() {
256 if models.is_empty() {
257 continue;
258 }
259 entries.push(LanguageModelPickerEntry::Separator(
260 models[0].model.provider_name().0,
261 ));
262 entries.extend(
263 models
264 .iter()
265 .map(|info| LanguageModelPickerEntry::Model(info.clone())),
266 );
267 }
268 entries
269 }
270
271 fn model_infos(&self) -> Vec<ModelInfo> {
272 let other = self
273 .other
274 .values()
275 .flat_map(|model| model.iter())
276 .cloned()
277 .collect::<Vec<_>>();
278 self.recommended
279 .iter()
280 .chain(&other)
281 .cloned()
282 .collect::<Vec<_>>()
283 }
284}
285
286enum LanguageModelPickerEntry {
287 Model(ModelInfo),
288 Separator(SharedString),
289}
290
291struct ModelMatcher {
292 models: Vec<ModelInfo>,
293 bg_executor: BackgroundExecutor,
294 candidates: Vec<StringMatchCandidate>,
295}
296
297impl ModelMatcher {
298 fn new(models: Vec<ModelInfo>, bg_executor: BackgroundExecutor) -> ModelMatcher {
299 let candidates = Self::make_match_candidates(&models);
300 Self {
301 models,
302 bg_executor,
303 candidates,
304 }
305 }
306
307 pub fn fuzzy_search(&self, query: &str) -> Vec<ModelInfo> {
308 let mut matches = self.bg_executor.block(match_strings(
309 &self.candidates,
310 query,
311 false,
312 true,
313 100,
314 &Default::default(),
315 self.bg_executor.clone(),
316 ));
317
318 let sorting_key = |mat: &StringMatch| {
319 let candidate = &self.candidates[mat.candidate_id];
320 (Reverse(OrderedFloat(mat.score)), candidate.id)
321 };
322 matches.sort_unstable_by_key(sorting_key);
323
324 let matched_models: Vec<_> = matches
325 .into_iter()
326 .map(|mat| self.models[mat.candidate_id].clone())
327 .collect();
328
329 matched_models
330 }
331
332 pub fn exact_search(&self, query: &str) -> Vec<ModelInfo> {
333 self.models
334 .iter()
335 .filter(|m| {
336 m.model
337 .name()
338 .0
339 .to_lowercase()
340 .contains(&query.to_lowercase())
341 })
342 .cloned()
343 .collect::<Vec<_>>()
344 }
345
346 fn make_match_candidates(model_infos: &Vec<ModelInfo>) -> Vec<StringMatchCandidate> {
347 model_infos
348 .iter()
349 .enumerate()
350 .map(|(index, model)| {
351 StringMatchCandidate::new(
352 index,
353 &format!(
354 "{}/{}",
355 &model.model.provider_name().0,
356 &model.model.name().0
357 ),
358 )
359 })
360 .collect::<Vec<_>>()
361 }
362}
363
364impl PickerDelegate for LanguageModelPickerDelegate {
365 type ListItem = AnyElement;
366
367 fn match_count(&self) -> usize {
368 self.filtered_entries.len()
369 }
370
371 fn selected_index(&self) -> usize {
372 self.selected_index
373 }
374
375 fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
376 self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
377 cx.notify();
378 }
379
380 fn can_select(
381 &mut self,
382 ix: usize,
383 _window: &mut Window,
384 _cx: &mut Context<Picker<Self>>,
385 ) -> bool {
386 match self.filtered_entries.get(ix) {
387 Some(LanguageModelPickerEntry::Model(_)) => true,
388 Some(LanguageModelPickerEntry::Separator(_)) | None => false,
389 }
390 }
391
392 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
393 "Select a model…".into()
394 }
395
396 fn update_matches(
397 &mut self,
398 query: String,
399 window: &mut Window,
400 cx: &mut Context<Picker<Self>>,
401 ) -> Task<()> {
402 let all_models = self.all_models.clone();
403 let active_model = (self.get_active_model)(cx);
404 let bg_executor = cx.background_executor();
405
406 let language_model_registry = LanguageModelRegistry::global(cx);
407
408 let configured_providers = language_model_registry
409 .read(cx)
410 .providers()
411 .into_iter()
412 .filter(|provider| provider.is_authenticated(cx))
413 .collect::<Vec<_>>();
414
415 let configured_provider_ids = configured_providers
416 .iter()
417 .map(|provider| provider.id())
418 .collect::<Vec<_>>();
419
420 let recommended_models = all_models
421 .recommended
422 .iter()
423 .filter(|m| configured_provider_ids.contains(&m.model.provider_id()))
424 .cloned()
425 .collect::<Vec<_>>();
426
427 let available_models = all_models
428 .model_infos()
429 .iter()
430 .filter(|m| configured_provider_ids.contains(&m.model.provider_id()))
431 .cloned()
432 .collect::<Vec<_>>();
433
434 let matcher_rec = ModelMatcher::new(recommended_models, bg_executor.clone());
435 let matcher_all = ModelMatcher::new(available_models, bg_executor.clone());
436
437 let recommended = matcher_rec.exact_search(&query);
438 let all = matcher_all.fuzzy_search(&query);
439
440 let filtered_models = GroupedModels::new(all, recommended);
441
442 cx.spawn_in(window, async move |this, cx| {
443 this.update_in(cx, |this, window, cx| {
444 this.delegate.filtered_entries = filtered_models.entries();
445 // Finds the currently selected model in the list
446 let new_index =
447 Self::get_active_model_index(&this.delegate.filtered_entries, active_model);
448 this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx);
449 cx.notify();
450 })
451 .ok();
452 })
453 }
454
455 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
456 if let Some(LanguageModelPickerEntry::Model(model_info)) =
457 self.filtered_entries.get(self.selected_index)
458 {
459 let model = model_info.model.clone();
460 (self.on_model_changed)(model.clone(), cx);
461
462 let current_index = self.selected_index;
463 self.set_selected_index(current_index, window, cx);
464
465 cx.emit(DismissEvent);
466 }
467 }
468
469 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
470 cx.emit(DismissEvent);
471 }
472
473 fn render_match(
474 &self,
475 ix: usize,
476 selected: bool,
477 _: &mut Window,
478 cx: &mut Context<Picker<Self>>,
479 ) -> Option<Self::ListItem> {
480 match self.filtered_entries.get(ix)? {
481 LanguageModelPickerEntry::Separator(title) => Some(
482 div()
483 .px_2()
484 .pb_1()
485 .when(ix > 1, |this| {
486 this.mt_1()
487 .pt_2()
488 .border_t_1()
489 .border_color(cx.theme().colors().border_variant)
490 })
491 .child(
492 Label::new(title)
493 .size(LabelSize::XSmall)
494 .color(Color::Muted),
495 )
496 .into_any_element(),
497 ),
498 LanguageModelPickerEntry::Model(model_info) => {
499 let active_model = (self.get_active_model)(cx);
500 let active_provider_id = active_model.as_ref().map(|m| m.provider.id());
501 let active_model_id = active_model.map(|m| m.model.id());
502
503 let is_selected = Some(model_info.model.provider_id()) == active_provider_id
504 && Some(model_info.model.id()) == active_model_id;
505
506 let model_icon_color = if is_selected {
507 Color::Accent
508 } else {
509 Color::Muted
510 };
511
512 Some(
513 ListItem::new(ix)
514 .inset(true)
515 .spacing(ListItemSpacing::Sparse)
516 .toggle_state(selected)
517 .start_slot(
518 Icon::new(model_info.icon)
519 .color(model_icon_color)
520 .size(IconSize::Small),
521 )
522 .child(
523 h_flex()
524 .w_full()
525 .pl_0p5()
526 .gap_1p5()
527 .w(px(240.))
528 .child(Label::new(model_info.model.name().0).truncate()),
529 )
530 .end_slot(div().pr_3().when(is_selected, |this| {
531 this.child(
532 Icon::new(IconName::Check)
533 .color(Color::Accent)
534 .size(IconSize::Small),
535 )
536 }))
537 .into_any_element(),
538 )
539 }
540 }
541 }
542
543 fn render_footer(
544 &self,
545 _window: &mut Window,
546 cx: &mut Context<Picker<Self>>,
547 ) -> Option<gpui::AnyElement> {
548 if !self.popover_styles {
549 return None;
550 }
551
552 Some(
553 h_flex()
554 .w_full()
555 .border_t_1()
556 .border_color(cx.theme().colors().border_variant)
557 .p_1()
558 .gap_4()
559 .justify_between()
560 .child(
561 Button::new("configure", "Configure")
562 .icon(IconName::Settings)
563 .icon_size(IconSize::Small)
564 .icon_color(Color::Muted)
565 .icon_position(IconPosition::Start)
566 .on_click(|_, window, cx| {
567 window.dispatch_action(
568 zed_actions::agent::OpenSettings.boxed_clone(),
569 cx,
570 );
571 }),
572 )
573 .into_any(),
574 )
575 }
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581 use futures::{future::BoxFuture, stream::BoxStream};
582 use gpui::{AsyncApp, TestAppContext, http_client};
583 use language_model::{
584 LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId,
585 LanguageModelName, LanguageModelProviderId, LanguageModelProviderName,
586 LanguageModelRequest, LanguageModelToolChoice,
587 };
588 use ui::IconName;
589
590 #[derive(Clone)]
591 struct TestLanguageModel {
592 name: LanguageModelName,
593 id: LanguageModelId,
594 provider_id: LanguageModelProviderId,
595 provider_name: LanguageModelProviderName,
596 }
597
598 impl TestLanguageModel {
599 fn new(name: &str, provider: &str) -> Self {
600 Self {
601 name: LanguageModelName::from(name.to_string()),
602 id: LanguageModelId::from(name.to_string()),
603 provider_id: LanguageModelProviderId::from(provider.to_string()),
604 provider_name: LanguageModelProviderName::from(provider.to_string()),
605 }
606 }
607 }
608
609 impl LanguageModel for TestLanguageModel {
610 fn id(&self) -> LanguageModelId {
611 self.id.clone()
612 }
613
614 fn name(&self) -> LanguageModelName {
615 self.name.clone()
616 }
617
618 fn provider_id(&self) -> LanguageModelProviderId {
619 self.provider_id.clone()
620 }
621
622 fn provider_name(&self) -> LanguageModelProviderName {
623 self.provider_name.clone()
624 }
625
626 fn supports_tools(&self) -> bool {
627 false
628 }
629
630 fn supports_tool_choice(&self, _choice: LanguageModelToolChoice) -> bool {
631 false
632 }
633
634 fn supports_images(&self) -> bool {
635 false
636 }
637
638 fn telemetry_id(&self) -> String {
639 format!("{}/{}", self.provider_id.0, self.name.0)
640 }
641
642 fn max_token_count(&self) -> u64 {
643 1000
644 }
645
646 fn count_tokens(
647 &self,
648 _: LanguageModelRequest,
649 _: &App,
650 ) -> BoxFuture<'static, http_client::Result<u64>> {
651 unimplemented!()
652 }
653
654 fn stream_completion(
655 &self,
656 _: LanguageModelRequest,
657 _: &AsyncApp,
658 ) -> BoxFuture<
659 'static,
660 Result<
661 BoxStream<
662 'static,
663 Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
664 >,
665 LanguageModelCompletionError,
666 >,
667 > {
668 unimplemented!()
669 }
670 }
671
672 fn create_models(model_specs: Vec<(&str, &str)>) -> Vec<ModelInfo> {
673 model_specs
674 .into_iter()
675 .map(|(provider, name)| ModelInfo {
676 model: Arc::new(TestLanguageModel::new(name, provider)),
677 icon: IconName::Ai,
678 })
679 .collect()
680 }
681
682 fn assert_models_eq(result: Vec<ModelInfo>, expected: Vec<&str>) {
683 assert_eq!(
684 result.len(),
685 expected.len(),
686 "Number of models doesn't match"
687 );
688
689 for (i, expected_name) in expected.iter().enumerate() {
690 assert_eq!(
691 result[i].model.telemetry_id(),
692 *expected_name,
693 "Model at position {} doesn't match expected model",
694 i
695 );
696 }
697 }
698
699 #[gpui::test]
700 fn test_exact_match(cx: &mut TestAppContext) {
701 let models = create_models(vec![
702 ("zed", "Claude 3.7 Sonnet"),
703 ("zed", "Claude 3.7 Sonnet Thinking"),
704 ("zed", "gpt-4.1"),
705 ("zed", "gpt-4.1-nano"),
706 ("openai", "gpt-3.5-turbo"),
707 ("openai", "gpt-4.1"),
708 ("openai", "gpt-4.1-nano"),
709 ("ollama", "mistral"),
710 ("ollama", "deepseek"),
711 ]);
712 let matcher = ModelMatcher::new(models, cx.background_executor.clone());
713
714 // The order of models should be maintained, case doesn't matter
715 let results = matcher.exact_search("GPT-4.1");
716 assert_models_eq(
717 results,
718 vec![
719 "zed/gpt-4.1",
720 "zed/gpt-4.1-nano",
721 "openai/gpt-4.1",
722 "openai/gpt-4.1-nano",
723 ],
724 );
725 }
726
727 #[gpui::test]
728 fn test_fuzzy_match(cx: &mut TestAppContext) {
729 let models = create_models(vec![
730 ("zed", "Claude 3.7 Sonnet"),
731 ("zed", "Claude 3.7 Sonnet Thinking"),
732 ("zed", "gpt-4.1"),
733 ("zed", "gpt-4.1-nano"),
734 ("openai", "gpt-3.5-turbo"),
735 ("openai", "gpt-4.1"),
736 ("openai", "gpt-4.1-nano"),
737 ("ollama", "mistral"),
738 ("ollama", "deepseek"),
739 ]);
740 let matcher = ModelMatcher::new(models, cx.background_executor.clone());
741
742 // Results should preserve models order whenever possible.
743 // In the case below, `zed/gpt-4.1` and `openai/gpt-4.1` have identical
744 // similarity scores, but `zed/gpt-4.1` was higher in the models list,
745 // so it should appear first in the results.
746 let results = matcher.fuzzy_search("41");
747 assert_models_eq(
748 results,
749 vec![
750 "zed/gpt-4.1",
751 "openai/gpt-4.1",
752 "zed/gpt-4.1-nano",
753 "openai/gpt-4.1-nano",
754 ],
755 );
756
757 // Model provider should be searchable as well
758 let results = matcher.fuzzy_search("ol"); // meaning "ollama"
759 assert_models_eq(results, vec!["ollama/mistral", "ollama/deepseek"]);
760
761 // Fuzzy search
762 let results = matcher.fuzzy_search("z4n");
763 assert_models_eq(results, vec!["zed/gpt-4.1-nano"]);
764 }
765
766 #[gpui::test]
767 fn test_exclude_recommended_models(_cx: &mut TestAppContext) {
768 let recommended_models = create_models(vec![("zed", "claude")]);
769 let all_models = create_models(vec![
770 ("zed", "claude"), // Should be filtered out from "other"
771 ("zed", "gemini"),
772 ("copilot", "o3"),
773 ]);
774
775 let grouped_models = GroupedModels::new(all_models, recommended_models);
776
777 let actual_other_models = grouped_models
778 .other
779 .values()
780 .flatten()
781 .cloned()
782 .collect::<Vec<_>>();
783
784 // Recommended models should not appear in "other"
785 assert_models_eq(actual_other_models, vec!["zed/gemini", "copilot/o3"]);
786 }
787
788 #[gpui::test]
789 fn test_dont_exclude_models_from_other_providers(_cx: &mut TestAppContext) {
790 let recommended_models = create_models(vec![("zed", "claude")]);
791 let all_models = create_models(vec![
792 ("zed", "claude"), // Should be filtered out from "other"
793 ("zed", "gemini"),
794 ("copilot", "claude"), // Should not be filtered out from "other"
795 ]);
796
797 let grouped_models = GroupedModels::new(all_models, recommended_models);
798
799 let actual_other_models = grouped_models
800 .other
801 .values()
802 .flatten()
803 .cloned()
804 .collect::<Vec<_>>();
805
806 // Recommended models should not appear in "other"
807 assert_models_eq(actual_other_models, vec!["zed/gemini", "copilot/claude"]);
808 }
809}