1use std::collections::BTreeMap;
26use std::future::Future;
27use std::pin::Pin;
28use std::time::Duration;
29
30pub const CATALOG_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
31const ANTHROPIC_MODELS_MAX_PAGES: usize = 20;
32
33mod anthropic;
34mod codex;
35mod generic;
36mod groq;
37mod nvidia;
38mod openrouter;
39
40pub use anthropic::{
41 anthropic_models_url, merge_catalog_pages, parse_anthropic_catalog_models,
42 parse_anthropic_catalog_page, AnthropicCatalogPage,
43};
44pub use codex::codex_static_catalog_models;
45pub use generic::parse_generic_catalog_models;
46pub use groq::{infer_groq_reasoning, parse_groq_catalog_models};
47pub use nvidia::{infer_nvidia_reasoning, parse_nvidia_catalog_models};
48pub use openrouter::parse_openrouter_catalog_models;
49
50#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Modality {
55 Text,
56 Image,
57 Audio,
58 Video,
59 File,
60 Other(String),
61}
62
63impl Modality {
64 #[allow(clippy::should_implement_trait)]
65 pub fn from_str(s: &str) -> Self {
66 match s {
67 "text" => Modality::Text,
68 "image" => Modality::Image,
69 "audio" => Modality::Audio,
70 "video" => Modality::Video,
71 "file" => Modality::File,
72 other => Modality::Other(other.to_string()),
73 }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Default)]
81pub struct PricingSummary {
82 pub prompt: Option<String>,
84 pub completion: Option<String>,
86 pub internal_reasoning: Option<String>,
88}
89
90impl PricingSummary {
91 pub fn has_internal_reasoning_cost(&self) -> bool {
93 self.internal_reasoning
94 .as_deref()
95 .map(|s| s != "0" && !s.trim().is_empty())
96 .unwrap_or(false)
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum ReasoningSupport {
105 None,
107 AnthropicAdaptive { adaptive: bool },
109 OpenRouter {
111 include_reasoning: bool,
112 effort: bool,
113 verbosity: bool,
114 internal_reasoning_priced: bool,
115 },
116 GroqReasoning,
118 NvidiaInlineThinking,
120 GenericOpenAi,
122 Unknown,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum CatalogSource {
130 Live,
132 StaticFallback,
134 StaticWithLive,
136 Inferred,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum CatalogProviderKind {
144 Anthropic,
145 OpenRouter,
146 Groq,
147 NvidiaNim,
148 OpenAiCodex,
149 Generic { key: String },
150 Local,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct CatalogModel {
158 pub provider_key: String,
160 pub provider_name: String,
162 pub provider_kind: CatalogProviderKind,
164 pub id: String,
166 pub label: Option<String>,
168 pub context_tokens: Option<u64>,
170 pub max_output_tokens: Option<u64>,
172 pub input_modalities: Vec<Modality>,
174 pub pricing: PricingSummary,
176 pub reasoning: ReasoningSupport,
178 pub source: CatalogSource,
180}
181
182impl CatalogModel {
183 pub fn new(
185 provider_key: impl Into<String>,
186 provider_name: impl Into<String>,
187 id: impl Into<String>,
188 ) -> Option<Self> {
189 let id = id.into();
190 if id.trim().is_empty() {
191 return None;
192 }
193 let pk = provider_key.into();
194 Some(Self {
195 provider_kind: CatalogProviderKind::Generic { key: pk.clone() },
196 provider_name: provider_name.into(),
197 provider_key: pk,
198 id,
199 label: None,
200 context_tokens: None,
201 max_output_tokens: None,
202 input_modalities: vec![Modality::Text],
203 pricing: PricingSummary::default(),
204 reasoning: ReasoningSupport::Unknown,
205 source: CatalogSource::Live,
206 })
207 }
208
209 pub fn runtime_id(&self) -> String {
211 match &self.provider_kind {
212 CatalogProviderKind::Anthropic => self.id.clone(),
213 _ => format!("{}/{}", self.provider_key, self.id),
214 }
215 }
216
217 pub fn display_label(&self) -> &str {
219 self.label.as_deref().unwrap_or(&self.id)
220 }
221}
222
223pub fn from_static_seed(
227 provider_key: &str,
228 provider_name: &str,
229 id: &str,
230 label: &str,
231) -> Option<CatalogModel> {
232 let mut m = CatalogModel::new(provider_key, provider_name, id)?;
233 m.label = if label.trim().is_empty() { None } else { Some(label.to_string()) };
234 m.source = CatalogSource::StaticFallback;
235 m.reasoning = ReasoningSupport::Unknown;
236 Some(m)
237}
238
239pub fn static_seeds_from_spec(
241 spec: &super::registry::ProviderSpec,
242) -> Vec<CatalogModel> {
243 spec.models
244 .iter()
245 .filter_map(|(id, label, _tier)| {
246 from_static_seed(spec.key, spec.name, id, label)
247 })
248 .collect()
249}
250
251pub trait ModelCatalogProvider: Sync {
254 fn provider_key(&self) -> &'static str;
255
256 fn fetch<'a>(
257 &'a self,
258 client: &'a reqwest::Client,
259 overrides: &'a BTreeMap<String, String>,
260 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>>;
261}
262
263pub struct OpenRouterCatalogProvider;
264pub struct GroqCatalogProvider;
265pub struct NvidiaCatalogProvider;
266pub struct AnthropicCatalogProvider;
267pub struct CodexCatalogProvider;
268pub struct GenericCatalogProvider;
269
270pub fn catalog_provider_for(provider_key: &str) -> &'static dyn ModelCatalogProvider {
271 match provider_key {
272 "openrouter" => &OpenRouterCatalogProvider,
273 "groq" => &GroqCatalogProvider,
274 "nvidia" => &NvidiaCatalogProvider,
275 "claude" | "anthropic" => &AnthropicCatalogProvider,
276 "openai-codex" => &CodexCatalogProvider,
277 _ => &GenericCatalogProvider,
278 }
279}
280
281fn catalog_get(client: &reqwest::Client, url: &str) -> reqwest::RequestBuilder {
282 client.get(url).timeout(CATALOG_REQUEST_TIMEOUT)
283}
284
285async fn read_catalog_response(resp: reqwest::Response) -> Result<String, String> {
286 let status = resp.status();
287 let body = resp.text().await.map_err(|e| format!("read failed: {e}"))?;
288 if !status.is_success() {
289 return Err(format!("model list failed: HTTP {status}"));
290 }
291 Ok(body)
292}
293
294async fn fetch_anthropic_catalog_models(
295 client: &reqwest::Client,
296) -> Result<Vec<CatalogModel>, String> {
297 let creds = crate::auth::ensure_fresh_token(client)
298 .await
299 .map_err(|e| format!("Anthropic is not configured: {e}"))?;
300 let mut pages = Vec::new();
301 let mut after_id: Option<String> = None;
302
303 for _ in 0..ANTHROPIC_MODELS_MAX_PAGES {
304 let url = anthropic_models_url(after_id.as_deref());
305 let resp = catalog_get(client, &url)
306 .bearer_auth(&creds.access)
307 .header("x-api-key", &creds.access)
308 .header("anthropic-version", "2023-06-01")
309 .send()
310 .await
311 .map_err(|e| format!("request failed: {e}"))?;
312 let body = read_catalog_response(resp).await?;
313 let page = parse_anthropic_catalog_page(&body).map_err(|e| format!("parse failed: {e}"))?;
314 let next_after_id = page.last_id.clone();
315 let has_more = page.has_more && next_after_id.is_some();
316 pages.push(page.models);
317 if !has_more {
318 return Ok(merge_catalog_pages(pages));
319 }
320 after_id = next_after_id;
321 }
322
323 Ok(merge_catalog_pages(pages))
324}
325
326impl ModelCatalogProvider for OpenRouterCatalogProvider {
327 fn provider_key(&self) -> &'static str { "openrouter" }
328
329 fn fetch<'a>(
330 &'a self,
331 client: &'a reqwest::Client,
332 _overrides: &'a BTreeMap<String, String>,
333 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
334 Box::pin(async move { fetch_openrouter_catalog_models(client).await })
335 }
336}
337
338impl ModelCatalogProvider for GroqCatalogProvider {
339 fn provider_key(&self) -> &'static str { "groq" }
340
341 fn fetch<'a>(
342 &'a self,
343 client: &'a reqwest::Client,
344 overrides: &'a BTreeMap<String, String>,
345 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
346 Box::pin(async move {
347 let spec = super::registry::providers()
348 .iter()
349 .find(|s| s.key == "groq")
350 .ok_or_else(|| "unknown provider: groq".to_string())?;
351 let api_key = super::registry::resolve_provider("groq", overrides)
352 .map(|(cfg, _)| cfg.api_key)
353 .ok_or_else(|| format!("{} is not configured", spec.name))?;
354 let url = format!("{}/models", spec.base_url.trim_end_matches('/'));
355 let resp = catalog_get(client, &url)
356 .bearer_auth(api_key)
357 .send()
358 .await
359 .map_err(|e| format!("request failed: {e}"))?;
360 let body = read_catalog_response(resp).await?;
361 parse_groq_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
362 })
363 }
364}
365
366impl ModelCatalogProvider for NvidiaCatalogProvider {
367 fn provider_key(&self) -> &'static str { "nvidia" }
368
369 fn fetch<'a>(
370 &'a self,
371 client: &'a reqwest::Client,
372 _overrides: &'a BTreeMap<String, String>,
373 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
374 Box::pin(async move {
375 let resp = catalog_get(client, "https://integrate.api.nvidia.com/v1/models")
376 .send()
377 .await
378 .map_err(|e| format!("request failed: {e}"))?;
379 let body = read_catalog_response(resp).await?;
380 parse_nvidia_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
381 })
382 }
383}
384
385impl ModelCatalogProvider for AnthropicCatalogProvider {
386 fn provider_key(&self) -> &'static str { "claude" }
387
388 fn fetch<'a>(
389 &'a self,
390 client: &'a reqwest::Client,
391 _overrides: &'a BTreeMap<String, String>,
392 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
393 Box::pin(async move { fetch_anthropic_catalog_models(client).await })
394 }
395}
396
397impl ModelCatalogProvider for CodexCatalogProvider {
398 fn provider_key(&self) -> &'static str { "openai-codex" }
399
400 fn fetch<'a>(
401 &'a self,
402 _client: &'a reqwest::Client,
403 _overrides: &'a BTreeMap<String, String>,
404 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
405 Box::pin(async move { Ok(codex_static_catalog_models()) })
406 }
407}
408
409impl ModelCatalogProvider for GenericCatalogProvider {
410 fn provider_key(&self) -> &'static str { "generic" }
411
412 fn fetch<'a>(
413 &'a self,
414 _client: &'a reqwest::Client,
415 _overrides: &'a BTreeMap<String, String>,
416 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
417 Box::pin(async move {
418 Err("generic catalog fetch requires provider key; use fetch_generic_catalog_provider_models".to_string())
419 })
420 }
421}
422
423async fn fetch_generic_catalog_provider_models(
424 client: &reqwest::Client,
425 provider_key: &str,
426 overrides: &BTreeMap<String, String>,
427) -> Result<Vec<CatalogModel>, String> {
428 let specs = super::registry::providers();
429 let spec = specs
430 .iter()
431 .find(|s| s.key == provider_key)
432 .ok_or_else(|| format!("unknown provider: {provider_key}"))?;
433
434 let api_key = super::registry::resolve_provider(provider_key, overrides)
435 .map(|(cfg, _)| cfg.api_key)
436 .ok_or_else(|| format!("{} is not configured", spec.name))?;
437
438 fetch_generic_catalog_models(
439 client,
440 provider_key,
441 spec.name,
442 spec.base_url,
443 &api_key,
444 ).await
445}
446
447pub async fn fetch_openrouter_catalog_models(
449 client: &reqwest::Client,
450) -> Result<Vec<CatalogModel>, String> {
451 let resp = catalog_get(client, "https://openrouter.ai/api/v1/models")
452 .send()
453 .await
454 .map_err(|e| format!("request failed: {e}"))?;
455 let body = read_catalog_response(resp).await?;
456 parse_openrouter_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
457}
458
459pub async fn fetch_generic_catalog_models(
461 client: &reqwest::Client,
462 provider_key: &str,
463 provider_name: &str,
464 base_url: &str,
465 api_key: &str,
466) -> Result<Vec<CatalogModel>, String> {
467 let url = format!("{}/models", base_url.trim_end_matches('/'));
468 let resp = catalog_get(client, &url)
469 .bearer_auth(api_key)
470 .send()
471 .await
472 .map_err(|e| format!("request failed: {e}"))?;
473 let body = read_catalog_response(resp).await?;
474 parse_generic_catalog_models(&body, provider_key, provider_name)
475 .map_err(|e| format!("parse failed: {e}"))
476}
477
478pub async fn fetch_catalog_models(
483 client: &reqwest::Client,
484 provider_key: &str,
485 overrides: &BTreeMap<String, String>,
486) -> Result<Vec<CatalogModel>, String> {
487 let provider = catalog_provider_for(provider_key);
488 if provider.provider_key() == "generic" {
489 return fetch_generic_catalog_provider_models(client, provider_key, overrides).await;
490 }
491 provider.fetch(client, overrides).await
492}
493
494#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[test]
503 fn catalog_model_rejects_empty_ids() {
504 assert!(CatalogModel::new("openrouter", "OpenRouter", "").is_none());
505 assert!(CatalogModel::new("openrouter", "OpenRouter", " ").is_none());
506 }
507
508 #[test]
509 fn static_seed_sets_fallback_source_and_runtime_id() {
510 let m = from_static_seed("groq", "Groq", "llama-3.3-70b-versatile", "Llama 3.3 70B")
511 .expect("valid seed");
512 assert_eq!(m.runtime_id(), "groq/llama-3.3-70b-versatile");
513 assert_eq!(m.display_label(), "Llama 3.3 70B");
514 assert_eq!(m.source, CatalogSource::StaticFallback);
515 }
516
517 #[test]
518 fn static_seed_empty_label_stores_none() {
519 let m = from_static_seed("groq", "Groq", "model-x", "").expect("valid id");
520 assert_eq!(m.label, None);
521 }
522
523 #[test]
524 fn static_seed_whitespace_label_stores_none() {
525 let m = from_static_seed("groq", "Groq", "model-x", " ").expect("valid id");
526 assert_eq!(m.label, None);
527 }
528
529 #[test]
530 fn static_seeds_from_spec_converts_all_groq_models() {
531 let spec = super::super::registry::providers()
532 .iter()
533 .find(|s| s.key == "groq")
534 .expect("groq spec");
535 let seeds = static_seeds_from_spec(spec);
536 assert_eq!(seeds.len(), spec.models.len());
537 assert!(seeds.iter().all(|m| m.source == CatalogSource::StaticFallback));
538 assert!(seeds.iter().all(|m| !m.id.is_empty()));
539 assert!(seeds.iter().all(|m| m.runtime_id().starts_with("groq/")));
540 }
541
542 #[test]
543 fn anthropic_runtime_id_is_bare() {
544 let mut m = CatalogModel::new("anthropic", "Anthropic", "claude-opus-4-7").unwrap();
545 m.provider_kind = CatalogProviderKind::Anthropic;
546 assert_eq!(m.runtime_id(), "claude-opus-4-7");
547 }
548
549 #[test]
550 fn pricing_summary_has_internal_reasoning_cost_zero_is_false() {
551 let p = PricingSummary {
552 prompt: None, completion: None,
553 internal_reasoning: Some("0".to_string()),
554 };
555 assert!(!p.has_internal_reasoning_cost());
556 }
557
558 #[test]
559 fn pricing_summary_has_internal_reasoning_cost_nonzero_is_true() {
560 let p = PricingSummary {
561 prompt: None, completion: None,
562 internal_reasoning: Some("0.0000035".to_string()),
563 };
564 assert!(p.has_internal_reasoning_cost());
565 }
566
567 #[test]
568 fn catalog_provider_trait_dispatch_selects_specialized_handlers() {
569 assert_eq!(catalog_provider_for("openrouter").provider_key(), "openrouter");
570 assert_eq!(catalog_provider_for("groq").provider_key(), "groq");
571 assert_eq!(catalog_provider_for("nvidia").provider_key(), "nvidia");
572 assert_eq!(catalog_provider_for("claude").provider_key(), "claude");
573 assert_eq!(catalog_provider_for("openai-codex").provider_key(), "openai-codex");
574 assert_eq!(catalog_provider_for("cerebras").provider_key(), "generic");
575 }
576
577 #[test]
578 fn catalog_request_timeout_is_bounded() {
579 assert_eq!(CATALOG_REQUEST_TIMEOUT, Duration::from_secs(15));
580 }
581
582 #[test]
583 fn anthropic_page_metadata_is_exposed_for_pagination() {
584 let page = parse_anthropic_catalog_page(r#"{
585 "data":[{"id":"claude-opus-4-7"}],
586 "has_more": true,
587 "last_id": "claude-opus-4-7"
588 }"#).expect("parse page");
589 assert!(page.has_more);
590 assert_eq!(page.last_id.as_deref(), Some("claude-opus-4-7"));
591 assert_eq!(page.models.len(), 1);
592 }
593
594 #[test]
595 fn anthropic_pagination_url_adds_after_id_cursor() {
596 assert_eq!(
597 anthropic_models_url(None),
598 "https://api.anthropic.com/v1/models?limit=100"
599 );
600 assert_eq!(
601 anthropic_models_url(Some("claude-opus-4-7")),
602 "https://api.anthropic.com/v1/models?limit=100&after_id=claude-opus-4-7"
603 );
604 }
605
606 #[test]
607 fn merge_catalog_pages_dedupes_by_id() {
608 let first = parse_anthropic_catalog_models(r#"{"data":[{"id":"claude-opus-4-7"}]}"#).unwrap();
609 let second = parse_anthropic_catalog_models(r#"{"data":[{"id":"claude-opus-4-7"},{"id":"claude-sonnet-4-6"}]}"#).unwrap();
610 let merged = merge_catalog_pages(vec![first, second]);
611 assert_eq!(merged.len(), 2);
612 assert_eq!(merged[0].id, "claude-opus-4-7");
613 assert_eq!(merged[1].id, "claude-sonnet-4-6");
614 }
615
616 mod openrouter {
619 use super::super::*;
620
621 const RICH_FIXTURE: &str = r#"{
622 "data": [
623 {
624 "id": "qwen/qwen3-coder",
625 "name": "Qwen: Qwen3 Coder",
626 "context_length": 131072,
627 "top_provider": { "max_completion_tokens": 32768 },
628 "supported_parameters": ["temperature", "top_p", "max_tokens"],
629 "pricing": { "prompt": "0.0000001", "completion": "0.0000004", "internal_reasoning": "0" },
630 "architecture": { "input_modalities": ["text"] }
631 },
632 {
633 "id": "anthropic/claude-opus-4-7",
634 "name": "Anthropic: Claude Opus 4.7",
635 "context_length": 200000,
636 "supported_parameters": ["temperature", "verbosity", "max_tokens"],
637 "pricing": { "prompt": "0.000015", "completion": "0.000075" }
638 },
639 {
640 "id": "openai/o4-mini",
641 "name": "OpenAI: o4-mini",
642 "context_length": 128000,
643 "supported_parameters": ["reasoning_effort", "max_tokens"],
644 "pricing": { "prompt": "0.0000011", "completion": "0.0000044" }
645 },
646 {
647 "id": "google/gemini-2.5-flash",
648 "name": "Google: Gemini 2.5 Flash",
649 "context_length": 1048576,
650 "supported_parameters": ["reasoning", "include_reasoning", "max_tokens"],
651 "pricing": { "prompt": "0.00000015", "completion": "0.0000035", "internal_reasoning": "0.0000035" },
652 "architecture": { "input_modalities": ["text", "image", "audio", "video"] }
653 },
654 {
655 "id": "",
656 "name": "Empty — must be filtered"
657 }
658 ]
659 }"#;
660
661 #[test]
662 fn parses_minimal_model() {
663 let json = r#"{"data":[{"id":"test/model","name":"Test Model"}]}"#;
664 let models = parse_openrouter_catalog_models(json).expect("parse ok");
665 assert_eq!(models.len(), 1);
666 assert_eq!(models[0].id, "test/model");
667 assert_eq!(models[0].label.as_deref(), Some("Test Model"));
668 assert_eq!(models[0].runtime_id(), "openrouter/test/model");
669 assert_eq!(models[0].provider_key, "openrouter");
670 assert_eq!(models[0].source, CatalogSource::Live);
671 }
672
673 #[test]
674 fn parses_context_length_and_max_output() {
675 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
676 let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
677 assert_eq!(qwen.context_tokens, Some(131_072));
678 assert_eq!(qwen.max_output_tokens, Some(32_768));
679 }
680
681 #[test]
682 fn filters_empty_ids() {
683 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
684 assert!(!models.iter().any(|m| m.id.is_empty()));
685 assert_eq!(models.len(), 4);
686 }
687
688 #[test]
689 fn parses_pricing_fields() {
690 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
691 let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
692 assert_eq!(qwen.pricing.prompt.as_deref(), Some("0.0000001"));
693 assert_eq!(qwen.pricing.completion.as_deref(), Some("0.0000004"));
694 assert!(!qwen.pricing.has_internal_reasoning_cost());
695 }
696
697 #[test]
698 fn parses_internal_reasoning_cost_flag() {
699 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
700 let gemini = models.iter().find(|m| m.id == "google/gemini-2.5-flash").unwrap();
701 assert!(gemini.pricing.has_internal_reasoning_cost());
702 }
703
704 #[test]
705 fn no_reasoning_params_maps_to_none() {
706 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
707 let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
708 assert_eq!(qwen.reasoning, ReasoningSupport::None);
709 }
710
711 #[test]
712 fn verbosity_param_maps_to_anthropic_adaptive() {
713 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
714 let claude = models.iter().find(|m| m.id == "anthropic/claude-opus-4-7").unwrap();
715 assert_eq!(claude.reasoning, ReasoningSupport::AnthropicAdaptive { adaptive: true });
716 }
717
718 #[test]
719 fn reasoning_effort_maps_to_openrouter_reasoning() {
720 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
721 let o4 = models.iter().find(|m| m.id == "openai/o4-mini").unwrap();
722 assert_eq!(o4.reasoning, ReasoningSupport::OpenRouter {
723 include_reasoning: false,
724 effort: true,
725 verbosity: false,
726 internal_reasoning_priced: false,
727 });
728 }
729
730 #[test]
731 fn reasoning_include_reasoning_maps_correctly() {
732 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
733 let gemini = models.iter().find(|m| m.id == "google/gemini-2.5-flash").unwrap();
734 assert_eq!(gemini.reasoning, ReasoningSupport::OpenRouter {
735 include_reasoning: true,
736 effort: false,
737 verbosity: false,
738 internal_reasoning_priced: true,
739 });
740 }
741
742 #[test]
743 fn parses_multimodal_input() {
744 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
745 let gemini = models.iter().find(|m| m.id == "google/gemini-2.5-flash").unwrap();
746 assert!(gemini.input_modalities.contains(&Modality::Text));
747 assert!(gemini.input_modalities.contains(&Modality::Image));
748 assert!(gemini.input_modalities.contains(&Modality::Audio));
749 assert!(gemini.input_modalities.contains(&Modality::Video));
750 }
751
752 #[test]
753 fn missing_modalities_defaults_to_text() {
754 let json = r#"{"data":[{"id":"test/model"}]}"#;
755 let models = parse_openrouter_catalog_models(json).expect("parse ok");
756 assert_eq!(models[0].input_modalities, vec![Modality::Text]);
757 }
758
759 #[test]
760 fn parses_openrouter_rich_metadata_and_reasoning_flags() {
761 let json = r#"{
763 "data": [{
764 "id": "anthropic/claude-sonnet-4.6",
765 "name": "Anthropic: Claude Sonnet 4.6",
766 "context_length": 1000000,
767 "architecture": { "input_modalities": ["text", "image"] },
768 "pricing": { "prompt": "0.000003", "completion": "0.000015", "internal_reasoning": "0.000012" },
769 "top_provider": { "max_completion_tokens": 128000 },
770 "supported_parameters": ["reasoning", "include_reasoning", "verbosity", "tools"]
771 }]
772 }"#;
773 let models = parse_openrouter_catalog_models(json).expect("parse ok");
774 assert_eq!(models.len(), 1);
775 let m = &models[0];
776 assert_eq!(m.runtime_id(), "openrouter/anthropic/claude-sonnet-4.6");
777 assert_eq!(m.context_tokens, Some(1_000_000));
778 assert_eq!(m.max_output_tokens, Some(128_000));
779 assert!(m.input_modalities.contains(&Modality::Image));
780 assert_eq!(m.reasoning, ReasoningSupport::AnthropicAdaptive { adaptive: true });
782 }
783
784 #[test]
785 fn parses_openrouter_non_reasoning_model_as_none() {
786 let json = r#"{
787 "data": [{
788 "id": "meta-llama/llama-3.3-70b-instruct",
789 "name": "Meta: Llama 3.3 70B",
790 "supported_parameters": ["temperature", "tools"]
791 }]
792 }"#;
793 let models = parse_openrouter_catalog_models(json).expect("parse ok");
794 assert_eq!(models[0].reasoning, ReasoningSupport::None);
795 }
796
797 #[test]
798 fn invalid_json_returns_error() {
799 assert!(parse_openrouter_catalog_models("{not json}").is_err());
800 }
801
802 #[test]
803 fn missing_data_key_returns_error() {
804 assert!(parse_openrouter_catalog_models(r#"{"models":[]}"#).is_err());
805 }
806 }
807
808 mod anthropic {
816 use super::super::*;
817
818 #[test]
819 fn parser_reads_optional_capabilities_and_token_limits() {
820 let json = r#"{
821 "data": [{
822 "id": "claude-opus-4-7",
823 "display_name": "Claude Opus 4.7",
824 "max_input_tokens": 200000,
825 "max_tokens": 32000,
826 "capabilities": {
827 "thinking": { "supported": true },
828 "effort": { "supported": true }
829 }
830 }],
831 "has_more": false
832 }"#;
833 let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
834 assert_eq!(models.len(), 1);
835 let model = &models[0];
836 assert_eq!(model.runtime_id(), "claude-opus-4-7");
837 assert_eq!(model.label.as_deref(), Some("Claude Opus 4.7"));
838 assert_eq!(model.context_tokens, Some(200_000));
839 assert_eq!(model.max_output_tokens, Some(32_000));
840 assert_eq!(model.reasoning, ReasoningSupport::AnthropicAdaptive { adaptive: true });
841 }
842
843 #[test]
844 fn parser_tolerates_missing_capabilities_as_unknown() {
845 let json = r#"{"data":[{"id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku"}]}"#;
846 let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
847 assert_eq!(models[0].reasoning, ReasoningSupport::Unknown);
848 }
849
850 #[test]
851 fn parser_filters_empty_ids() {
852 let json = r#"{"data":[{"id":""},{"id":"claude-sonnet-4-6"}]}"#;
853 let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
854 assert_eq!(models.len(), 1);
855 assert_eq!(models[0].id, "claude-sonnet-4-6");
856 }
857 }
858
859 mod codex {
860 use super::super::*;
861
862 #[test]
863 fn static_catalog_uses_fallback_source_and_prefixed_runtime_ids() {
864 let models = codex_static_catalog_models();
865 assert!(models.iter().any(|m| m.id == "gpt-5.5"));
866 assert!(models.iter().any(|m| m.id == "gpt-5.4"));
867 assert!(models.iter().any(|m| m.id == "gpt-5.4-mini"));
868 assert!(!models.iter().any(|m| m.id == "gpt-5.5-pro"));
869 assert!(!models.iter().any(|m| m.id == "gpt-5.4-nano"));
870 assert!(!models.iter().any(|m| m.id == "gpt-5.1-codex-mini"));
871 assert!(models.iter().all(|m| m.source == CatalogSource::StaticFallback));
872 assert!(models.iter().all(|m| m.runtime_id().starts_with("openai-codex/")));
873 }
874 }
875
876 mod groq {
879 use super::super::*;
880
881 #[test]
882 fn parser_extracts_context_window_and_filters_inactive() {
883 let json = r#"{"data":[
884 {"id":"llama-3.3-70b-versatile","active":true,"context_window":131072,"owned_by":"Meta"},
885 {"id":"old-model-v1","active":false,"context_window":8192,"owned_by":"Meta"}
886 ]}"#;
887 let models = parse_groq_catalog_models(json).expect("parse groq");
888 assert_eq!(models.len(), 1);
889 assert_eq!(models[0].id, "llama-3.3-70b-versatile");
890 assert_eq!(models[0].context_tokens, Some(131_072));
891 assert_eq!(models[0].reasoning, ReasoningSupport::None);
892 }
893
894 #[test]
895 fn inference_maps_reasoning_families() {
896 assert_eq!(infer_groq_reasoning("openai/gpt-oss-120b"), ReasoningSupport::GroqReasoning);
897 assert_eq!(infer_groq_reasoning("qwen/qwen3-32b"), ReasoningSupport::GroqReasoning);
898 assert_eq!(infer_groq_reasoning("groq/compound-mini"), ReasoningSupport::GroqReasoning);
899 assert_eq!(infer_groq_reasoning("llama-3.3-70b-versatile"), ReasoningSupport::None);
900 }
901
902 #[test]
903 fn parser_filters_empty_ids() {
904 let json = r#"{"data":[{"id":"","active":true},{"id":"openai/gpt-oss-20b","active":true,"context_window":131072}]}"#;
905 let models = parse_groq_catalog_models(json).expect("parse groq");
906 assert_eq!(models.len(), 1);
907 assert_eq!(models[0].id, "openai/gpt-oss-20b");
908 }
909 }
910
911 mod nvidia {
912 use super::super::*;
913
914 #[test]
915 fn parser_dedupes_and_enriches_known_context() {
916 let json = r#"{"data":[
917 {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
918 {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
919 {"id":"moonshotai/kimi-k2-thinking","owned_by":"moonshotai"}
920 ]}"#;
921 let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
922 assert_eq!(models.len(), 2);
923 let ultra = models.iter().find(|m| m.id.contains("ultra")).unwrap();
924 assert_eq!(ultra.context_tokens, Some(128_000));
925 assert_eq!(ultra.reasoning, ReasoningSupport::NvidiaInlineThinking);
926 let kimi = models.iter().find(|m| m.id.contains("kimi")).unwrap();
927 assert_eq!(kimi.context_tokens, Some(256_000));
928 }
929
930 #[test]
931 fn inference_detects_thinking_and_standard_models() {
932 assert_eq!(infer_nvidia_reasoning("qwen/qwen3-next-80b-a3b-thinking"), ReasoningSupport::NvidiaInlineThinking);
933 assert_eq!(infer_nvidia_reasoning("nvidia/cosmos-reason2-8b"), ReasoningSupport::NvidiaInlineThinking);
934 assert_eq!(infer_nvidia_reasoning("meta/llama-3.3-70b-instruct"), ReasoningSupport::None);
935 }
936
937 #[test]
938 fn parser_filters_empty_ids() {
939 let json = r#"{"data":[{"id":""},{"id":"meta/llama-3.3-70b-instruct"}]}"#;
940 let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
941 assert_eq!(models.len(), 1);
942 assert_eq!(models[0].id, "meta/llama-3.3-70b-instruct");
943 }
944 }
945
946
947 mod generic_compat {
948 use super::super::*;
949
950 #[test]
951 fn parses_generic_catalog_models_and_filters_empty_ids() {
952 let json = r#"{
953 "data": [
954 { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
955 { "id": "" },
956 { "id": "openai/gpt-oss-120b" }
957 ]
958 }"#;
959 let models = parse_generic_catalog_models(json, "openrouter", "OpenRouter")
960 .expect("parse ok");
961 assert_eq!(models.len(), 2);
962 assert_eq!(models[0].runtime_id(), "openrouter/qwen/qwen3-coder");
963 assert_eq!(models[0].display_label(), "Qwen: Qwen3 Coder");
964 assert_eq!(models[1].display_label(), "openai/gpt-oss-120b");
965 }
966
967 #[test]
968 fn whitespace_only_id_is_filtered() {
969 let json = r#"{"data":[{"id":" "},{"id":"valid"}]}"#;
970 let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
971 assert_eq!(models.len(), 1);
972 assert_eq!(models[0].id, "valid");
973 }
974
975 #[test]
976 fn whitespace_label_stored_as_none() {
977 let json = r#"{"data":[{"id":"m1","name":" "}]}"#;
978 let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
979 assert_eq!(models[0].label, None);
980 }
981
982 #[test]
983 fn generic_catalog_source_is_live() {
984 let json = r#"{"data":[{"id":"m1","name":"Model One"}]}"#;
985 let models = parse_generic_catalog_models(json, "testprovider", "Test")
986 .expect("parse ok");
987 assert_eq!(models[0].source, CatalogSource::Live);
988 }
989
990 #[test]
991 fn generic_reasoning_is_generic_open_ai() {
992 let json = r#"{"data":[{"id":"m1"}]}"#;
993 let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
994 assert_eq!(models[0].reasoning, ReasoningSupport::GenericOpenAi);
995 }
996
997 #[test]
998 fn generic_parse_matches_legacy_parse_behavior() {
999 let json = r#"{
1002 "data": [
1003 { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
1004 { "id": "openai/gpt-oss-120b" }
1005 ]
1006 }"#;
1007 let legacy = super::super::super::registry::parse_provider_models_response(json)
1008 .expect("legacy parse ok");
1009 let catalog = parse_generic_catalog_models(json, "openrouter", "OpenRouter")
1010 .expect("catalog parse ok");
1011 assert_eq!(legacy.len(), catalog.len());
1012 for (l, c) in legacy.iter().zip(catalog.iter()) {
1013 assert_eq!(l.id, c.id, "ids must match");
1014 assert_eq!(l.name, c.label, "labels must match");
1015 }
1016 }
1017
1018 #[test]
1019 fn static_seeds_from_spec_all_providers() {
1020 for spec in super::super::super::registry::providers() {
1022 if spec.models.is_empty() { continue; }
1023 let seeds = super::super::static_seeds_from_spec(spec);
1024 assert!(!seeds.is_empty(), "no seeds for {}", spec.key);
1025 assert!(
1026 seeds.iter().all(|m| m.runtime_id().starts_with(&format!("{}/", spec.key))),
1027 "runtime_id prefix wrong for {}",
1028 spec.key
1029 );
1030 }
1031 }
1032 }
1033}