Skip to main content

everruns_provider/
model_discovery.rs

1//! Provider model discovery and display ranking.
2//!
3//! Ported from yolop, where every host that offers a model picker had to
4//! reimplement the same three steps: ask the driver for a catalog, fall back to
5//! the OpenAI-compatible `GET <base>/models` for endpoints the drivers decline,
6//! and merge the answer with [`crate::model_profiles`] so bare ids still render
7//! human-readable names. None of that is host-specific, so it lives beside the
8//! driver registry and the profile registry it depends on.
9//!
10//! Discovery is deliberately three-valued: `Ok(None)` means "this provider has
11//! no catalog to offer" and callers should keep their curated suggestions,
12//! while `Err` means the catalog request itself failed.
13
14use crate::driver_helpers::shared_request_http_client;
15use crate::driver_registry::{DiscoveredModel, DriverId, DriverRegistry, ProviderConfig};
16use crate::error::{AgentLoopError, Result};
17use crate::model_profiles::get_model_profile;
18
19/// One model offered by a provider, ready for display: the bare id plus
20/// human-readable metadata merged from the provider's API response and the
21/// model profile registry.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct DiscoveredProviderModel {
24    /// Bare model id, as chat calls and profile lookups expect it.
25    pub model_id: String,
26    /// Human-readable name, when the provider or a profile supplies one.
27    pub display_name: Option<String>,
28    /// Short description, when a profile or the provider supplies one.
29    pub description: Option<String>,
30}
31
32/// Query a provider's models API through its driver.
33///
34/// Returns `Ok(None)` when the provider (or its custom endpoint) does not
35/// support model listing; callers should fall back to curated suggestions in
36/// that case rather than treating it as an error.
37///
38/// Drivers that decline listing for an unrecognized OpenAI-compatible endpoint
39/// (Ollama, Gemini's OpenAI surface, proxies) are retried against
40/// [`list_openai_compatible_models`] when the config carries a base URL.
41pub async fn discover_provider_models(
42    registry: &DriverRegistry,
43    config: &ProviderConfig,
44) -> Result<Option<Vec<DiscoveredProviderModel>>> {
45    // Neither of these has a catalog: the simulator has no API, and Bedrock
46    // model access is an account-level IAM concern rather than a listable one.
47    if matches!(config.provider_type, DriverId::LlmSim | DriverId::Bedrock) {
48        return Ok(None);
49    }
50
51    let driver = registry.create_chat_driver(config)?;
52    let models = match driver.list_models().await? {
53        Some(models) => Some(models),
54        None => match &config.base_url {
55            Some(base_url) => {
56                list_openai_compatible_models(base_url, config.api_key.as_deref()).await?
57            }
58            None => None,
59        },
60    };
61    let Some(models) = models else {
62        return Ok(None);
63    };
64
65    Ok(Some(normalize_and_enrich(&config.provider_type, models)))
66}
67
68/// Normalize discovered ids, sort newest-first, and merge in profile metadata.
69///
70/// Split out from [`discover_provider_models`] so a host that obtained a
71/// catalog some other way (a cached response, a proxy's own endpoint) gets the
72/// same presentation.
73pub fn normalize_and_enrich(
74    provider_type: &DriverId,
75    mut models: Vec<DiscoveredModel>,
76) -> Vec<DiscoveredProviderModel> {
77    for model in models.iter_mut() {
78        // Gemini's OpenAI-compatible surface reports ids as `models/<id>`; the
79        // bare id is what chat calls and profile lookups expect.
80        if let Some(bare) = model.model_id.strip_prefix("models/") {
81            model.model_id = bare.to_string();
82        }
83    }
84    models.sort_by(|a, b| {
85        b.created_at
86            .cmp(&a.created_at)
87            .then_with(|| a.model_id.cmp(&b.model_id))
88    });
89    enrich_with_profiles(provider_type, models)
90}
91
92/// Merge each discovered model with metadata from the model profile registry.
93///
94/// The curated profile wins for descriptions (short, written for display); the
95/// provider's API response wins for display names, since it knows its own
96/// catalog best (e.g. OpenRouter's `name` field), with the profile filling the
97/// gap for APIs that return bare ids (e.g. OpenAI).
98pub fn enrich_with_profiles(
99    provider_type: &DriverId,
100    models: Vec<DiscoveredModel>,
101) -> Vec<DiscoveredProviderModel> {
102    models
103        .into_iter()
104        .map(|model| {
105            let core_profile = get_model_profile(provider_type, &model.model_id);
106            let api_profile = model.discovered_profile;
107            let display_name = model
108                .display_name
109                .filter(|name| !name.is_empty() && *name != model.model_id)
110                .or_else(|| core_profile.as_ref().map(|profile| profile.name.clone()));
111            let description = core_profile
112                .as_ref()
113                .and_then(|profile| profile.description.clone())
114                .or_else(|| {
115                    api_profile
116                        .as_ref()
117                        .and_then(|profile| profile.description.clone())
118                });
119            DiscoveredProviderModel {
120                model_id: model.model_id,
121                display_name,
122                description,
123            }
124        })
125        .collect()
126}
127
128#[derive(serde::Deserialize)]
129struct OpenAiCompatibleModelsResponse {
130    data: Vec<OpenAiCompatibleModel>,
131}
132
133#[derive(serde::Deserialize)]
134struct OpenAiCompatibleModel {
135    id: String,
136    #[serde(default)]
137    created: Option<i64>,
138    #[serde(default)]
139    owned_by: Option<String>,
140}
141
142/// Discovery fallback for OpenAI-compatible endpoints no driver recognizes:
143/// `GET <base>/models` with bearer auth.
144pub async fn list_openai_compatible_models(
145    base_url: &str,
146    api_key: Option<&str>,
147) -> Result<Option<Vec<DiscoveredModel>>> {
148    // THREAT[TM-API-013]: Provider base URLs are org-configurable. Use the
149    // shared client so redirects, private DNS results, and hung responses are
150    // rejected at request time.
151    list_openai_compatible_models_with_client(&shared_request_http_client(), base_url, api_key)
152        .await
153}
154
155async fn list_openai_compatible_models_with_client(
156    client: &reqwest::Client,
157    base_url: &str,
158    api_key: Option<&str>,
159) -> Result<Option<Vec<DiscoveredModel>>> {
160    let url = format!("{}/models", base_url.trim_end_matches('/'));
161    let mut request = client.get(&url);
162    if let Some(key) = api_key {
163        request = request.bearer_auth(key);
164    }
165    let response = request
166        .send()
167        .await
168        .map_err(|error| AgentLoopError::llm(format!("fetch models from {url}: {error}")))?;
169    if !response.status().is_success() {
170        return Err(AgentLoopError::llm(format!(
171            "models API at {url} returned {}",
172            response.status()
173        )));
174    }
175    let parsed: OpenAiCompatibleModelsResponse = response.json().await.map_err(|error| {
176        AgentLoopError::llm(format!("parse models response from {url}: {error}"))
177    })?;
178    let models = parsed
179        .data
180        .into_iter()
181        .map(|model| DiscoveredModel {
182            capabilities: vec!["chat".to_string()],
183            created_at: model
184                .created
185                .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)),
186            display_name: None,
187            owned_by: model.owned_by,
188            model_id: model.id,
189            discovered_profile: None,
190        })
191        .collect();
192    Ok(Some(models))
193}
194
195/// Models reordered for display, plus how many leading entries belong in the
196/// recommended section.
197#[derive(Clone, Debug, PartialEq, Eq)]
198pub struct RankedDiscoveredModels {
199    /// Recommended models first, then the rest of the catalog.
200    pub models: Vec<DiscoveredProviderModel>,
201    /// How many leading entries of `models` are recommendations.
202    pub recommended_count: usize,
203}
204
205/// Cap on the recommended block, so it stays a shortlist rather than a second
206/// full catalog.
207const RECOMMENDED_CAP: usize = 20;
208
209/// Reorder discovered models for a picker.
210///
211/// Aggregator catalogs (OpenRouter lists several hundred models) get a short
212/// recommended block — `curated` ids that are actually offered, then the active
213/// model, then profile-known flagships from major vendors — followed by the rest
214/// sorted by id. Single-vendor providers already return a useful order
215/// (newest-first from discovery) and are left alone.
216///
217/// `curated` entries may carry a trailing reasoning-effort suffix
218/// (`"vendor/model high"`); only the leading token is matched.
219pub fn rank_discovered_models(
220    provider_type: &DriverId,
221    models: Vec<DiscoveredProviderModel>,
222    current_model: Option<&str>,
223    curated: &[&str],
224) -> RankedDiscoveredModels {
225    if matches!(provider_type, DriverId::OpenRouter) {
226        rank_aggregator_models(provider_type, models, current_model, curated)
227    } else {
228        RankedDiscoveredModels {
229            recommended_count: 0,
230            models,
231        }
232    }
233}
234
235fn rank_aggregator_models(
236    provider_type: &DriverId,
237    models: Vec<DiscoveredProviderModel>,
238    current_model: Option<&str>,
239    curated: &[&str],
240) -> RankedDiscoveredModels {
241    let mut recommended_ids: Vec<String> = Vec::new();
242
243    for suggestion in curated {
244        let bare = bare_model_id(suggestion);
245        if models.iter().any(|model| model.model_id == bare) {
246            push_unique(&mut recommended_ids, bare.to_string());
247        }
248    }
249
250    if let Some(current) = current_model.map(bare_model_id)
251        && models.iter().any(|model| model.model_id == current)
252    {
253        push_unique(&mut recommended_ids, current.to_string());
254    }
255
256    let mut profile_candidates: Vec<String> = models
257        .iter()
258        .filter(|model| {
259            !recommended_ids.contains(&model.model_id)
260                && is_major_vendor_model(&model.model_id)
261                && get_model_profile(provider_type, &model.model_id).is_some()
262        })
263        .map(|model| model.model_id.clone())
264        .collect();
265    profile_candidates.sort();
266    for model_id in profile_candidates {
267        if recommended_ids.len() >= RECOMMENDED_CAP {
268            break;
269        }
270        push_unique(&mut recommended_ids, model_id);
271    }
272
273    let recommended_count = recommended_ids.len();
274    let mut ranked = Vec::with_capacity(models.len());
275    for model_id in &recommended_ids {
276        if let Some(index) = models.iter().position(|model| &model.model_id == model_id) {
277            ranked.push(models[index].clone());
278        }
279    }
280
281    let mut rest: Vec<DiscoveredProviderModel> = models
282        .into_iter()
283        .filter(|model| !recommended_ids.contains(&model.model_id))
284        .collect();
285    rest.sort_by(|a, b| a.model_id.cmp(&b.model_id));
286    ranked.extend(rest);
287
288    RankedDiscoveredModels {
289        models: ranked,
290        recommended_count,
291    }
292}
293
294/// Strip a trailing reasoning-effort suffix from a model spec
295/// (`"nvidia/nemotron-3 high"` → `"nvidia/nemotron-3"`).
296pub fn bare_model_id(spec: &str) -> &str {
297    spec.split_whitespace().next().unwrap_or(spec)
298}
299
300fn push_unique(ids: &mut Vec<String>, id: String) {
301    if !ids.contains(&id) {
302        ids.push(id);
303    }
304}
305
306fn is_major_vendor_model(model_id: &str) -> bool {
307    model_id.starts_with("openai/")
308        || model_id.starts_with("anthropic/")
309        || model_id.starts_with("google/")
310        || model_id.starts_with("nvidia/")
311}
312
313/// One model matched by [`search_provider_models`], qualified by the provider
314/// it came from.
315#[derive(Clone, Debug, PartialEq, Eq)]
316pub struct ModelSearchMatch {
317    /// Caller-supplied label for the provider that offers this model.
318    pub provider: String,
319    /// Exact model id, as it must be passed back to the provider.
320    pub model_id: String,
321    /// Human-readable name, when known.
322    pub display_name: Option<String>,
323}
324
325/// Outcome of a search across several providers.
326///
327/// Partial results are the normal case, so failures are reported alongside
328/// matches rather than replacing them: one provider being down or holding a
329/// stale key should not hide the models the others offer.
330#[derive(Clone, Debug, Default, PartialEq, Eq)]
331pub struct ModelSearchResult {
332    /// Matches, sorted by provider then model id.
333    pub matches: Vec<ModelSearchMatch>,
334    /// Providers that were actually queried (a provider with no catalog is
335    /// skipped rather than reported as an error).
336    pub providers_searched: Vec<String>,
337    /// Per-provider failures, as `"<provider>: <error>"`.
338    pub provider_errors: Vec<String>,
339}
340
341/// Search a provider's already-discovered catalog for `query`.
342///
343/// Case-insensitive substring match over the model id and display name. Split
344/// out from the fan-out so the matching rule is testable on its own and reusable
345/// by a host that keeps its own catalog.
346pub fn match_models(
347    provider: &str,
348    models: &[DiscoveredProviderModel],
349    query: &str,
350) -> Vec<ModelSearchMatch> {
351    let needle = query.trim().to_lowercase();
352    if needle.is_empty() {
353        return Vec::new();
354    }
355    models
356        .iter()
357        .filter(|model| {
358            model.model_id.to_lowercase().contains(&needle)
359                || model
360                    .display_name
361                    .as_deref()
362                    .is_some_and(|name| name.to_lowercase().contains(&needle))
363        })
364        .map(|model| ModelSearchMatch {
365            provider: provider.to_string(),
366            model_id: model.model_id.clone(),
367            display_name: model.display_name.clone(),
368        })
369        .collect()
370}
371
372/// Search every supplied provider's catalog for `query`.
373///
374/// This is what turns "use the luna model" into a set of exact, provider-
375/// qualified ids a caller can act on, instead of sending an invented literal to
376/// a provider. Each entry in `providers` is a caller-chosen label paired with
377/// the config to query; the label is what comes back on each match.
378///
379/// Providers are queried in the order given. A provider with no catalog
380/// (`Ok(None)`) is silently skipped — that is "nothing to search", not a
381/// failure — while a provider that errors is recorded in `provider_errors` and
382/// the search continues.
383pub async fn search_provider_models(
384    registry: &DriverRegistry,
385    providers: &[(String, ProviderConfig)],
386    query: &str,
387) -> ModelSearchResult {
388    let mut result = ModelSearchResult::default();
389    if query.trim().is_empty() {
390        return result;
391    }
392
393    for (label, config) in providers {
394        match discover_provider_models(registry, config).await {
395            Ok(Some(models)) => {
396                result.providers_searched.push(label.clone());
397                result.matches.extend(match_models(label, &models, query));
398            }
399            Ok(None) => {}
400            Err(error) => result.provider_errors.push(format!("{label}: {error}")),
401        }
402    }
403
404    result
405        .matches
406        .sort_by(|a, b| (&a.provider, &a.model_id).cmp(&(&b.provider, &b.model_id)));
407    result
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    fn bare_discovered(model_id: &str) -> DiscoveredModel {
415        DiscoveredModel {
416            capabilities: vec!["chat".to_string()],
417            model_id: model_id.to_string(),
418            display_name: None,
419            created_at: None,
420            owned_by: None,
421            discovered_profile: None,
422        }
423    }
424
425    fn model(id: &str) -> DiscoveredProviderModel {
426        DiscoveredProviderModel {
427            model_id: id.to_string(),
428            display_name: None,
429            description: None,
430        }
431    }
432
433    #[tokio::test]
434    async fn discovery_is_unsupported_for_llmsim() {
435        // The offline simulator has no models API; discovery must signal
436        // "unsupported" rather than erroring, so callers keep curated lists.
437        let registry = DriverRegistry::new();
438        let result = discover_provider_models(&registry, &ProviderConfig::new(DriverId::LlmSim))
439            .await
440            .expect("llmsim discovery should not error");
441        assert!(result.is_none());
442    }
443
444    #[test]
445    fn enrichment_fills_names_and_descriptions_from_profiles() {
446        let enriched = enrich_with_profiles(&DriverId::OpenAI, vec![bare_discovered("gpt-5.5")]);
447
448        assert_eq!(enriched.len(), 1);
449        assert_eq!(enriched[0].model_id, "gpt-5.5");
450        assert_eq!(enriched[0].display_name.as_deref(), Some("GPT-5.5"));
451        assert!(
452            enriched[0].description.is_some(),
453            "profile description should be carried over"
454        );
455    }
456
457    #[test]
458    fn enrichment_prefers_api_display_name_over_profile() {
459        let mut discovered = bare_discovered("gpt-5.5");
460        discovered.display_name = Some("GPT-5.5 (via gateway)".to_string());
461
462        let enriched = enrich_with_profiles(&DriverId::OpenAI, vec![discovered]);
463
464        assert_eq!(
465            enriched[0].display_name.as_deref(),
466            Some("GPT-5.5 (via gateway)")
467        );
468    }
469
470    #[test]
471    fn enrichment_keeps_unknown_models_with_bare_ids() {
472        let enriched = enrich_with_profiles(
473            &DriverId::OpenAI,
474            vec![bare_discovered("totally-new-model")],
475        );
476
477        assert_eq!(enriched[0].model_id, "totally-new-model");
478        assert!(enriched[0].display_name.is_none());
479        assert!(enriched[0].description.is_none());
480    }
481
482    #[test]
483    fn normalization_strips_gemini_style_prefixes_and_sorts_newest_first() {
484        let mut older = bare_discovered("models/qwen3");
485        older.created_at = chrono::DateTime::from_timestamp(1_600_000_000, 0);
486        let mut newer = bare_discovered("llama3.2:latest");
487        newer.created_at = chrono::DateTime::from_timestamp(1_700_000_000, 0);
488
489        let normalized = normalize_and_enrich(&DriverId::OpenAI, vec![older, newer]);
490
491        let ids: Vec<&str> = normalized.iter().map(|m| m.model_id.as_str()).collect();
492        assert_eq!(ids, &["llama3.2:latest", "qwen3"]);
493    }
494
495    #[test]
496    fn aggregator_ranking_puts_curated_and_current_first_then_sorts_rest() {
497        let ranked = rank_discovered_models(
498            &DriverId::OpenRouter,
499            vec![
500                model("zai/glm-5"),
501                model("openai/gpt-5.5"),
502                model("anthropic/claude-opus-4-8"),
503                model("moon/kimi-k3"),
504            ],
505            Some("moon/kimi-k3"),
506            &["openai/gpt-5.5", "anthropic/claude-opus-4-8"],
507        );
508
509        assert_eq!(ranked.recommended_count, 3);
510        let ids: Vec<&str> = ranked.models.iter().map(|m| m.model_id.as_str()).collect();
511        assert_eq!(
512            ids,
513            &[
514                "openai/gpt-5.5",
515                "anthropic/claude-opus-4-8",
516                "moon/kimi-k3",
517                "zai/glm-5",
518            ]
519        );
520    }
521
522    #[test]
523    fn curated_ids_absent_from_the_catalog_are_not_recommended() {
524        let ranked = rank_discovered_models(
525            &DriverId::OpenRouter,
526            vec![model("zai/glm-5")],
527            None,
528            &["openai/gpt-5.5"],
529        );
530
531        assert_eq!(ranked.recommended_count, 0);
532        assert_eq!(ranked.models.len(), 1);
533    }
534
535    #[test]
536    fn single_vendor_providers_keep_discovery_order() {
537        let ranked = rank_discovered_models(
538            &DriverId::OpenAI,
539            vec![model("gpt-5.5"), model("gpt-5.2")],
540            None,
541            &["gpt-5.2"],
542        );
543
544        assert_eq!(ranked.recommended_count, 0);
545        let ids: Vec<&str> = ranked.models.iter().map(|m| m.model_id.as_str()).collect();
546        assert_eq!(ids, &["gpt-5.5", "gpt-5.2"]);
547    }
548
549    #[test]
550    fn bare_model_id_strips_reasoning_effort_suffix() {
551        assert_eq!(
552            bare_model_id("nvidia/nemotron-3-super-120b-a12b high"),
553            "nvidia/nemotron-3-super-120b-a12b"
554        );
555    }
556
557    /// Drivers decline listing for unrecognized custom endpoints (here a
558    /// localhost "Ollama"); discovery must then query the OpenAI-compatible
559    /// `GET <base>/models` itself.
560    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
561    async fn openai_compatible_fallback_lists_models() {
562        use std::io::{Read, Write};
563
564        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind mock server");
565        let addr = listener.local_addr().expect("mock server addr");
566        let server = std::thread::spawn(move || {
567            let (mut stream, _) = listener.accept().expect("accept");
568            let mut buf = [0u8; 4096];
569            let _ = stream.read(&mut buf);
570            let body = r#"{"object":"list","data":[
571                {"id":"llama3.2:latest","object":"model","created":1700000000,"owned_by":"library"},
572                {"id":"models/qwen3","object":"model"}
573            ]}"#;
574            let response = format!(
575                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
576                body.len(),
577            );
578            stream
579                .write_all(response.as_bytes())
580                .expect("write response");
581        });
582
583        let discovered = list_openai_compatible_models_with_client(
584            &reqwest::Client::builder()
585                .no_proxy()
586                .build()
587                .expect("build mock HTTP client"),
588            &format!("http://{addr}/v1"),
589            None,
590        )
591        .await
592        .expect("fallback discovery should succeed")
593        .expect("endpoint lists models");
594        server.join().expect("mock server thread");
595
596        let presented = normalize_and_enrich(&DriverId::OpenAI, discovered);
597        let ids: Vec<&str> = presented.iter().map(|m| m.model_id.as_str()).collect();
598        assert!(ids.contains(&"llama3.2:latest"), "ids: {ids:?}");
599        // Gemini-style `models/` prefixes are normalized to bare ids.
600        assert!(ids.contains(&"qwen3"), "ids: {ids:?}");
601    }
602
603    #[tokio::test]
604    async fn openai_compatible_fallback_blocks_internal_addresses() {
605        list_openai_compatible_models("http://127.0.0.1:9/v1", None)
606            .await
607            .expect_err("model discovery must use the provider SSRF guard");
608    }
609
610    fn presented(id: &str, display_name: Option<&str>) -> DiscoveredProviderModel {
611        DiscoveredProviderModel {
612            model_id: id.to_string(),
613            display_name: display_name.map(str::to_string),
614            description: None,
615        }
616    }
617
618    #[test]
619    fn matching_is_case_insensitive_over_id_and_display_name() {
620        let catalog = vec![
621            presented("openai/gpt-5.5", Some("GPT-5.5")),
622            presented("moon/luna-1", None),
623            presented("acme/nebula", Some("Luna Nebula")),
624        ];
625
626        let by_id = match_models("openrouter", &catalog, "LUNA");
627        let ids: Vec<&str> = by_id.iter().map(|m| m.model_id.as_str()).collect();
628        assert_eq!(
629            ids,
630            vec!["moon/luna-1", "acme/nebula"],
631            "a display-name hit counts as much as an id hit"
632        );
633        assert!(by_id.iter().all(|m| m.provider == "openrouter"));
634    }
635
636    #[test]
637    fn an_empty_query_matches_nothing_rather_than_everything() {
638        // Returning the whole catalog for an empty query would flood the caller
639        // and, in a tool context, the model.
640        let catalog = vec![presented("openai/gpt-5.5", None)];
641        assert!(match_models("openrouter", &catalog, "   ").is_empty());
642    }
643
644    #[tokio::test]
645    async fn search_skips_catalog_less_providers_and_records_failures() {
646        let registry = DriverRegistry::new();
647        let providers = vec![
648            // No catalog to offer: skipped, not an error.
649            ("sim".to_string(), ProviderConfig::new(DriverId::LlmSim)),
650            // No driver registered and no base URL for the HTTP fallback: an
651            // error against this provider, which must not sink the search.
652            (
653                "broken".to_string(),
654                ProviderConfig::new(DriverId::OpenAI).with_api_key("k"),
655            ),
656        ];
657
658        let result = search_provider_models(&registry, &providers, "gpt").await;
659
660        assert!(result.matches.is_empty());
661        assert!(
662            !result.providers_searched.contains(&"sim".to_string()),
663            "a provider with no catalog was not searched"
664        );
665        assert_eq!(result.provider_errors.len(), 1);
666        assert!(result.provider_errors[0].starts_with("broken: "));
667    }
668
669    #[tokio::test]
670    async fn an_empty_query_short_circuits_before_any_provider_call() {
671        let registry = DriverRegistry::new();
672        let providers = vec![(
673            "broken".to_string(),
674            ProviderConfig::new(DriverId::OpenAI).with_api_key("k"),
675        )];
676
677        let result = search_provider_models(&registry, &providers, "").await;
678
679        assert_eq!(result, ModelSearchResult::default());
680    }
681}