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