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 config = crate::config::load_config();
301 let source = config.auth.credential_source();
302 let cache = crate::auth::TokenCache::new();
303 let access = crate::auth::resolve_access_token("anthropic", &source, &cache, client)
304 .await
305 .map_err(|e| format!("Anthropic is not configured: {e}"))?;
306 let mut pages = Vec::new();
307 let mut after_id: Option<String> = None;
308
309 for _ in 0..ANTHROPIC_MODELS_MAX_PAGES {
310 let url = anthropic_models_url(after_id.as_deref());
311 let resp = catalog_get(client, &url)
312 .bearer_auth(&access)
313 .header("x-api-key", &access)
314 .header("anthropic-version", "2023-06-01")
315 .send()
316 .await
317 .map_err(|e| format!("request failed: {e}"))?;
318 let body = read_catalog_response(resp).await?;
319 let page = parse_anthropic_catalog_page(&body).map_err(|e| format!("parse failed: {e}"))?;
320 let next_after_id = page.last_id.clone();
321 let has_more = page.has_more && next_after_id.is_some();
322 pages.push(page.models);
323 if !has_more {
324 return Ok(merge_catalog_pages(pages));
325 }
326 after_id = next_after_id;
327 }
328
329 Ok(merge_catalog_pages(pages))
330}
331
332impl ModelCatalogProvider for OpenRouterCatalogProvider {
333 fn provider_key(&self) -> &'static str { "openrouter" }
334
335 fn fetch<'a>(
336 &'a self,
337 client: &'a reqwest::Client,
338 _overrides: &'a BTreeMap<String, String>,
339 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
340 Box::pin(async move { fetch_openrouter_catalog_models(client).await })
341 }
342}
343
344impl ModelCatalogProvider for GroqCatalogProvider {
345 fn provider_key(&self) -> &'static str { "groq" }
346
347 fn fetch<'a>(
348 &'a self,
349 client: &'a reqwest::Client,
350 overrides: &'a BTreeMap<String, String>,
351 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
352 Box::pin(async move {
353 let spec = super::registry::providers()
354 .iter()
355 .find(|s| s.key == "groq")
356 .ok_or_else(|| "unknown provider: groq".to_string())?;
357 let api_key = super::registry::resolve_provider("groq", overrides)
358 .map(|(cfg, _)| cfg.api_key)
359 .ok_or_else(|| format!("{} is not configured", spec.name))?;
360 let url = format!("{}/models", spec.base_url.trim_end_matches('/'));
361 let resp = catalog_get(client, &url)
362 .bearer_auth(api_key)
363 .send()
364 .await
365 .map_err(|e| format!("request failed: {e}"))?;
366 let body = read_catalog_response(resp).await?;
367 parse_groq_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
368 })
369 }
370}
371
372impl ModelCatalogProvider for NvidiaCatalogProvider {
373 fn provider_key(&self) -> &'static str { "nvidia" }
374
375 fn fetch<'a>(
376 &'a self,
377 client: &'a reqwest::Client,
378 _overrides: &'a BTreeMap<String, String>,
379 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
380 Box::pin(async move {
381 let resp = catalog_get(client, "https://integrate.api.nvidia.com/v1/models")
382 .send()
383 .await
384 .map_err(|e| format!("request failed: {e}"))?;
385 let body = read_catalog_response(resp).await?;
386 parse_nvidia_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
387 })
388 }
389}
390
391impl ModelCatalogProvider for AnthropicCatalogProvider {
392 fn provider_key(&self) -> &'static str { "claude" }
393
394 fn fetch<'a>(
395 &'a self,
396 client: &'a reqwest::Client,
397 _overrides: &'a BTreeMap<String, String>,
398 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
399 Box::pin(async move { fetch_anthropic_catalog_models(client).await })
400 }
401}
402
403impl ModelCatalogProvider for CodexCatalogProvider {
404 fn provider_key(&self) -> &'static str { "openai-codex" }
405
406 fn fetch<'a>(
407 &'a self,
408 _client: &'a reqwest::Client,
409 _overrides: &'a BTreeMap<String, String>,
410 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
411 Box::pin(async move { Ok(codex_static_catalog_models()) })
412 }
413}
414
415impl ModelCatalogProvider for GenericCatalogProvider {
416 fn provider_key(&self) -> &'static str { "generic" }
417
418 fn fetch<'a>(
419 &'a self,
420 _client: &'a reqwest::Client,
421 _overrides: &'a BTreeMap<String, String>,
422 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
423 Box::pin(async move {
424 Err("generic catalog fetch requires provider key; use fetch_generic_catalog_provider_models".to_string())
425 })
426 }
427}
428
429async fn fetch_generic_catalog_provider_models(
430 client: &reqwest::Client,
431 provider_key: &str,
432 overrides: &BTreeMap<String, String>,
433) -> Result<Vec<CatalogModel>, String> {
434 let specs = super::registry::providers();
435 let spec = specs
436 .iter()
437 .find(|s| s.key == provider_key)
438 .ok_or_else(|| format!("unknown provider: {provider_key}"))?;
439
440 let api_key = super::registry::resolve_provider(provider_key, overrides)
441 .map(|(cfg, _)| cfg.api_key)
442 .ok_or_else(|| format!("{} is not configured", spec.name))?;
443
444 fetch_generic_catalog_models(
445 client,
446 provider_key,
447 spec.name,
448 spec.base_url,
449 &api_key,
450 ).await
451}
452
453pub async fn fetch_openrouter_catalog_models(
455 client: &reqwest::Client,
456) -> Result<Vec<CatalogModel>, String> {
457 let resp = catalog_get(client, "https://openrouter.ai/api/v1/models")
458 .send()
459 .await
460 .map_err(|e| format!("request failed: {e}"))?;
461 let body = read_catalog_response(resp).await?;
462 parse_openrouter_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
463}
464
465pub async fn fetch_generic_catalog_models(
467 client: &reqwest::Client,
468 provider_key: &str,
469 provider_name: &str,
470 base_url: &str,
471 api_key: &str,
472) -> Result<Vec<CatalogModel>, String> {
473 let url = format!("{}/models", base_url.trim_end_matches('/'));
474 let resp = catalog_get(client, &url)
475 .bearer_auth(api_key)
476 .send()
477 .await
478 .map_err(|e| format!("request failed: {e}"))?;
479 let body = read_catalog_response(resp).await?;
480 parse_generic_catalog_models(&body, provider_key, provider_name)
481 .map_err(|e| format!("parse failed: {e}"))
482}
483
484pub async fn fetch_catalog_models(
489 client: &reqwest::Client,
490 provider_key: &str,
491 overrides: &BTreeMap<String, String>,
492) -> Result<Vec<CatalogModel>, String> {
493 let provider = catalog_provider_for(provider_key);
494 if provider.provider_key() == "generic" {
495 return fetch_generic_catalog_provider_models(client, provider_key, overrides).await;
496 }
497 provider.fetch(client, overrides).await
498}
499
500#[cfg(test)]
503mod tests {
504 use super::*;
505
506 #[test]
509 fn catalog_model_rejects_empty_ids() {
510 assert!(CatalogModel::new("openrouter", "OpenRouter", "").is_none());
511 assert!(CatalogModel::new("openrouter", "OpenRouter", " ").is_none());
512 }
513
514 #[test]
515 fn static_seed_sets_fallback_source_and_runtime_id() {
516 let m = from_static_seed("groq", "Groq", "llama-3.3-70b-versatile", "Llama 3.3 70B")
517 .expect("valid seed");
518 assert_eq!(m.runtime_id(), "groq/llama-3.3-70b-versatile");
519 assert_eq!(m.display_label(), "Llama 3.3 70B");
520 assert_eq!(m.source, CatalogSource::StaticFallback);
521 }
522
523 #[test]
524 fn static_seed_empty_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_seed_whitespace_label_stores_none() {
531 let m = from_static_seed("groq", "Groq", "model-x", " ").expect("valid id");
532 assert_eq!(m.label, None);
533 }
534
535 #[test]
536 fn static_seeds_from_spec_converts_all_groq_models() {
537 let spec = super::super::registry::providers()
538 .iter()
539 .find(|s| s.key == "groq")
540 .expect("groq spec");
541 let seeds = static_seeds_from_spec(spec);
542 assert_eq!(seeds.len(), spec.models.len());
543 assert!(seeds.iter().all(|m| m.source == CatalogSource::StaticFallback));
544 assert!(seeds.iter().all(|m| !m.id.is_empty()));
545 assert!(seeds.iter().all(|m| m.runtime_id().starts_with("groq/")));
546 }
547
548 #[test]
549 fn anthropic_runtime_id_is_bare() {
550 let mut m = CatalogModel::new("anthropic", "Anthropic", "claude-opus-4-7").unwrap();
551 m.provider_kind = CatalogProviderKind::Anthropic;
552 assert_eq!(m.runtime_id(), "claude-opus-4-7");
553 }
554
555 #[test]
556 fn pricing_summary_has_internal_reasoning_cost_zero_is_false() {
557 let p = PricingSummary {
558 prompt: None, completion: None,
559 internal_reasoning: Some("0".to_string()),
560 };
561 assert!(!p.has_internal_reasoning_cost());
562 }
563
564 #[test]
565 fn pricing_summary_has_internal_reasoning_cost_nonzero_is_true() {
566 let p = PricingSummary {
567 prompt: None, completion: None,
568 internal_reasoning: Some("0.0000035".to_string()),
569 };
570 assert!(p.has_internal_reasoning_cost());
571 }
572
573 #[test]
574 fn catalog_provider_trait_dispatch_selects_specialized_handlers() {
575 assert_eq!(catalog_provider_for("openrouter").provider_key(), "openrouter");
576 assert_eq!(catalog_provider_for("groq").provider_key(), "groq");
577 assert_eq!(catalog_provider_for("nvidia").provider_key(), "nvidia");
578 assert_eq!(catalog_provider_for("claude").provider_key(), "claude");
579 assert_eq!(catalog_provider_for("openai-codex").provider_key(), "openai-codex");
580 assert_eq!(catalog_provider_for("cerebras").provider_key(), "generic");
581 }
582
583 #[test]
584 fn catalog_request_timeout_is_bounded() {
585 assert_eq!(CATALOG_REQUEST_TIMEOUT, Duration::from_secs(15));
586 }
587
588 #[test]
589 fn anthropic_page_metadata_is_exposed_for_pagination() {
590 let page = parse_anthropic_catalog_page(r#"{
591 "data":[{"id":"claude-opus-4-7"}],
592 "has_more": true,
593 "last_id": "claude-opus-4-7"
594 }"#).expect("parse page");
595 assert!(page.has_more);
596 assert_eq!(page.last_id.as_deref(), Some("claude-opus-4-7"));
597 assert_eq!(page.models.len(), 1);
598 }
599
600 #[test]
601 fn anthropic_pagination_url_adds_after_id_cursor() {
602 assert_eq!(
603 anthropic_models_url(None),
604 "https://api.anthropic.com/v1/models?limit=100"
605 );
606 assert_eq!(
607 anthropic_models_url(Some("claude-opus-4-7")),
608 "https://api.anthropic.com/v1/models?limit=100&after_id=claude-opus-4-7"
609 );
610 }
611
612 #[test]
613 fn merge_catalog_pages_dedupes_by_id() {
614 let first = parse_anthropic_catalog_models(r#"{"data":[{"id":"claude-opus-4-7"}]}"#).unwrap();
615 let second = parse_anthropic_catalog_models(r#"{"data":[{"id":"claude-opus-4-7"},{"id":"claude-sonnet-4-6"}]}"#).unwrap();
616 let merged = merge_catalog_pages(vec![first, second]);
617 assert_eq!(merged.len(), 2);
618 assert_eq!(merged[0].id, "claude-opus-4-7");
619 assert_eq!(merged[1].id, "claude-sonnet-4-6");
620 }
621
622 mod openrouter {
625 use super::super::*;
626
627 const RICH_FIXTURE: &str = r#"{
628 "data": [
629 {
630 "id": "qwen/qwen3-coder",
631 "name": "Qwen: Qwen3 Coder",
632 "context_length": 131072,
633 "top_provider": { "max_completion_tokens": 32768 },
634 "supported_parameters": ["temperature", "top_p", "max_tokens"],
635 "pricing": { "prompt": "0.0000001", "completion": "0.0000004", "internal_reasoning": "0" },
636 "architecture": { "input_modalities": ["text"] }
637 },
638 {
639 "id": "anthropic/claude-opus-4-7",
640 "name": "Anthropic: Claude Opus 4.7",
641 "context_length": 200000,
642 "supported_parameters": ["temperature", "verbosity", "max_tokens"],
643 "pricing": { "prompt": "0.000015", "completion": "0.000075" }
644 },
645 {
646 "id": "openai/o4-mini",
647 "name": "OpenAI: o4-mini",
648 "context_length": 128000,
649 "supported_parameters": ["reasoning_effort", "max_tokens"],
650 "pricing": { "prompt": "0.0000011", "completion": "0.0000044" }
651 },
652 {
653 "id": "google/gemini-2.5-flash",
654 "name": "Google: Gemini 2.5 Flash",
655 "context_length": 1048576,
656 "supported_parameters": ["reasoning", "include_reasoning", "max_tokens"],
657 "pricing": { "prompt": "0.00000015", "completion": "0.0000035", "internal_reasoning": "0.0000035" },
658 "architecture": { "input_modalities": ["text", "image", "audio", "video"] }
659 },
660 {
661 "id": "",
662 "name": "Empty — must be filtered"
663 }
664 ]
665 }"#;
666
667 #[test]
668 fn parses_minimal_model() {
669 let json = r#"{"data":[{"id":"test/model","name":"Test Model"}]}"#;
670 let models = parse_openrouter_catalog_models(json).expect("parse ok");
671 assert_eq!(models.len(), 1);
672 assert_eq!(models[0].id, "test/model");
673 assert_eq!(models[0].label.as_deref(), Some("Test Model"));
674 assert_eq!(models[0].runtime_id(), "openrouter/test/model");
675 assert_eq!(models[0].provider_key, "openrouter");
676 assert_eq!(models[0].source, CatalogSource::Live);
677 }
678
679 #[test]
680 fn parses_context_length_and_max_output() {
681 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
682 let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
683 assert_eq!(qwen.context_tokens, Some(131_072));
684 assert_eq!(qwen.max_output_tokens, Some(32_768));
685 }
686
687 #[test]
688 fn filters_empty_ids() {
689 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
690 assert!(!models.iter().any(|m| m.id.is_empty()));
691 assert_eq!(models.len(), 4);
692 }
693
694 #[test]
695 fn parses_pricing_fields() {
696 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
697 let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
698 assert_eq!(qwen.pricing.prompt.as_deref(), Some("0.0000001"));
699 assert_eq!(qwen.pricing.completion.as_deref(), Some("0.0000004"));
700 assert!(!qwen.pricing.has_internal_reasoning_cost());
701 }
702
703 #[test]
704 fn parses_internal_reasoning_cost_flag() {
705 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
706 let gemini = models.iter().find(|m| m.id == "google/gemini-2.5-flash").unwrap();
707 assert!(gemini.pricing.has_internal_reasoning_cost());
708 }
709
710 #[test]
711 fn no_reasoning_params_maps_to_none() {
712 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
713 let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
714 assert_eq!(qwen.reasoning, ReasoningSupport::None);
715 }
716
717 #[test]
718 fn verbosity_param_maps_to_anthropic_adaptive() {
719 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
720 let claude = models.iter().find(|m| m.id == "anthropic/claude-opus-4-7").unwrap();
721 assert_eq!(claude.reasoning, ReasoningSupport::AnthropicAdaptive { adaptive: true });
722 }
723
724 #[test]
725 fn reasoning_effort_maps_to_openrouter_reasoning() {
726 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
727 let o4 = models.iter().find(|m| m.id == "openai/o4-mini").unwrap();
728 assert_eq!(o4.reasoning, ReasoningSupport::OpenRouter {
729 include_reasoning: false,
730 effort: true,
731 verbosity: false,
732 internal_reasoning_priced: false,
733 });
734 }
735
736 #[test]
737 fn reasoning_include_reasoning_maps_correctly() {
738 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
739 let gemini = models.iter().find(|m| m.id == "google/gemini-2.5-flash").unwrap();
740 assert_eq!(gemini.reasoning, ReasoningSupport::OpenRouter {
741 include_reasoning: true,
742 effort: false,
743 verbosity: false,
744 internal_reasoning_priced: true,
745 });
746 }
747
748 #[test]
749 fn parses_multimodal_input() {
750 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
751 let gemini = models.iter().find(|m| m.id == "google/gemini-2.5-flash").unwrap();
752 assert!(gemini.input_modalities.contains(&Modality::Text));
753 assert!(gemini.input_modalities.contains(&Modality::Image));
754 assert!(gemini.input_modalities.contains(&Modality::Audio));
755 assert!(gemini.input_modalities.contains(&Modality::Video));
756 }
757
758 #[test]
759 fn missing_modalities_defaults_to_text() {
760 let json = r#"{"data":[{"id":"test/model"}]}"#;
761 let models = parse_openrouter_catalog_models(json).expect("parse ok");
762 assert_eq!(models[0].input_modalities, vec![Modality::Text]);
763 }
764
765 #[test]
766 fn parses_openrouter_rich_metadata_and_reasoning_flags() {
767 let json = r#"{
769 "data": [{
770 "id": "anthropic/claude-sonnet-4.6",
771 "name": "Anthropic: Claude Sonnet 4.6",
772 "context_length": 1000000,
773 "architecture": { "input_modalities": ["text", "image"] },
774 "pricing": { "prompt": "0.000003", "completion": "0.000015", "internal_reasoning": "0.000012" },
775 "top_provider": { "max_completion_tokens": 128000 },
776 "supported_parameters": ["reasoning", "include_reasoning", "verbosity", "tools"]
777 }]
778 }"#;
779 let models = parse_openrouter_catalog_models(json).expect("parse ok");
780 assert_eq!(models.len(), 1);
781 let m = &models[0];
782 assert_eq!(m.runtime_id(), "openrouter/anthropic/claude-sonnet-4.6");
783 assert_eq!(m.context_tokens, Some(1_000_000));
784 assert_eq!(m.max_output_tokens, Some(128_000));
785 assert!(m.input_modalities.contains(&Modality::Image));
786 assert_eq!(m.reasoning, ReasoningSupport::AnthropicAdaptive { adaptive: true });
788 }
789
790 #[test]
791 fn parses_openrouter_non_reasoning_model_as_none() {
792 let json = r#"{
793 "data": [{
794 "id": "meta-llama/llama-3.3-70b-instruct",
795 "name": "Meta: Llama 3.3 70B",
796 "supported_parameters": ["temperature", "tools"]
797 }]
798 }"#;
799 let models = parse_openrouter_catalog_models(json).expect("parse ok");
800 assert_eq!(models[0].reasoning, ReasoningSupport::None);
801 }
802
803 #[test]
804 fn invalid_json_returns_error() {
805 assert!(parse_openrouter_catalog_models("{not json}").is_err());
806 }
807
808 #[test]
809 fn missing_data_key_returns_error() {
810 assert!(parse_openrouter_catalog_models(r#"{"models":[]}"#).is_err());
811 }
812 }
813
814 mod anthropic {
822 use super::super::*;
823
824 #[test]
825 fn parser_reads_optional_capabilities_and_token_limits() {
826 let json = r#"{
827 "data": [{
828 "id": "claude-opus-4-7",
829 "display_name": "Claude Opus 4.7",
830 "max_input_tokens": 200000,
831 "max_tokens": 32000,
832 "capabilities": {
833 "thinking": { "supported": true },
834 "effort": { "supported": true }
835 }
836 }],
837 "has_more": false
838 }"#;
839 let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
840 assert_eq!(models.len(), 1);
841 let model = &models[0];
842 assert_eq!(model.runtime_id(), "claude-opus-4-7");
843 assert_eq!(model.label.as_deref(), Some("Claude Opus 4.7"));
844 assert_eq!(model.context_tokens, Some(200_000));
845 assert_eq!(model.max_output_tokens, Some(32_000));
846 assert_eq!(model.reasoning, ReasoningSupport::AnthropicAdaptive { adaptive: true });
847 }
848
849 #[test]
850 fn parser_tolerates_missing_capabilities_as_unknown() {
851 let json = r#"{"data":[{"id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku"}]}"#;
852 let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
853 assert_eq!(models[0].reasoning, ReasoningSupport::Unknown);
854 }
855
856 #[test]
857 fn parser_filters_empty_ids() {
858 let json = r#"{"data":[{"id":""},{"id":"claude-sonnet-4-6"}]}"#;
859 let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
860 assert_eq!(models.len(), 1);
861 assert_eq!(models[0].id, "claude-sonnet-4-6");
862 }
863 }
864
865 mod codex {
866 use super::super::*;
867
868 #[test]
869 fn static_catalog_uses_fallback_source_and_prefixed_runtime_ids() {
870 let models = codex_static_catalog_models();
871 assert!(models.iter().any(|m| m.id == "gpt-5.5"));
872 assert!(models.iter().any(|m| m.id == "gpt-5.4"));
873 assert!(models.iter().any(|m| m.id == "gpt-5.4-mini"));
874 assert!(!models.iter().any(|m| m.id == "gpt-5.5-pro"));
875 assert!(!models.iter().any(|m| m.id == "gpt-5.4-nano"));
876 assert!(!models.iter().any(|m| m.id == "gpt-5.1-codex-mini"));
877 assert!(models.iter().all(|m| m.source == CatalogSource::StaticFallback));
878 assert!(models.iter().all(|m| m.runtime_id().starts_with("openai-codex/")));
879 }
880 }
881
882 mod groq {
885 use super::super::*;
886
887 #[test]
888 fn parser_extracts_context_window_and_filters_inactive() {
889 let json = r#"{"data":[
890 {"id":"llama-3.3-70b-versatile","active":true,"context_window":131072,"owned_by":"Meta"},
891 {"id":"old-model-v1","active":false,"context_window":8192,"owned_by":"Meta"}
892 ]}"#;
893 let models = parse_groq_catalog_models(json).expect("parse groq");
894 assert_eq!(models.len(), 1);
895 assert_eq!(models[0].id, "llama-3.3-70b-versatile");
896 assert_eq!(models[0].context_tokens, Some(131_072));
897 assert_eq!(models[0].reasoning, ReasoningSupport::None);
898 }
899
900 #[test]
901 fn inference_maps_reasoning_families() {
902 assert_eq!(infer_groq_reasoning("openai/gpt-oss-120b"), ReasoningSupport::GroqReasoning);
903 assert_eq!(infer_groq_reasoning("qwen/qwen3-32b"), ReasoningSupport::GroqReasoning);
904 assert_eq!(infer_groq_reasoning("groq/compound-mini"), ReasoningSupport::GroqReasoning);
905 assert_eq!(infer_groq_reasoning("llama-3.3-70b-versatile"), ReasoningSupport::None);
906 }
907
908 #[test]
909 fn parser_filters_empty_ids() {
910 let json = r#"{"data":[{"id":"","active":true},{"id":"openai/gpt-oss-20b","active":true,"context_window":131072}]}"#;
911 let models = parse_groq_catalog_models(json).expect("parse groq");
912 assert_eq!(models.len(), 1);
913 assert_eq!(models[0].id, "openai/gpt-oss-20b");
914 }
915 }
916
917 mod nvidia {
918 use super::super::*;
919
920 #[test]
921 fn parser_dedupes_and_enriches_known_context() {
922 let json = r#"{"data":[
923 {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
924 {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
925 {"id":"moonshotai/kimi-k2-thinking","owned_by":"moonshotai"}
926 ]}"#;
927 let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
928 assert_eq!(models.len(), 2);
929 let ultra = models.iter().find(|m| m.id.contains("ultra")).unwrap();
930 assert_eq!(ultra.context_tokens, Some(128_000));
931 assert_eq!(ultra.reasoning, ReasoningSupport::NvidiaInlineThinking);
932 let kimi = models.iter().find(|m| m.id.contains("kimi")).unwrap();
933 assert_eq!(kimi.context_tokens, Some(256_000));
934 }
935
936 #[test]
937 fn inference_detects_thinking_and_standard_models() {
938 assert_eq!(infer_nvidia_reasoning("qwen/qwen3-next-80b-a3b-thinking"), ReasoningSupport::NvidiaInlineThinking);
939 assert_eq!(infer_nvidia_reasoning("nvidia/cosmos-reason2-8b"), ReasoningSupport::NvidiaInlineThinking);
940 assert_eq!(infer_nvidia_reasoning("meta/llama-3.3-70b-instruct"), ReasoningSupport::None);
941 }
942
943 #[test]
944 fn parser_filters_empty_ids() {
945 let json = r#"{"data":[{"id":""},{"id":"meta/llama-3.3-70b-instruct"}]}"#;
946 let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
947 assert_eq!(models.len(), 1);
948 assert_eq!(models[0].id, "meta/llama-3.3-70b-instruct");
949 }
950 }
951
952
953 mod generic_compat {
954 use super::super::*;
955
956 #[test]
957 fn parses_generic_catalog_models_and_filters_empty_ids() {
958 let json = r#"{
959 "data": [
960 { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
961 { "id": "" },
962 { "id": "openai/gpt-oss-120b" }
963 ]
964 }"#;
965 let models = parse_generic_catalog_models(json, "openrouter", "OpenRouter")
966 .expect("parse ok");
967 assert_eq!(models.len(), 2);
968 assert_eq!(models[0].runtime_id(), "openrouter/qwen/qwen3-coder");
969 assert_eq!(models[0].display_label(), "Qwen: Qwen3 Coder");
970 assert_eq!(models[1].display_label(), "openai/gpt-oss-120b");
971 }
972
973 #[test]
974 fn whitespace_only_id_is_filtered() {
975 let json = r#"{"data":[{"id":" "},{"id":"valid"}]}"#;
976 let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
977 assert_eq!(models.len(), 1);
978 assert_eq!(models[0].id, "valid");
979 }
980
981 #[test]
982 fn whitespace_label_stored_as_none() {
983 let json = r#"{"data":[{"id":"m1","name":" "}]}"#;
984 let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
985 assert_eq!(models[0].label, None);
986 }
987
988 #[test]
989 fn generic_catalog_source_is_live() {
990 let json = r#"{"data":[{"id":"m1","name":"Model One"}]}"#;
991 let models = parse_generic_catalog_models(json, "testprovider", "Test")
992 .expect("parse ok");
993 assert_eq!(models[0].source, CatalogSource::Live);
994 }
995
996 #[test]
997 fn generic_reasoning_is_generic_open_ai() {
998 let json = r#"{"data":[{"id":"m1"}]}"#;
999 let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
1000 assert_eq!(models[0].reasoning, ReasoningSupport::GenericOpenAi);
1001 }
1002
1003 #[test]
1004 fn generic_parse_matches_legacy_parse_behavior() {
1005 let json = r#"{
1008 "data": [
1009 { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
1010 { "id": "openai/gpt-oss-120b" }
1011 ]
1012 }"#;
1013 let legacy = super::super::super::registry::parse_provider_models_response(json)
1014 .expect("legacy parse ok");
1015 let catalog = parse_generic_catalog_models(json, "openrouter", "OpenRouter")
1016 .expect("catalog parse ok");
1017 assert_eq!(legacy.len(), catalog.len());
1018 for (l, c) in legacy.iter().zip(catalog.iter()) {
1019 assert_eq!(l.id, c.id, "ids must match");
1020 assert_eq!(l.name, c.label, "labels must match");
1021 }
1022 }
1023
1024 #[test]
1025 fn static_seeds_from_spec_all_providers() {
1026 for spec in super::super::super::registry::providers() {
1028 if spec.models.is_empty() { continue; }
1029 let seeds = super::super::static_seeds_from_spec(spec);
1030 assert!(!seeds.is_empty(), "no seeds for {}", spec.key);
1031 assert!(
1032 seeds.iter().all(|m| m.runtime_id().starts_with(&format!("{}/", spec.key))),
1033 "runtime_id prefix wrong for {}",
1034 spec.key
1035 );
1036 }
1037 }
1038 }
1039}