language_model_selector.rs

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