1use std::future::Future;
26use std::pin::Pin;
27use std::time::Duration;
28
29pub const CATALOG_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
30const ANTHROPIC_MODELS_MAX_PAGES: usize = 20;
31
32mod anthropic;
33pub mod capability_cache;
34mod codex;
35mod generic;
36mod github_copilot;
37mod google_gemini;
38mod groq;
39pub mod kimi;
40mod nvidia;
41mod openrouter;
42pub mod validation;
43mod xai;
44
45pub use anthropic::{
46 anthropic_mode_capabilities, anthropic_models_url, anthropic_static_capability,
47 merge_catalog_pages, parse_anthropic_catalog_models, parse_anthropic_catalog_page,
48 plan_anthropic_execution, plan_standard_anthropic_transport, AnthropicCatalogPage,
49 AnthropicExecutionMode, AnthropicExecutionPlan, AnthropicPlanError, AnthropicPlanErrorCode,
50 AnthropicPlanPrerequisites, AnthropicWireEffort, AnthropicWorkflowPlan,
51};
52pub use codex::{
53 codex_models_path, codex_models_url, codex_static_capability, codex_static_catalog_models,
54 parse_codex_catalog_models, plan_codex_execution, validate_codex_level, CodexCapabilitySource,
55 CodexExecutionMode, CodexExecutionPlan, CodexMultiAgentMode, CodexPlanError,
56 CodexPlanErrorCode, CodexRequestRole, CodexWireEffort, ExecutionRole,
57 PROVIDER_KEY as CODEX_PROVIDER_KEY, PROVIDER_NAME as CODEX_PROVIDER_NAME,
58};
59pub use generic::parse_generic_catalog_models;
60pub use github_copilot::{
61 copilot_model, copilot_static_catalog_models, models_request_headers,
62 parse_copilot_catalog_entries, parse_copilot_catalog_models,
63 preferred_wire_protocol_from_endpoints, runtime_wire_protocol as github_copilot_runtime_model,
64 selectable_copilot_entries, validate_models_endpoint, CopilotCatalogEntry, CopilotEndpoint,
65 CopilotModelDescriptor, CopilotPolicyState, CopilotWire, COPILOT_API_VERSION,
66 COPILOT_FALLBACK_MODELS, MAX_MODELS_BODY_BYTES, MODELS_BASE_URL, MODELS_PATH, MODELS_URL,
67 PROVIDER_KEY as COPILOT_PROVIDER_KEY, PROVIDER_NAME as COPILOT_PROVIDER_NAME,
68};
69pub use google_gemini::{
70 google_gemini_model, google_gemini_static_catalog_models, GoogleGeminiModelDescriptor,
71 GOOGLE_GEMINI_TEXT_MODELS, PROVIDER_KEY as GOOGLE_GEMINI_PROVIDER_KEY,
72 PROVIDER_NAME as GOOGLE_GEMINI_PROVIDER_NAME,
73};
74pub use groq::{infer_groq_reasoning, parse_groq_catalog_models};
75pub use kimi::{apply_kimi_reasoning_params, kimi_static_capability, KimiReasoningCapability};
76pub use nvidia::{infer_nvidia_reasoning, parse_nvidia_catalog_models};
77pub use openrouter::parse_openrouter_catalog_models;
78pub use xai::{
79 xai_model, xai_static_capability, xai_static_catalog_models, XaiModelDescriptor,
80 XaiReasoningCapability, XAI_TEXT_MODELS,
81};
82
83#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum Modality {
88 Text,
89 Image,
90 Audio,
91 Video,
92 File,
93 Other(String),
94}
95
96impl Modality {
97 #[allow(clippy::should_implement_trait)]
98 pub fn from_str(s: &str) -> Self {
99 match s {
100 "text" => Modality::Text,
101 "image" => Modality::Image,
102 "audio" => Modality::Audio,
103 "video" => Modality::Video,
104 "file" => Modality::File,
105 other => Modality::Other(other.to_string()),
106 }
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Default)]
114pub struct PricingSummary {
115 pub prompt: Option<String>,
117 pub completion: Option<String>,
119 pub internal_reasoning: Option<String>,
121}
122
123impl PricingSummary {
124 pub fn has_internal_reasoning_cost(&self) -> bool {
126 self.internal_reasoning
127 .as_deref()
128 .map(|s| s != "0" && !s.trim().is_empty())
129 .unwrap_or(false)
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum CodexMultiAgentVersion {
141 V1,
142 V2,
143 Unknown,
144}
145
146impl CodexMultiAgentVersion {
147 pub fn as_str(self) -> &'static str {
148 match self {
149 Self::V1 => "v1",
150 Self::V2 => "v2",
151 Self::Unknown => "unknown",
152 }
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum ReasoningSupport {
159 None,
161 AnthropicAdaptive { adaptive: bool },
163 OpenRouter {
165 include_reasoning: bool,
166 effort: bool,
167 verbosity: bool,
168 internal_reasoning_priced: bool,
169 },
170 GroqReasoning,
172 NvidiaInlineThinking,
174 GenericOpenAi,
176 CodexNamed {
178 supported: Vec<agent_core::reasoning::ReasoningLevel>,
181 default_level: Option<agent_core::reasoning::ReasoningLevel>,
183 multi_agent_version: Option<CodexMultiAgentVersion>,
185 },
186 Unknown,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
193pub enum CatalogSource {
194 Live,
196 StaticFallback,
198 StaticWithLive,
200 Inferred,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
207pub enum CatalogProviderKind {
208 Anthropic,
209 OpenRouter,
210 Groq,
211 NvidiaNim,
212 OpenAiCodex,
213 Generic { key: String },
214 Local,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct CatalogModel {
222 pub provider_key: String,
224 pub provider_name: String,
226 pub provider_kind: CatalogProviderKind,
228 pub id: String,
230 pub label: Option<String>,
232 pub context_tokens: Option<u64>,
234 pub max_output_tokens: Option<u64>,
236 pub input_modalities: Vec<Modality>,
238 pub pricing: PricingSummary,
240 pub reasoning: ReasoningSupport,
242 pub source: CatalogSource,
244}
245
246impl CatalogModel {
247 pub fn new(
249 provider_key: impl Into<String>,
250 provider_name: impl Into<String>,
251 id: impl Into<String>,
252 ) -> Option<Self> {
253 let id = id.into();
254 if id.trim().is_empty() {
255 return None;
256 }
257 let pk = provider_key.into();
258 Some(Self {
259 provider_kind: CatalogProviderKind::Generic { key: pk.clone() },
260 provider_name: provider_name.into(),
261 provider_key: pk,
262 id,
263 label: None,
264 context_tokens: None,
265 max_output_tokens: None,
266 input_modalities: vec![Modality::Text],
267 pricing: PricingSummary::default(),
268 reasoning: ReasoningSupport::Unknown,
269 source: CatalogSource::Live,
270 })
271 }
272
273 pub fn runtime_id(&self) -> String {
275 format!("{}/{}", self.provider_key, self.id)
276 }
277
278 pub fn display_label(&self) -> &str {
280 self.label.as_deref().unwrap_or(&self.id)
281 }
282
283 pub fn codex_supported_levels(&self) -> Option<&[agent_core::reasoning::ReasoningLevel]> {
286 match &self.reasoning {
287 ReasoningSupport::CodexNamed { supported, .. } => Some(supported),
288 _ => None,
289 }
290 }
291
292 pub fn codex_multi_agent_version(&self) -> Option<CodexMultiAgentVersion> {
295 match &self.reasoning {
296 ReasoningSupport::CodexNamed {
297 multi_agent_version,
298 ..
299 } => *multi_agent_version,
300 _ => None,
301 }
302 }
303
304 pub fn codex_supports_level(&self, level: agent_core::reasoning::ReasoningLevel) -> bool {
307 self.codex_supported_levels()
308 .is_some_and(|levels| levels.contains(&level))
309 }
310}
311
312pub fn from_static_seed(
316 provider_key: &str,
317 provider_name: &str,
318 id: &str,
319 label: &str,
320) -> Option<CatalogModel> {
321 let mut m = CatalogModel::new(provider_key, provider_name, id)?;
322 m.label = if label.trim().is_empty() {
323 None
324 } else {
325 Some(label.to_string())
326 };
327 m.source = CatalogSource::StaticFallback;
328 m.reasoning = ReasoningSupport::Unknown;
329 Some(m)
330}
331
332pub fn static_seeds_from_spec(spec: &super::registry::ProviderSpec) -> Vec<CatalogModel> {
334 spec.models
335 .iter()
336 .filter_map(|(id, label, _tier)| from_static_seed(spec.key, spec.name, id, label))
337 .collect()
338}
339
340pub trait ModelCatalogProvider: Sync {
343 fn provider_key(&self) -> &'static str;
344
345 fn fetch<'a>(
346 &'a self,
347 client: &'a reqwest::Client,
348 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>>;
349}
350
351pub struct OpenRouterCatalogProvider;
352pub struct GroqCatalogProvider;
353pub struct NvidiaCatalogProvider;
354pub struct AnthropicCatalogProvider;
355pub struct CodexCatalogProvider;
356pub struct XaiCatalogProvider;
357pub struct GitHubCopilotCatalogProvider;
358pub struct GoogleGeminiCatalogProvider;
359pub struct GenericCatalogProvider;
360
361pub fn catalog_provider_for(provider_key: &str) -> &'static dyn ModelCatalogProvider {
362 match provider_key {
363 "openrouter" => &OpenRouterCatalogProvider,
364 "groq" => &GroqCatalogProvider,
365 "nvidia" => &NvidiaCatalogProvider,
366 "claude" | "anthropic" => &AnthropicCatalogProvider,
367 "openai-codex" => &CodexCatalogProvider,
368 "xai-auth" => &XaiCatalogProvider,
369 "github-copilot" => &GitHubCopilotCatalogProvider,
370 "google-gemini" => &GoogleGeminiCatalogProvider,
371 _ => &GenericCatalogProvider,
372 }
373}
374
375fn catalog_get(client: &reqwest::Client, url: &str) -> reqwest::RequestBuilder {
376 client.get(url).timeout(CATALOG_REQUEST_TIMEOUT)
377}
378
379async fn read_catalog_response(resp: reqwest::Response) -> Result<String, String> {
380 let status = resp.status();
381 let body = resp.text().await.map_err(|e| format!("read failed: {e}"))?;
382 if !status.is_success() {
383 return Err(format!("model list failed: HTTP {status}"));
384 }
385 Ok(body)
386}
387
388async fn fetch_anthropic_catalog_models(
389 _client: &reqwest::Client,
390) -> Result<Vec<CatalogModel>, String> {
391 let mut pages = Vec::new();
395 let mut after_id: Option<String> = None;
396
397 for _ in 0..ANTHROPIC_MODELS_MAX_PAGES {
398 let path = anthropic_models_proxy_path(after_id.as_deref());
399 let body = broker_proxy_catalog_body("anthropic", &path).await?;
400 let page = parse_anthropic_catalog_page(&body).map_err(|e| format!("parse failed: {e}"))?;
401 let next_after_id = page.last_id.clone();
402 let has_more = page.has_more && next_after_id.is_some();
403 pages.push(page.models);
404 if !has_more {
405 return Ok(merge_catalog_pages(pages));
406 }
407 after_id = next_after_id;
408 }
409
410 Ok(merge_catalog_pages(pages))
411}
412
413pub fn anthropic_models_proxy_path(after_id: Option<&str>) -> String {
415 let absolute = anthropic_models_url(after_id);
417 absolute
418 .strip_prefix("https://api.anthropic.com")
419 .unwrap_or(&absolute)
420 .to_string()
421}
422
423impl ModelCatalogProvider for OpenRouterCatalogProvider {
424 fn provider_key(&self) -> &'static str {
425 "openrouter"
426 }
427
428 fn fetch<'a>(
429 &'a self,
430 client: &'a reqwest::Client,
431 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
432 Box::pin(async move { fetch_openrouter_catalog_models(client).await })
433 }
434}
435
436impl ModelCatalogProvider for GroqCatalogProvider {
437 fn provider_key(&self) -> &'static str {
438 "groq"
439 }
440
441 fn fetch<'a>(
442 &'a self,
443 _client: &'a reqwest::Client,
444 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
445 Box::pin(async move {
446 let body = broker_catalog_models_body("groq").await?;
447 parse_groq_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
448 })
449 }
450}
451
452impl ModelCatalogProvider for NvidiaCatalogProvider {
453 fn provider_key(&self) -> &'static str {
454 "nvidia"
455 }
456
457 fn fetch<'a>(
458 &'a self,
459 client: &'a reqwest::Client,
460 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
461 Box::pin(async move {
462 let resp = catalog_get(client, "https://integrate.api.nvidia.com/v1/models")
463 .send()
464 .await
465 .map_err(|e| format!("request failed: {e}"))?;
466 let body = read_catalog_response(resp).await?;
467 parse_nvidia_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
468 })
469 }
470}
471
472impl ModelCatalogProvider for AnthropicCatalogProvider {
473 fn provider_key(&self) -> &'static str {
474 "claude"
475 }
476
477 fn fetch<'a>(
478 &'a self,
479 client: &'a reqwest::Client,
480 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
481 Box::pin(async move { fetch_anthropic_catalog_models(client).await })
482 }
483}
484
485pub fn is_missing_credential_catalog_error(err: &str) -> bool {
491 let lower = err.to_lowercase();
496
497 if lower.contains("model list failed")
499 || lower.contains("parse failed")
500 || lower.contains("broker denied")
501 || lower.contains("broker transport error")
502 || lower.contains("missing chatgpt account id")
503 || lower.contains("http 4")
504 || lower.contains("http 5")
505 || lower.contains("connection reset")
506 || lower.contains("timed out")
507 || lower.contains("timeout")
508 {
509 return false;
510 }
511
512 lower.contains("credential error: no credentials for ")
517 || lower.contains("no credential configured")
518 || lower.contains("unknown provider:")
519 || lower.contains("not logged")
520 || lower.contains("registration required")
521}
522
523impl ModelCatalogProvider for CodexCatalogProvider {
524 fn provider_key(&self) -> &'static str {
525 "openai-codex"
526 }
527
528 fn fetch<'a>(
529 &'a self,
530 _client: &'a reqwest::Client,
531 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
532 Box::pin(async move {
536 let path = codex_models_path(env!("CARGO_PKG_VERSION"));
537 match broker_proxy_catalog_body("openai-codex", &path).await {
538 Ok(body) => {
539 let models = parse_codex_catalog_models(&body)
540 .map_err(|e| format!("parse failed: {e}"))?;
541 capability_cache::replace_provider(self.provider_key(), &models);
542 Ok(models)
543 }
544 Err(CatalogBrokerError::NotAuthenticated(_)) => Ok(codex_static_catalog_models()),
547 Err(CatalogBrokerError::Failed(err)) => Err(err),
548 }
549 })
550 }
551}
552
553impl ModelCatalogProvider for XaiCatalogProvider {
554 fn provider_key(&self) -> &'static str {
555 "xai-auth"
556 }
557
558 fn fetch<'a>(
559 &'a self,
560 _client: &'a reqwest::Client,
561 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
562 Box::pin(async move { Ok(xai_static_catalog_models()) })
563 }
564}
565
566impl ModelCatalogProvider for GitHubCopilotCatalogProvider {
567 fn provider_key(&self) -> &'static str {
568 "github-copilot"
569 }
570
571 fn fetch<'a>(
572 &'a self,
573 _client: &'a reqwest::Client,
574 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
575 Box::pin(async move {
580 match broker_catalog_models_body("github-copilot").await {
581 Ok(body) => {
582 parse_copilot_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
583 }
584 Err(CatalogBrokerError::NotAuthenticated(_)) => Ok(copilot_static_catalog_models()),
585 Err(CatalogBrokerError::Failed(err)) => Err(err),
586 }
587 })
588 }
589}
590
591impl ModelCatalogProvider for GoogleGeminiCatalogProvider {
592 fn provider_key(&self) -> &'static str {
593 "google-gemini"
594 }
595
596 fn fetch<'a>(
597 &'a self,
598 _client: &'a reqwest::Client,
599 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
600 Box::pin(async move { Ok(google_gemini_static_catalog_models()) })
606 }
607}
608
609impl ModelCatalogProvider for GenericCatalogProvider {
610 fn provider_key(&self) -> &'static str {
611 "generic"
612 }
613
614 fn fetch<'a>(
615 &'a self,
616 _client: &'a reqwest::Client,
617 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
618 Box::pin(async move {
619 Err("generic catalog fetch requires provider key; use fetch_generic_catalog_provider_models".to_string())
620 })
621 }
622}
623
624async fn fetch_generic_catalog_provider_models(
625 provider_key: &str,
626) -> Result<Vec<CatalogModel>, String> {
627 let specs = super::registry::providers();
628 let spec = specs
629 .iter()
630 .find(|s| s.key == provider_key)
631 .ok_or_else(|| format!("unknown provider: {provider_key}"))?;
632 if !crate::auth::broker::static_key_configured(provider_key) {
633 return Err(format!("{} is not configured", spec.name));
634 }
635 let body = broker_catalog_models_body(provider_key).await?;
636 parse_generic_catalog_models(&body, provider_key, spec.name)
637 .map_err(|e| format!("parse failed: {e}"))
638}
639
640async fn broker_catalog_models_body(provider_key: &str) -> Result<String, CatalogBrokerError> {
643 broker_proxy_catalog_body(provider_key, "/models").await
644}
645
646#[derive(Debug, Clone, PartialEq, Eq)]
650pub enum CatalogBrokerError {
651 NotAuthenticated(String),
655 Failed(String),
658}
659
660impl CatalogBrokerError {
661 fn message(&self) -> &str {
662 match self {
663 Self::NotAuthenticated(msg) | Self::Failed(msg) => msg,
664 }
665 }
666}
667
668impl From<CatalogBrokerError> for String {
669 fn from(err: CatalogBrokerError) -> Self {
670 err.message().to_string()
671 }
672}
673
674fn classify_catalog_broker_error(err: agent_core::auth::BrokerError) -> CatalogBrokerError {
679 use agent_core::auth::BrokerError as B;
680 let msg = format!("request failed: {err}");
681 match &err {
682 B::NotConfigured(_) | B::UnknownProvider(_) | B::RegistrationRequired { .. } => {
683 CatalogBrokerError::NotAuthenticated(msg)
684 }
685 B::Credential(detail) if detail.starts_with("No credentials for ") => {
686 CatalogBrokerError::NotAuthenticated(msg)
687 }
688 _ => CatalogBrokerError::Failed(msg),
689 }
690}
691
692async fn broker_proxy_catalog_body(
695 provider_key: &str,
696 path: &str,
697) -> Result<String, CatalogBrokerError> {
698 let resp = crate::auth::broker::global_broker()
699 .proxy(crate::auth::ProxyRequest {
700 provider: provider_key.to_string(),
701 method: crate::auth::ProxyMethod::Get,
702 path: path.to_string(),
703 body: None,
704 stream: false,
705 body_bytes: None,
706 })
707 .await
708 .map_err(classify_catalog_broker_error)?;
709 if !(200..300).contains(&resp.status) {
710 return Err(CatalogBrokerError::Failed(format!(
711 "model list failed: HTTP {}",
712 resp.status
713 )));
714 }
715 Ok(resp.body)
716}
717
718pub async fn fetch_openrouter_catalog_models(
720 client: &reqwest::Client,
721) -> Result<Vec<CatalogModel>, String> {
722 let resp = catalog_get(client, "https://openrouter.ai/api/v1/models")
723 .send()
724 .await
725 .map_err(|e| format!("request failed: {e}"))?;
726 let body = read_catalog_response(resp).await?;
727 parse_openrouter_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
728}
729
730pub async fn fetch_catalog_models(
735 client: &reqwest::Client,
736 provider_key: &str,
737) -> Result<Vec<CatalogModel>, String> {
738 let provider = catalog_provider_for(provider_key);
739 if provider.provider_key() == "generic" {
740 return fetch_generic_catalog_provider_models(provider_key).await;
741 }
742 provider.fetch(client).await
743}
744
745#[cfg(test)]
748mod tests {
749 use super::*;
750
751 #[test]
754 fn catalog_model_rejects_empty_ids() {
755 assert!(CatalogModel::new("openrouter", "OpenRouter", "").is_none());
756 assert!(CatalogModel::new("openrouter", "OpenRouter", " ").is_none());
757 }
758
759 #[test]
760 fn static_seed_sets_fallback_source_and_runtime_id() {
761 let m = from_static_seed("groq", "Groq", "llama-3.3-70b-versatile", "Llama 3.3 70B")
762 .expect("valid seed");
763 assert_eq!(m.runtime_id(), "groq/llama-3.3-70b-versatile");
764 assert_eq!(m.display_label(), "Llama 3.3 70B");
765 assert_eq!(m.source, CatalogSource::StaticFallback);
766 }
767
768 #[test]
769 fn static_seed_empty_label_stores_none() {
770 let m = from_static_seed("groq", "Groq", "model-x", "").expect("valid id");
771 assert_eq!(m.label, None);
772 }
773
774 #[test]
775 fn static_seed_whitespace_label_stores_none() {
776 let m = from_static_seed("groq", "Groq", "model-x", " ").expect("valid id");
777 assert_eq!(m.label, None);
778 }
779
780 #[test]
781 fn static_seeds_from_spec_converts_all_groq_models() {
782 let spec = super::super::registry::providers()
783 .iter()
784 .find(|s| s.key == "groq")
785 .expect("groq spec");
786 let seeds = static_seeds_from_spec(spec);
787 assert_eq!(seeds.len(), spec.models.len());
788 assert!(seeds
789 .iter()
790 .all(|m| m.source == CatalogSource::StaticFallback));
791 assert!(seeds.iter().all(|m| !m.id.is_empty()));
792 assert!(seeds.iter().all(|m| m.runtime_id().starts_with("groq/")));
793 }
794
795 #[test]
796 fn anthropic_runtime_id_is_provider_qualified() {
797 let mut m = CatalogModel::new("anthropic", "Anthropic", "claude-opus-4-7").unwrap();
798 m.provider_kind = CatalogProviderKind::Anthropic;
799 assert_eq!(m.runtime_id(), "anthropic/claude-opus-4-7");
800 }
801
802 #[test]
803 fn pricing_summary_has_internal_reasoning_cost_zero_is_false() {
804 let p = PricingSummary {
805 prompt: None,
806 completion: None,
807 internal_reasoning: Some("0".to_string()),
808 };
809 assert!(!p.has_internal_reasoning_cost());
810 }
811
812 #[test]
813 fn pricing_summary_has_internal_reasoning_cost_nonzero_is_true() {
814 let p = PricingSummary {
815 prompt: None,
816 completion: None,
817 internal_reasoning: Some("0.0000035".to_string()),
818 };
819 assert!(p.has_internal_reasoning_cost());
820 }
821
822 #[tokio::test]
823 async fn ui_catalog_fetch_includes_xai_static_models() {
824 let models = fetch_catalog_models(&reqwest::Client::new(), "xai-auth")
825 .await
826 .expect("static xAI catalog");
827 assert_eq!(models, xai_static_catalog_models());
828 assert!(models
829 .iter()
830 .all(|model| model.runtime_id().starts_with("xai-auth/")));
831 }
832
833 #[tokio::test]
834 #[serial_test::serial(synaps_base_dir)]
837 async fn ui_catalog_fetch_github_copilot_returns_prefixed_chat_models() {
838 let models = fetch_catalog_models(&reqwest::Client::new(), "github-copilot")
842 .await
843 .expect("GitHub Copilot catalog");
844 assert!(!models.is_empty());
845 assert!(models
846 .iter()
847 .all(|model| model.runtime_id().starts_with("github-copilot/")));
848 let ids: std::collections::HashSet<_> = models.iter().map(|m| m.id.as_str()).collect();
849 for required in ["gpt-5.3-codex", "claude-sonnet-4.6", "gemini-3.5-flash"] {
852 assert!(ids.contains(required), "missing {required}");
853 }
854 assert!(!ids.contains("text-embedding-3-small"));
855 }
856
857 #[tokio::test]
862 #[serial_test::serial(synaps_base_dir)]
863 async fn copilot_catalog_without_credentials_is_deterministic_static_fallback() {
864 let _base = crate::test_env::BaseDirGuard::new();
865 let models = fetch_catalog_models(&reqwest::Client::new(), "github-copilot")
866 .await
867 .expect("credential-less fetch must fall back to static seeds");
868 assert_eq!(models, copilot_static_catalog_models());
869 assert!(models
870 .iter()
871 .all(|m| m.runtime_id().starts_with("github-copilot/")));
872 }
873
874 #[tokio::test]
879 #[serial_test::serial(synaps_base_dir)]
880 async fn copilot_catalog_is_deterministic_under_concurrent_base_dir_churn() {
881 let base_a = tempfile::TempDir::new().unwrap();
882 let base_b = tempfile::TempDir::new().unwrap();
883 let old = std::env::var("SYNAPS_BASE_DIR").ok();
884 std::env::set_var("SYNAPS_BASE_DIR", base_a.path());
885
886 let local = tokio::task::LocalSet::new();
887 local
888 .run_until(async {
889 let churn = {
890 let a = base_a.path().to_path_buf();
891 let b = base_b.path().to_path_buf();
892 tokio::task::spawn_local(async move {
895 loop {
896 std::env::set_var("SYNAPS_BASE_DIR", &a);
897 tokio::task::yield_now().await;
898 std::env::set_var("SYNAPS_BASE_DIR", &b);
899 tokio::task::yield_now().await;
900 }
901 })
902 };
903 for _ in 0..20 {
904 let models = fetch_catalog_models(&reqwest::Client::new(), "github-copilot")
905 .await
906 .expect("fetch must be deterministic under base-dir churn");
907 assert_eq!(models, copilot_static_catalog_models());
908 tokio::task::yield_now().await;
909 }
910 churn.abort();
911 })
912 .await;
913
914 match old {
915 Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
916 None => std::env::remove_var("SYNAPS_BASE_DIR"),
917 }
918 }
919
920 #[test]
921 fn github_copilot_static_catalog_is_available_without_network() {
922 let models = copilot_static_catalog_models();
923 assert_eq!(models.len(), COPILOT_FALLBACK_MODELS.len());
924 assert!(models
925 .iter()
926 .all(|m| m.source == CatalogSource::StaticFallback));
927 }
928
929 #[test]
930 fn catalog_provider_trait_dispatch_selects_specialized_handlers() {
931 assert_eq!(
932 catalog_provider_for("openrouter").provider_key(),
933 "openrouter"
934 );
935 assert_eq!(catalog_provider_for("groq").provider_key(), "groq");
936 assert_eq!(catalog_provider_for("nvidia").provider_key(), "nvidia");
937 assert_eq!(catalog_provider_for("claude").provider_key(), "claude");
938 assert_eq!(
939 catalog_provider_for("openai-codex").provider_key(),
940 "openai-codex"
941 );
942 assert_eq!(catalog_provider_for("xai-auth").provider_key(), "xai-auth");
943 assert_eq!(
944 catalog_provider_for("github-copilot").provider_key(),
945 "github-copilot"
946 );
947 assert_eq!(catalog_provider_for("cerebras").provider_key(), "generic");
948 }
949
950 #[test]
951 fn catalog_request_timeout_is_bounded() {
952 assert_eq!(CATALOG_REQUEST_TIMEOUT, Duration::from_secs(15));
953 }
954
955 #[test]
956 fn anthropic_page_metadata_is_exposed_for_pagination() {
957 let page = parse_anthropic_catalog_page(
958 r#"{
959 "data":[{"id":"claude-opus-4-7"}],
960 "has_more": true,
961 "last_id": "claude-opus-4-7"
962 }"#,
963 )
964 .expect("parse page");
965 assert!(page.has_more);
966 assert_eq!(page.last_id.as_deref(), Some("claude-opus-4-7"));
967 assert_eq!(page.models.len(), 1);
968 }
969
970 #[test]
971 fn anthropic_pagination_url_adds_after_id_cursor() {
972 assert_eq!(
973 anthropic_models_url(None),
974 "https://api.anthropic.com/v1/models?limit=100"
975 );
976 assert_eq!(
977 anthropic_models_url(Some("claude-opus-4-7")),
978 "https://api.anthropic.com/v1/models?limit=100&after_id=claude-opus-4-7"
979 );
980 }
981
982 #[test]
983 fn merge_catalog_pages_dedupes_by_id() {
984 let first =
985 parse_anthropic_catalog_models(r#"{"data":[{"id":"claude-opus-4-7"}]}"#).unwrap();
986 let second = parse_anthropic_catalog_models(
987 r#"{"data":[{"id":"claude-opus-4-7"},{"id":"claude-sonnet-4-6"}]}"#,
988 )
989 .unwrap();
990 let merged = merge_catalog_pages(vec![first, second]);
991 assert_eq!(merged.len(), 2);
992 assert_eq!(merged[0].id, "claude-opus-4-7");
993 assert_eq!(merged[1].id, "claude-sonnet-4-6");
994 }
995
996 mod openrouter {
999 use super::super::*;
1000
1001 const RICH_FIXTURE: &str = r#"{
1002 "data": [
1003 {
1004 "id": "qwen/qwen3-coder",
1005 "name": "Qwen: Qwen3 Coder",
1006 "context_length": 131072,
1007 "top_provider": { "max_completion_tokens": 32768 },
1008 "supported_parameters": ["temperature", "top_p", "max_tokens"],
1009 "pricing": { "prompt": "0.0000001", "completion": "0.0000004", "internal_reasoning": "0" },
1010 "architecture": { "input_modalities": ["text"] }
1011 },
1012 {
1013 "id": "anthropic/claude-opus-4-7",
1014 "name": "Anthropic: Claude Opus 4.7",
1015 "context_length": 200000,
1016 "supported_parameters": ["temperature", "verbosity", "max_tokens"],
1017 "pricing": { "prompt": "0.000015", "completion": "0.000075" }
1018 },
1019 {
1020 "id": "openai/o4-mini",
1021 "name": "OpenAI: o4-mini",
1022 "context_length": 128000,
1023 "supported_parameters": ["reasoning_effort", "max_tokens"],
1024 "pricing": { "prompt": "0.0000011", "completion": "0.0000044" }
1025 },
1026 {
1027 "id": "google/gemini-2.5-flash",
1028 "name": "Google: Gemini 2.5 Flash",
1029 "context_length": 1048576,
1030 "supported_parameters": ["reasoning", "include_reasoning", "max_tokens"],
1031 "pricing": { "prompt": "0.00000015", "completion": "0.0000035", "internal_reasoning": "0.0000035" },
1032 "architecture": { "input_modalities": ["text", "image", "audio", "video"] }
1033 },
1034 {
1035 "id": "",
1036 "name": "Empty — must be filtered"
1037 }
1038 ]
1039 }"#;
1040
1041 #[test]
1042 fn parses_minimal_model() {
1043 let json = r#"{"data":[{"id":"test/model","name":"Test Model"}]}"#;
1044 let models = parse_openrouter_catalog_models(json).expect("parse ok");
1045 assert_eq!(models.len(), 1);
1046 assert_eq!(models[0].id, "test/model");
1047 assert_eq!(models[0].label.as_deref(), Some("Test Model"));
1048 assert_eq!(models[0].runtime_id(), "openrouter/test/model");
1049 assert_eq!(models[0].provider_key, "openrouter");
1050 assert_eq!(models[0].source, CatalogSource::Live);
1051 }
1052
1053 #[test]
1054 fn parses_context_length_and_max_output() {
1055 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1056 let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
1057 assert_eq!(qwen.context_tokens, Some(131_072));
1058 assert_eq!(qwen.max_output_tokens, Some(32_768));
1059 }
1060
1061 #[test]
1062 fn filters_empty_ids() {
1063 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1064 assert!(!models.iter().any(|m| m.id.is_empty()));
1065 assert_eq!(models.len(), 4);
1066 }
1067
1068 #[test]
1069 fn parses_pricing_fields() {
1070 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1071 let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
1072 assert_eq!(qwen.pricing.prompt.as_deref(), Some("0.0000001"));
1073 assert_eq!(qwen.pricing.completion.as_deref(), Some("0.0000004"));
1074 assert!(!qwen.pricing.has_internal_reasoning_cost());
1075 }
1076
1077 #[test]
1078 fn parses_internal_reasoning_cost_flag() {
1079 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1080 let gemini = models
1081 .iter()
1082 .find(|m| m.id == "google/gemini-2.5-flash")
1083 .unwrap();
1084 assert!(gemini.pricing.has_internal_reasoning_cost());
1085 }
1086
1087 #[test]
1088 fn no_reasoning_params_maps_to_none() {
1089 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1090 let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
1091 assert_eq!(qwen.reasoning, ReasoningSupport::None);
1092 }
1093
1094 #[test]
1095 fn verbosity_param_maps_to_anthropic_adaptive() {
1096 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1097 let claude = models
1098 .iter()
1099 .find(|m| m.id == "anthropic/claude-opus-4-7")
1100 .unwrap();
1101 assert_eq!(
1102 claude.reasoning,
1103 ReasoningSupport::AnthropicAdaptive { adaptive: true }
1104 );
1105 }
1106
1107 #[test]
1108 fn reasoning_effort_maps_to_openrouter_reasoning() {
1109 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1110 let o4 = models.iter().find(|m| m.id == "openai/o4-mini").unwrap();
1111 assert_eq!(
1112 o4.reasoning,
1113 ReasoningSupport::OpenRouter {
1114 include_reasoning: false,
1115 effort: true,
1116 verbosity: false,
1117 internal_reasoning_priced: false,
1118 }
1119 );
1120 }
1121
1122 #[test]
1123 fn reasoning_include_reasoning_maps_correctly() {
1124 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1125 let gemini = models
1126 .iter()
1127 .find(|m| m.id == "google/gemini-2.5-flash")
1128 .unwrap();
1129 assert_eq!(
1130 gemini.reasoning,
1131 ReasoningSupport::OpenRouter {
1132 include_reasoning: true,
1133 effort: false,
1134 verbosity: false,
1135 internal_reasoning_priced: true,
1136 }
1137 );
1138 }
1139
1140 #[test]
1141 fn parses_multimodal_input() {
1142 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1143 let gemini = models
1144 .iter()
1145 .find(|m| m.id == "google/gemini-2.5-flash")
1146 .unwrap();
1147 assert!(gemini.input_modalities.contains(&Modality::Text));
1148 assert!(gemini.input_modalities.contains(&Modality::Image));
1149 assert!(gemini.input_modalities.contains(&Modality::Audio));
1150 assert!(gemini.input_modalities.contains(&Modality::Video));
1151 }
1152
1153 #[test]
1154 fn missing_modalities_defaults_to_text() {
1155 let json = r#"{"data":[{"id":"test/model"}]}"#;
1156 let models = parse_openrouter_catalog_models(json).expect("parse ok");
1157 assert_eq!(models[0].input_modalities, vec![Modality::Text]);
1158 }
1159
1160 #[test]
1161 fn parses_openrouter_rich_metadata_and_reasoning_flags() {
1162 let json = r#"{
1164 "data": [{
1165 "id": "anthropic/claude-sonnet-4.6",
1166 "name": "Anthropic: Claude Sonnet 4.6",
1167 "context_length": 1000000,
1168 "architecture": { "input_modalities": ["text", "image"] },
1169 "pricing": { "prompt": "0.000003", "completion": "0.000015", "internal_reasoning": "0.000012" },
1170 "top_provider": { "max_completion_tokens": 128000 },
1171 "supported_parameters": ["reasoning", "include_reasoning", "verbosity", "tools"]
1172 }]
1173 }"#;
1174 let models = parse_openrouter_catalog_models(json).expect("parse ok");
1175 assert_eq!(models.len(), 1);
1176 let m = &models[0];
1177 assert_eq!(m.runtime_id(), "openrouter/anthropic/claude-sonnet-4.6");
1178 assert_eq!(m.context_tokens, Some(1_000_000));
1179 assert_eq!(m.max_output_tokens, Some(128_000));
1180 assert!(m.input_modalities.contains(&Modality::Image));
1181 assert_eq!(
1183 m.reasoning,
1184 ReasoningSupport::AnthropicAdaptive { adaptive: true }
1185 );
1186 }
1187
1188 #[test]
1189 fn parses_openrouter_non_reasoning_model_as_none() {
1190 let json = r#"{
1191 "data": [{
1192 "id": "meta-llama/llama-3.3-70b-instruct",
1193 "name": "Meta: Llama 3.3 70B",
1194 "supported_parameters": ["temperature", "tools"]
1195 }]
1196 }"#;
1197 let models = parse_openrouter_catalog_models(json).expect("parse ok");
1198 assert_eq!(models[0].reasoning, ReasoningSupport::None);
1199 }
1200
1201 #[test]
1202 fn invalid_json_returns_error() {
1203 assert!(parse_openrouter_catalog_models("{not json}").is_err());
1204 }
1205
1206 #[test]
1207 fn missing_data_key_returns_error() {
1208 assert!(parse_openrouter_catalog_models(r#"{"models":[]}"#).is_err());
1209 }
1210 }
1211
1212 mod anthropic {
1217 use super::super::*;
1218
1219 #[test]
1220 fn parser_reads_optional_capabilities_and_token_limits() {
1221 let json = r#"{
1222 "data": [{
1223 "id": "claude-opus-4-7",
1224 "display_name": "Claude Opus 4.7",
1225 "max_input_tokens": 200000,
1226 "max_tokens": 32000,
1227 "capabilities": {
1228 "thinking": { "supported": true },
1229 "effort": { "supported": true }
1230 }
1231 }],
1232 "has_more": false
1233 }"#;
1234 let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
1235 assert_eq!(models.len(), 1);
1236 let model = &models[0];
1237 assert_eq!(model.runtime_id(), "anthropic/claude-opus-4-7");
1238 assert_eq!(model.label.as_deref(), Some("Claude Opus 4.7"));
1239 assert_eq!(model.context_tokens, Some(200_000));
1240 assert_eq!(model.max_output_tokens, Some(32_000));
1241 assert_eq!(
1242 model.reasoning,
1243 ReasoningSupport::AnthropicAdaptive { adaptive: true }
1244 );
1245 }
1246
1247 #[test]
1248 fn parser_tolerates_missing_capabilities_as_unknown() {
1249 let json =
1250 r#"{"data":[{"id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku"}]}"#;
1251 let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
1252 assert_eq!(models[0].reasoning, ReasoningSupport::Unknown);
1253 }
1254
1255 #[test]
1256 fn parser_filters_empty_ids() {
1257 let json = r#"{"data":[{"id":""},{"id":"claude-sonnet-4-6"}]}"#;
1258 let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
1259 assert_eq!(models.len(), 1);
1260 assert_eq!(models[0].id, "claude-sonnet-4-6");
1261 }
1262 }
1263
1264 mod codex {
1265 use super::super::*;
1266
1267 #[test]
1268 fn static_catalog_uses_fallback_source_and_prefixed_runtime_ids() {
1269 let models = codex_static_catalog_models();
1270 assert!(models.iter().any(|m| m.id == "gpt-5.5"));
1271 assert!(models.iter().any(|m| m.id == "gpt-5.4"));
1272 assert!(models.iter().any(|m| m.id == "gpt-5.4-mini"));
1273 assert!(!models.iter().any(|m| m.id == "gpt-5.5-pro"));
1274 assert!(!models.iter().any(|m| m.id == "gpt-5.4-nano"));
1275 assert!(!models.iter().any(|m| m.id == "gpt-5.1-codex-mini"));
1276 assert!(models
1277 .iter()
1278 .all(|m| m.source == CatalogSource::StaticFallback));
1279 assert!(models
1280 .iter()
1281 .all(|m| m.runtime_id().starts_with("openai-codex/")));
1282 }
1283
1284 #[test]
1285 fn live_parser_filters_non_list_visibility_from_fixture() {
1286 let fixture = include_str!("fixtures/openai_codex_models.json");
1287 let models = parse_codex_catalog_models(fixture).expect("parse");
1288 let ids: std::collections::HashSet<_> = models.iter().map(|m| m.id.as_str()).collect();
1289 assert!(ids.contains("gpt-5.6-sol"));
1290 assert!(ids.contains("gpt-5.3-codex-spark"));
1291 assert!(!ids.contains("codex-auto-review"));
1292 assert!(!ids.contains("codex-internal-eval"));
1293 assert!(models.iter().all(|m| m.source == CatalogSource::Live));
1294 }
1295
1296 #[test]
1297 fn models_path_carries_package_client_version() {
1298 let path = codex_models_path(env!("CARGO_PKG_VERSION"));
1299 assert!(path.starts_with("/models?client_version="));
1300 assert!(path.contains(env!("CARGO_PKG_VERSION")));
1301 }
1302
1303 #[test]
1304 fn missing_credential_fallback_classifies_real_broker_not_configured_text() {
1305 let not_configured = format!(
1306 "request failed: {}",
1307 crate::auth::BrokerError::NotConfigured("openai-codex".into())
1308 );
1309 assert!(
1310 is_missing_credential_catalog_error(¬_configured),
1311 "got: {not_configured}"
1312 );
1313 let unknown = format!(
1314 "request failed: {}",
1315 crate::auth::BrokerError::UnknownProvider("openai-codex".into())
1316 );
1317 assert!(
1318 is_missing_credential_catalog_error(&unknown),
1319 "got: {unknown}"
1320 );
1321 }
1322
1323 #[test]
1324 fn missing_credential_fallback_classifies_exact_broker_credential_no_credentials_text() {
1325 let raw = crate::auth::BrokerError::Credential(format!(
1330 "No credentials for openai-codex at {}. Run `synaps login`.",
1331 std::path::Path::new("/tmp/auth.json").display()
1332 ));
1333 let wrapped = format!("request failed: {raw}");
1334 assert!(
1335 is_missing_credential_catalog_error(&wrapped),
1336 "exact BrokerError::Credential missing-login text must fall back to static seeds; got: {wrapped}"
1337 );
1338 assert!(is_missing_credential_catalog_error(
1340 "request failed: credential error: No credentials for openai-codex at /home/user/.synaps/auth.json. Run `synaps login`."
1341 ));
1342 }
1343
1344 #[test]
1348 fn typed_classifier_maps_auth_misses_to_fallback_and_operations_to_failed() {
1349 use crate::auth::BrokerError as B;
1350 let auth_misses = [
1351 B::NotConfigured("github-copilot".into()),
1352 B::UnknownProvider("github-copilot".into()),
1353 B::RegistrationRequired {
1354 provider: "github-copilot".into(),
1355 remediation: "run synaps login".into(),
1356 },
1357 B::Credential(
1361 "No credentials for github-copilot at /tmp/x/auth.json. Run `synaps login`."
1362 .into(),
1363 ),
1364 ];
1365 for err in auth_misses {
1366 assert!(
1367 matches!(
1368 classify_catalog_broker_error(err.clone()),
1369 CatalogBrokerError::NotAuthenticated(_)
1370 ),
1371 "must classify as no-live-session: {err}"
1372 );
1373 }
1374
1375 let operational = [
1376 B::Transport("connection reset".into()),
1377 B::Denied("proxy path not allowlisted".into()),
1378 B::Unauthorized,
1379 B::Credential("github-copilot credential is missing chatgpt account id".into()),
1380 B::UnsupportedCapability {
1381 provider: "github-copilot".into(),
1382 capability: "tools".into(),
1383 },
1384 ];
1385 for err in operational {
1386 assert!(
1387 matches!(
1388 classify_catalog_broker_error(err.clone()),
1389 CatalogBrokerError::Failed(_)
1390 ),
1391 "must classify as operational failure: {err}"
1392 );
1393 }
1394 }
1395
1396 #[test]
1397 fn missing_credential_fallback_does_not_hide_transport_http_parse_or_account_errors() {
1398 for err in [
1399 "request failed: broker transport error: connection reset",
1400 "model list failed: HTTP 401",
1401 "model list failed: HTTP 500",
1402 "parse failed: missing field `models`",
1403 "request failed: broker denied request: proxy path '/models' is not in the provider's endpoint allowlist",
1404 "request failed: credential error: openai-codex credential is missing chatgpt account id",
1405 "request failed: broker transport error: provider request failed: 403 Forbidden",
1406 ] {
1407 assert!(
1408 !is_missing_credential_catalog_error(err),
1409 "must not fallback for: {err}"
1410 );
1411 }
1412 }
1413 }
1414
1415 mod anthropic_paths {
1416 use super::super::*;
1417
1418 #[test]
1419 fn proxy_path_round_trips_limit_and_after_id() {
1420 assert_eq!(anthropic_models_proxy_path(None), "/v1/models?limit=100");
1421 assert_eq!(
1422 anthropic_models_proxy_path(Some("claude-opus-4-7")),
1423 "/v1/models?limit=100&after_id=claude-opus-4-7"
1424 );
1425 assert_eq!(
1426 anthropic_models_proxy_path(Some("")),
1427 "/v1/models?limit=100"
1428 );
1429 }
1430 }
1431
1432 mod groq {
1435 use super::super::*;
1436
1437 #[test]
1438 fn parser_extracts_context_window_and_filters_inactive() {
1439 let json = r#"{"data":[
1440 {"id":"llama-3.3-70b-versatile","active":true,"context_window":131072,"owned_by":"Meta"},
1441 {"id":"old-model-v1","active":false,"context_window":8192,"owned_by":"Meta"}
1442 ]}"#;
1443 let models = parse_groq_catalog_models(json).expect("parse groq");
1444 assert_eq!(models.len(), 1);
1445 assert_eq!(models[0].id, "llama-3.3-70b-versatile");
1446 assert_eq!(models[0].context_tokens, Some(131_072));
1447 assert_eq!(models[0].reasoning, ReasoningSupport::None);
1448 }
1449
1450 #[test]
1451 fn inference_maps_reasoning_families() {
1452 assert_eq!(
1453 infer_groq_reasoning("openai/gpt-oss-120b"),
1454 ReasoningSupport::GroqReasoning
1455 );
1456 assert_eq!(
1457 infer_groq_reasoning("qwen/qwen3-32b"),
1458 ReasoningSupport::GroqReasoning
1459 );
1460 assert_eq!(
1461 infer_groq_reasoning("groq/compound-mini"),
1462 ReasoningSupport::GroqReasoning
1463 );
1464 assert_eq!(
1465 infer_groq_reasoning("llama-3.3-70b-versatile"),
1466 ReasoningSupport::None
1467 );
1468 }
1469
1470 #[test]
1471 fn parser_filters_empty_ids() {
1472 let json = r#"{"data":[{"id":"","active":true},{"id":"openai/gpt-oss-20b","active":true,"context_window":131072}]}"#;
1473 let models = parse_groq_catalog_models(json).expect("parse groq");
1474 assert_eq!(models.len(), 1);
1475 assert_eq!(models[0].id, "openai/gpt-oss-20b");
1476 }
1477 }
1478
1479 mod nvidia {
1480 use super::super::*;
1481
1482 #[test]
1483 fn parser_dedupes_and_enriches_known_context() {
1484 let json = r#"{"data":[
1485 {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
1486 {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
1487 {"id":"moonshotai/kimi-k2-thinking","owned_by":"moonshotai"}
1488 ]}"#;
1489 let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
1490 assert_eq!(models.len(), 2);
1491 let ultra = models.iter().find(|m| m.id.contains("ultra")).unwrap();
1492 assert_eq!(ultra.context_tokens, Some(128_000));
1493 assert_eq!(ultra.reasoning, ReasoningSupport::NvidiaInlineThinking);
1494 let kimi = models.iter().find(|m| m.id.contains("kimi")).unwrap();
1495 assert_eq!(kimi.context_tokens, Some(256_000));
1496 }
1497
1498 #[test]
1499 fn inference_detects_thinking_and_standard_models() {
1500 assert_eq!(
1501 infer_nvidia_reasoning("qwen/qwen3-next-80b-a3b-thinking"),
1502 ReasoningSupport::NvidiaInlineThinking
1503 );
1504 assert_eq!(
1505 infer_nvidia_reasoning("nvidia/cosmos-reason2-8b"),
1506 ReasoningSupport::NvidiaInlineThinking
1507 );
1508 assert_eq!(
1509 infer_nvidia_reasoning("meta/llama-3.3-70b-instruct"),
1510 ReasoningSupport::None
1511 );
1512 }
1513
1514 #[test]
1515 fn parser_filters_empty_ids() {
1516 let json = r#"{"data":[{"id":""},{"id":"meta/llama-3.3-70b-instruct"}]}"#;
1517 let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
1518 assert_eq!(models.len(), 1);
1519 assert_eq!(models[0].id, "meta/llama-3.3-70b-instruct");
1520 }
1521 }
1522
1523 mod generic_compat {
1524 use super::super::*;
1525
1526 #[test]
1527 fn parses_generic_catalog_models_and_filters_empty_ids() {
1528 let json = r#"{
1529 "data": [
1530 { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
1531 { "id": "" },
1532 { "id": "openai/gpt-oss-120b" }
1533 ]
1534 }"#;
1535 let models =
1536 parse_generic_catalog_models(json, "openrouter", "OpenRouter").expect("parse ok");
1537 assert_eq!(models.len(), 2);
1538 assert_eq!(models[0].runtime_id(), "openrouter/qwen/qwen3-coder");
1539 assert_eq!(models[0].display_label(), "Qwen: Qwen3 Coder");
1540 assert_eq!(models[1].display_label(), "openai/gpt-oss-120b");
1541 }
1542
1543 #[test]
1544 fn whitespace_only_id_is_filtered() {
1545 let json = r#"{"data":[{"id":" "},{"id":"valid"}]}"#;
1546 let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
1547 assert_eq!(models.len(), 1);
1548 assert_eq!(models[0].id, "valid");
1549 }
1550
1551 #[test]
1552 fn whitespace_label_stored_as_none() {
1553 let json = r#"{"data":[{"id":"m1","name":" "}]}"#;
1554 let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
1555 assert_eq!(models[0].label, None);
1556 }
1557
1558 #[test]
1559 fn generic_catalog_source_is_live() {
1560 let json = r#"{"data":[{"id":"m1","name":"Model One"}]}"#;
1561 let models =
1562 parse_generic_catalog_models(json, "testprovider", "Test").expect("parse ok");
1563 assert_eq!(models[0].source, CatalogSource::Live);
1564 }
1565
1566 #[test]
1567 fn generic_reasoning_is_generic_open_ai() {
1568 let json = r#"{"data":[{"id":"m1"}]}"#;
1569 let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
1570 assert_eq!(models[0].reasoning, ReasoningSupport::GenericOpenAi);
1571 }
1572
1573 #[test]
1574 fn generic_parse_matches_legacy_parse_behavior() {
1575 let json = r#"{
1578 "data": [
1579 { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
1580 { "id": "openai/gpt-oss-120b" }
1581 ]
1582 }"#;
1583 let legacy = super::super::super::registry::parse_provider_models_response(json)
1584 .expect("legacy parse ok");
1585 let catalog = parse_generic_catalog_models(json, "openrouter", "OpenRouter")
1586 .expect("catalog parse ok");
1587 assert_eq!(legacy.len(), catalog.len());
1588 for (l, c) in legacy.iter().zip(catalog.iter()) {
1589 assert_eq!(l.id, c.id, "ids must match");
1590 assert_eq!(l.name, c.label, "labels must match");
1591 }
1592 }
1593
1594 #[test]
1595 fn static_seeds_from_spec_all_providers() {
1596 for spec in super::super::super::registry::providers() {
1598 if spec.models.is_empty() {
1599 continue;
1600 }
1601 let seeds = super::super::static_seeds_from_spec(spec);
1602 assert!(!seeds.is_empty(), "no seeds for {}", spec.key);
1603 assert!(
1604 seeds
1605 .iter()
1606 .all(|m| m.runtime_id().starts_with(&format!("{}/", spec.key))),
1607 "runtime_id prefix wrong for {}",
1608 spec.key
1609 );
1610 }
1611 }
1612 }
1613}