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