1use 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#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct DiscoveredProviderModel {
24 pub model_id: String,
26 pub display_name: Option<String>,
28 pub description: Option<String>,
30}
31
32pub async fn discover_provider_models(
42 registry: &DriverRegistry,
43 config: &ProviderConfig,
44) -> Result<Option<Vec<DiscoveredProviderModel>>> {
45 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
68pub fn normalize_and_enrich(
74 provider_type: &DriverId,
75 mut models: Vec<DiscoveredModel>,
76) -> Vec<DiscoveredProviderModel> {
77 for model in models.iter_mut() {
78 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
92pub 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
142pub async fn list_openai_compatible_models(
145 base_url: &str,
146 api_key: Option<&str>,
147) -> Result<Option<Vec<DiscoveredModel>>> {
148 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#[derive(Clone, Debug, PartialEq, Eq)]
198pub struct RankedDiscoveredModels {
199 pub models: Vec<DiscoveredProviderModel>,
201 pub recommended_count: usize,
203}
204
205const RECOMMENDED_CAP: usize = 20;
208
209pub 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
294pub 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#[derive(Clone, Debug, PartialEq, Eq)]
316pub struct ModelSearchMatch {
317 pub provider: String,
319 pub model_id: String,
321 pub display_name: Option<String>,
323}
324
325#[derive(Clone, Debug, Default, PartialEq, Eq)]
331pub struct ModelSearchResult {
332 pub matches: Vec<ModelSearchMatch>,
334 pub providers_searched: Vec<String>,
337 pub provider_errors: Vec<String>,
339}
340
341pub 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
372pub 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 let registry = DriverRegistry::new();
438 let result = discover_provider_models(®istry, &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 #[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 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 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 ("sim".to_string(), ProviderConfig::new(DriverId::LlmSim)),
650 (
653 "broken".to_string(),
654 ProviderConfig::new(DriverId::OpenAI).with_api_key("k"),
655 ),
656 ];
657
658 let result = search_provider_models(®istry, &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(®istry, &providers, "").await;
678
679 assert_eq!(result, ModelSearchResult::default());
680 }
681}