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