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;
40pub mod muse;
41mod nvidia;
42mod openrouter;
43pub mod validation;
44mod xai;
45
46pub use anthropic::{
47 anthropic_mode_capabilities, anthropic_models_url, anthropic_static_capability,
48 merge_catalog_pages, parse_anthropic_catalog_models, parse_anthropic_catalog_page,
49 plan_anthropic_execution, plan_standard_anthropic_transport, AnthropicCatalogPage,
50 AnthropicExecutionMode, AnthropicExecutionPlan, AnthropicPlanError, AnthropicPlanErrorCode,
51 AnthropicPlanPrerequisites, AnthropicWireEffort, AnthropicWorkflowPlan,
52};
53pub use codex::{
54 codex_models_path, codex_models_url, codex_static_capability, codex_static_catalog_models,
55 parse_codex_catalog_models, plan_codex_execution, validate_codex_level, CodexCapabilitySource,
56 CodexExecutionMode, CodexExecutionPlan, CodexMultiAgentMode, CodexPlanError,
57 CodexPlanErrorCode, CodexRequestRole, CodexWireEffort, ExecutionRole, CODEX_CLIENT_VERSION,
58 PROVIDER_KEY as CODEX_PROVIDER_KEY, PROVIDER_NAME as CODEX_PROVIDER_NAME,
59};
60pub use generic::parse_generic_catalog_models;
61pub use github_copilot::{
62 copilot_model, copilot_static_catalog_models, models_request_headers,
63 parse_copilot_catalog_entries, parse_copilot_catalog_models,
64 preferred_wire_protocol_from_endpoints, runtime_wire_protocol as github_copilot_runtime_model,
65 selectable_copilot_entries, validate_models_endpoint, CopilotCatalogEntry, CopilotEndpoint,
66 CopilotModelDescriptor, CopilotPolicyState, CopilotWire, COPILOT_API_VERSION,
67 COPILOT_FALLBACK_MODELS, MAX_MODELS_BODY_BYTES, MODELS_BASE_URL, MODELS_PATH, MODELS_URL,
68 PROVIDER_KEY as COPILOT_PROVIDER_KEY, PROVIDER_NAME as COPILOT_PROVIDER_NAME,
69};
70pub use google_gemini::{
71 google_gemini_model, google_gemini_static_catalog_models, GoogleGeminiModelDescriptor,
72 GOOGLE_GEMINI_TEXT_MODELS, PROVIDER_KEY as GOOGLE_GEMINI_PROVIDER_KEY,
73 PROVIDER_NAME as GOOGLE_GEMINI_PROVIDER_NAME,
74};
75pub use groq::{infer_groq_reasoning, parse_groq_catalog_models};
76pub use kimi::{
77 apply_kimi_reasoning_params, kimi_code_capability, kimi_code_model,
78 kimi_code_static_catalog_models, kimi_static_capability, KimiCodeModelDescriptor,
79 KimiReasoningCapability, KIMI_CODE_TEXT_MODELS,
80};
81pub use muse::{
82 muse_default_level, muse_effort_label, muse_routable_id, muse_runtime_model,
83 muse_static_catalog_models, muse_static_model, muse_supported_levels,
84 parse_muse_catalog_models, MuseModelDescriptor, MUSE_MODELS_PATH, MUSE_PROVIDER_KEY,
85 MUSE_PROVIDER_NAME, MUSE_TEXT_MODELS,
86};
87pub use nvidia::{infer_nvidia_reasoning, parse_nvidia_catalog_models};
88pub use openrouter::parse_openrouter_catalog_models;
89pub use xai::{
90 xai_model, xai_static_capability, xai_static_catalog_models, XaiModelDescriptor,
91 XaiReasoningCapability, XAI_TEXT_MODELS,
92};
93
94#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum Modality {
99 Text,
100 Image,
101 Audio,
102 Video,
103 File,
104 Other(String),
105}
106
107impl Modality {
108 #[allow(clippy::should_implement_trait)]
109 pub fn from_str(s: &str) -> Self {
110 match s {
111 "text" => Modality::Text,
112 "image" => Modality::Image,
113 "audio" => Modality::Audio,
114 "video" => Modality::Video,
115 "file" => Modality::File,
116 other => Modality::Other(other.to_string()),
117 }
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Default)]
125pub struct PricingSummary {
126 pub prompt: Option<String>,
128 pub completion: Option<String>,
130 pub internal_reasoning: Option<String>,
132}
133
134impl PricingSummary {
135 pub fn has_internal_reasoning_cost(&self) -> bool {
137 self.internal_reasoning
138 .as_deref()
139 .map(|s| s != "0" && !s.trim().is_empty())
140 .unwrap_or(false)
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum CodexMultiAgentVersion {
152 V1,
153 V2,
154 Unknown,
155}
156
157impl CodexMultiAgentVersion {
158 pub fn as_str(self) -> &'static str {
159 match self {
160 Self::V1 => "v1",
161 Self::V2 => "v2",
162 Self::Unknown => "unknown",
163 }
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum ReasoningSupport {
170 None,
172 AnthropicAdaptive { adaptive: bool },
174 OpenRouter {
176 include_reasoning: bool,
177 effort: bool,
178 verbosity: bool,
179 internal_reasoning_priced: bool,
180 },
181 GroqReasoning,
183 NvidiaInlineThinking,
185 GenericOpenAi,
187 CodexNamed {
189 supported: Vec<agent_core::reasoning::ReasoningLevel>,
192 default_level: Option<agent_core::reasoning::ReasoningLevel>,
194 multi_agent_reasoning_effort: Option<agent_core::reasoning::ReasoningLevel>,
196 multi_agent_version: Option<CodexMultiAgentVersion>,
198 },
199 NamedEffort {
203 supported: Vec<agent_core::reasoning::ReasoningLevel>,
204 default_level: Option<agent_core::reasoning::ReasoningLevel>,
207 },
208 Unknown,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum CatalogSource {
216 Live,
218 StaticFallback,
220 StaticWithLive,
222 Inferred,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
229pub enum CatalogProviderKind {
230 Anthropic,
231 OpenRouter,
232 Groq,
233 NvidiaNim,
234 OpenAiCodex,
235 Generic { key: String },
236 Local,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct CatalogModel {
244 pub provider_key: String,
246 pub provider_name: String,
248 pub provider_kind: CatalogProviderKind,
250 pub id: String,
252 pub label: Option<String>,
254 pub context_tokens: Option<u64>,
256 pub max_output_tokens: Option<u64>,
258 pub input_modalities: Vec<Modality>,
260 pub pricing: PricingSummary,
262 pub reasoning: ReasoningSupport,
264 pub source: CatalogSource,
266}
267
268impl CatalogModel {
269 pub fn new(
271 provider_key: impl Into<String>,
272 provider_name: impl Into<String>,
273 id: impl Into<String>,
274 ) -> Option<Self> {
275 let id = id.into();
276 if id.trim().is_empty() {
277 return None;
278 }
279 let pk = provider_key.into();
280 Some(Self {
281 provider_kind: CatalogProviderKind::Generic { key: pk.clone() },
282 provider_name: provider_name.into(),
283 provider_key: pk,
284 id,
285 label: None,
286 context_tokens: None,
287 max_output_tokens: None,
288 input_modalities: vec![Modality::Text],
289 pricing: PricingSummary::default(),
290 reasoning: ReasoningSupport::Unknown,
291 source: CatalogSource::Live,
292 })
293 }
294
295 pub fn runtime_id(&self) -> String {
297 format!("{}/{}", self.provider_key, self.id)
298 }
299
300 pub fn display_label(&self) -> &str {
302 self.label.as_deref().unwrap_or(&self.id)
303 }
304
305 pub fn codex_supported_levels(&self) -> Option<&[agent_core::reasoning::ReasoningLevel]> {
308 match &self.reasoning {
309 ReasoningSupport::CodexNamed { supported, .. } => Some(supported),
310 _ => None,
311 }
312 }
313
314 pub fn codex_multi_agent_version(&self) -> Option<CodexMultiAgentVersion> {
317 match &self.reasoning {
318 ReasoningSupport::CodexNamed {
319 multi_agent_version,
320 ..
321 } => *multi_agent_version,
322 _ => None,
323 }
324 }
325
326 pub fn codex_supports_level(&self, level: agent_core::reasoning::ReasoningLevel) -> bool {
329 self.codex_supported_levels()
330 .is_some_and(|levels| levels.contains(&level))
331 }
332}
333
334pub fn from_static_seed(
338 provider_key: &str,
339 provider_name: &str,
340 id: &str,
341 label: &str,
342) -> Option<CatalogModel> {
343 let mut m = CatalogModel::new(provider_key, provider_name, id)?;
344 m.label = if label.trim().is_empty() {
345 None
346 } else {
347 Some(label.to_string())
348 };
349 m.source = CatalogSource::StaticFallback;
350 m.reasoning = ReasoningSupport::Unknown;
351 Some(m)
352}
353
354pub fn static_seeds_from_spec(spec: &super::registry::ProviderSpec) -> Vec<CatalogModel> {
356 spec.models
357 .iter()
358 .filter_map(|(id, label, _tier)| from_static_seed(spec.key, spec.name, id, label))
359 .collect()
360}
361
362pub trait ModelCatalogProvider: Sync {
365 fn provider_key(&self) -> &'static str;
366
367 fn fetch<'a>(
368 &'a self,
369 client: &'a reqwest::Client,
370 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>>;
371}
372
373pub struct OpenRouterCatalogProvider;
374pub struct GroqCatalogProvider;
375pub struct NvidiaCatalogProvider;
376pub struct AnthropicCatalogProvider;
377pub struct CodexCatalogProvider;
378pub struct XaiCatalogProvider;
379pub struct GitHubCopilotCatalogProvider;
380pub struct GoogleGeminiCatalogProvider;
381pub struct MuseCatalogProvider;
382pub struct GenericCatalogProvider;
383
384pub fn catalog_provider_for(provider_key: &str) -> &'static dyn ModelCatalogProvider {
385 match provider_key {
386 "openrouter" => &OpenRouterCatalogProvider,
387 "groq" => &GroqCatalogProvider,
388 "nvidia" => &NvidiaCatalogProvider,
389 "claude" | "anthropic" => &AnthropicCatalogProvider,
390 "openai-codex" => &CodexCatalogProvider,
391 "xai-auth" => &XaiCatalogProvider,
392 "github-copilot" => &GitHubCopilotCatalogProvider,
393 "google-gemini" => &GoogleGeminiCatalogProvider,
394 "muse" => &MuseCatalogProvider,
395 _ => &GenericCatalogProvider,
396 }
397}
398
399fn catalog_get(client: &reqwest::Client, url: &str) -> reqwest::RequestBuilder {
400 client.get(url).timeout(CATALOG_REQUEST_TIMEOUT)
401}
402
403async fn read_catalog_response(resp: reqwest::Response) -> Result<String, String> {
404 let status = resp.status();
405 let body = resp.text().await.map_err(|e| format!("read failed: {e}"))?;
406 if !status.is_success() {
407 return Err(format!("model list failed: HTTP {status}"));
408 }
409 Ok(body)
410}
411
412async fn fetch_anthropic_catalog_models(
413 _client: &reqwest::Client,
414) -> Result<Vec<CatalogModel>, String> {
415 let mut pages = Vec::new();
419 let mut after_id: Option<String> = None;
420
421 for _ in 0..ANTHROPIC_MODELS_MAX_PAGES {
422 let path = anthropic_models_proxy_path(after_id.as_deref());
423 let body = broker_proxy_catalog_body("anthropic", &path).await?;
424 let page = parse_anthropic_catalog_page(&body).map_err(|e| format!("parse failed: {e}"))?;
425 let next_after_id = page.last_id.clone();
426 let has_more = page.has_more && next_after_id.is_some();
427 pages.push(page.models);
428 if !has_more {
429 return Ok(merge_catalog_pages(pages));
430 }
431 after_id = next_after_id;
432 }
433
434 Ok(merge_catalog_pages(pages))
435}
436
437pub fn anthropic_models_proxy_path(after_id: Option<&str>) -> String {
439 let absolute = anthropic_models_url(after_id);
441 absolute
442 .strip_prefix("https://api.anthropic.com")
443 .unwrap_or(&absolute)
444 .to_string()
445}
446
447impl ModelCatalogProvider for OpenRouterCatalogProvider {
448 fn provider_key(&self) -> &'static str {
449 "openrouter"
450 }
451
452 fn fetch<'a>(
453 &'a self,
454 client: &'a reqwest::Client,
455 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
456 Box::pin(async move { fetch_openrouter_catalog_models(client).await })
457 }
458}
459
460impl ModelCatalogProvider for GroqCatalogProvider {
461 fn provider_key(&self) -> &'static str {
462 "groq"
463 }
464
465 fn fetch<'a>(
466 &'a self,
467 _client: &'a reqwest::Client,
468 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
469 Box::pin(async move {
470 let body = broker_catalog_models_body("groq").await?;
471 parse_groq_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
472 })
473 }
474}
475
476impl ModelCatalogProvider for NvidiaCatalogProvider {
477 fn provider_key(&self) -> &'static str {
478 "nvidia"
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 {
486 let resp = catalog_get(client, "https://integrate.api.nvidia.com/v1/models")
487 .send()
488 .await
489 .map_err(|e| format!("request failed: {e}"))?;
490 let body = read_catalog_response(resp).await?;
491 parse_nvidia_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
492 })
493 }
494}
495
496impl ModelCatalogProvider for AnthropicCatalogProvider {
497 fn provider_key(&self) -> &'static str {
498 "claude"
499 }
500
501 fn fetch<'a>(
502 &'a self,
503 client: &'a reqwest::Client,
504 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
505 Box::pin(async move { fetch_anthropic_catalog_models(client).await })
506 }
507}
508
509pub fn is_missing_credential_catalog_error(err: &str) -> bool {
515 let lower = err.to_lowercase();
520
521 if lower.contains("model list failed")
523 || lower.contains("parse failed")
524 || lower.contains("broker denied")
525 || lower.contains("broker transport error")
526 || lower.contains("missing chatgpt account id")
527 || lower.contains("http 4")
528 || lower.contains("http 5")
529 || lower.contains("connection reset")
530 || lower.contains("timed out")
531 || lower.contains("timeout")
532 {
533 return false;
534 }
535
536 lower.contains("credential error: no credentials for ")
541 || lower.contains("no credential configured")
542 || lower.contains("unknown provider:")
543 || lower.contains("not logged")
544 || lower.contains("registration required")
545}
546
547impl ModelCatalogProvider for CodexCatalogProvider {
548 fn provider_key(&self) -> &'static str {
549 "openai-codex"
550 }
551
552 fn fetch<'a>(
553 &'a self,
554 _client: &'a reqwest::Client,
555 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
556 Box::pin(async move {
560 let path = codex_models_path(codex::CODEX_CLIENT_VERSION);
564 match broker_proxy_catalog_body("openai-codex", &path).await {
565 Ok(body) => {
566 let models = parse_codex_catalog_models(&body)
567 .map_err(|e| format!("parse failed: {e}"))?;
568 capability_cache::replace_provider(self.provider_key(), &models);
569 Ok(models)
570 }
571 Err(CatalogBrokerError::NotAuthenticated(_)) => Ok(codex_static_catalog_models()),
574 Err(CatalogBrokerError::Failed(err)) => Err(err),
575 }
576 })
577 }
578}
579
580impl ModelCatalogProvider for XaiCatalogProvider {
581 fn provider_key(&self) -> &'static str {
582 "xai-auth"
583 }
584
585 fn fetch<'a>(
586 &'a self,
587 _client: &'a reqwest::Client,
588 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
589 Box::pin(async move { Ok(xai_static_catalog_models()) })
590 }
591}
592
593impl ModelCatalogProvider for GitHubCopilotCatalogProvider {
594 fn provider_key(&self) -> &'static str {
595 "github-copilot"
596 }
597
598 fn fetch<'a>(
599 &'a self,
600 _client: &'a reqwest::Client,
601 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
602 Box::pin(async move {
607 match broker_catalog_models_body("github-copilot").await {
608 Ok(body) => {
609 parse_copilot_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
610 }
611 Err(CatalogBrokerError::NotAuthenticated(_)) => Ok(copilot_static_catalog_models()),
612 Err(CatalogBrokerError::Failed(err)) => Err(err),
613 }
614 })
615 }
616}
617
618impl ModelCatalogProvider for GoogleGeminiCatalogProvider {
619 fn provider_key(&self) -> &'static str {
620 "google-gemini"
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 { Ok(google_gemini_static_catalog_models()) })
633 }
634}
635
636impl ModelCatalogProvider for MuseCatalogProvider {
637 fn provider_key(&self) -> &'static str {
638 MUSE_PROVIDER_KEY
639 }
640
641 fn fetch<'a>(
642 &'a self,
643 _client: &'a reqwest::Client,
644 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
645 Box::pin(async move {
649 match broker_proxy_catalog_body(MUSE_PROVIDER_KEY, MUSE_MODELS_PATH).await {
650 Ok(body) => {
651 let models = parse_muse_catalog_models(&body)
652 .map_err(|e| format!("parse failed: {e}"))?;
653 capability_cache::replace_provider(self.provider_key(), &models);
654 Ok(models)
655 }
656 Err(CatalogBrokerError::NotAuthenticated(_)) => Ok(muse_static_catalog_models()),
657 Err(CatalogBrokerError::Failed(err)) => Err(err),
658 }
659 })
660 }
661}
662
663const MUSE_WARMUP_BACKOFF: std::time::Duration = std::time::Duration::from_secs(300);
667const MUSE_WARMUP_FAILURE_BACKOFF: std::time::Duration = std::time::Duration::from_secs(20);
671const MUSE_WARMUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
673
674static MUSE_WARMUP_NEXT: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
676
677fn set_muse_warmup_backoff(backoff: std::time::Duration) {
678 *MUSE_WARMUP_NEXT.lock().unwrap_or_else(|e| e.into_inner()) =
679 Some(std::time::Instant::now() + backoff);
680}
681
682#[cfg(test)]
683pub(crate) fn reset_muse_warmup_backoff() {
684 *MUSE_WARMUP_NEXT.lock().unwrap_or_else(|e| e.into_inner()) = None;
685}
686
687#[cfg(test)]
688pub(crate) fn muse_warmup_retry_in() -> Option<std::time::Duration> {
689 let next = (*MUSE_WARMUP_NEXT.lock().unwrap_or_else(|e| e.into_inner()))?;
690 next.checked_duration_since(std::time::Instant::now())
691}
692
693pub async fn ensure_muse_catalog_for(
706 model: &str,
707 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
708) {
709 let Some(id) = model.strip_prefix("muse/") else {
710 return;
711 };
712 if !muse_routable_id(id)
713 || capability_cache::get(&format!("{MUSE_PROVIDER_KEY}/{id}"))
714 .is_some_and(|row| row.source == CatalogSource::Live)
715 {
716 return;
717 }
718 {
719 let mut next = MUSE_WARMUP_NEXT.lock().unwrap_or_else(|e| e.into_inner());
720 let now = std::time::Instant::now();
721 if next.is_some_and(|at| now < at) {
722 return;
723 }
724 *next = Some(now + MUSE_WARMUP_BACKOFF);
727 }
728 let mut failure = WarmupFailureGuard(true);
731 let fetch = broker_proxy_catalog_body_via(broker, MUSE_PROVIDER_KEY, MUSE_MODELS_PATH);
732 let loaded = match tokio::time::timeout(MUSE_WARMUP_TIMEOUT, fetch).await {
733 Ok(Ok(body)) => match parse_muse_catalog_models(&body) {
734 Ok(models) => {
735 let row = models.iter().find(|m| m.id == id);
736 tracing::info!(
737 models = models.len(),
738 listed = row.is_some(),
739 efforts = ?row.map(|m| &m.reasoning),
740 "muse catalog loaded for {model}"
741 );
742 capability_cache::replace_provider(MUSE_PROVIDER_KEY, &models);
743 true
744 }
745 Err(err) => {
746 tracing::warn!("muse catalog warm-up parse failed: {err}");
747 false
748 }
749 },
750 Ok(Err(CatalogBrokerError::NotAuthenticated(_))) => {
751 tracing::info!("muse catalog warm-up skipped: no Muse login or META_API_KEY");
752 false
753 }
754 Ok(Err(CatalogBrokerError::Failed(err))) => {
755 tracing::warn!("muse catalog warm-up failed: {err}");
756 false
757 }
758 Err(_) => {
759 tracing::warn!("muse catalog warm-up timed out");
760 false
761 }
762 };
763 failure.0 = !loaded;
764}
765
766struct WarmupFailureGuard(bool);
769
770impl Drop for WarmupFailureGuard {
771 fn drop(&mut self) {
772 if self.0 {
773 set_muse_warmup_backoff(MUSE_WARMUP_FAILURE_BACKOFF);
774 }
775 }
776}
777
778impl ModelCatalogProvider for GenericCatalogProvider {
779 fn provider_key(&self) -> &'static str {
780 "generic"
781 }
782
783 fn fetch<'a>(
784 &'a self,
785 _client: &'a reqwest::Client,
786 ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
787 Box::pin(async move {
788 Err("generic catalog fetch requires provider key; use fetch_generic_catalog_provider_models".to_string())
789 })
790 }
791}
792
793async fn fetch_generic_catalog_provider_models(
794 provider_key: &str,
795) -> Result<Vec<CatalogModel>, String> {
796 let specs = super::registry::providers();
797 let spec = specs
798 .iter()
799 .find(|s| s.key == provider_key)
800 .ok_or_else(|| format!("unknown provider: {provider_key}"))?;
801 if !crate::auth::broker::static_key_configured(provider_key) {
802 return Err(format!("{} is not configured", spec.name));
803 }
804 let body = broker_catalog_models_body(provider_key).await?;
805 parse_generic_catalog_models(&body, provider_key, spec.name)
806 .map_err(|e| format!("parse failed: {e}"))
807}
808
809async fn broker_catalog_models_body(provider_key: &str) -> Result<String, CatalogBrokerError> {
812 broker_proxy_catalog_body(provider_key, "/models").await
813}
814
815#[derive(Debug, Clone, PartialEq, Eq)]
819pub enum CatalogBrokerError {
820 NotAuthenticated(String),
824 Failed(String),
827}
828
829impl CatalogBrokerError {
830 fn message(&self) -> &str {
831 match self {
832 Self::NotAuthenticated(msg) | Self::Failed(msg) => msg,
833 }
834 }
835}
836
837impl From<CatalogBrokerError> for String {
838 fn from(err: CatalogBrokerError) -> Self {
839 err.message().to_string()
840 }
841}
842
843fn classify_catalog_broker_error(err: agent_core::auth::BrokerError) -> CatalogBrokerError {
848 use agent_core::auth::BrokerError as B;
849 let msg = format!("request failed: {err}");
850 match &err {
851 B::NotConfigured(_) | B::UnknownProvider(_) | B::RegistrationRequired { .. } => {
852 CatalogBrokerError::NotAuthenticated(msg)
853 }
854 B::Credential(detail) if detail.starts_with("No credentials for ") => {
855 CatalogBrokerError::NotAuthenticated(msg)
856 }
857 _ => CatalogBrokerError::Failed(msg),
858 }
859}
860
861async fn broker_proxy_catalog_body(
864 provider_key: &str,
865 path: &str,
866) -> Result<String, CatalogBrokerError> {
867 broker_proxy_catalog_body_via(&crate::auth::broker::global_broker(), provider_key, path).await
868}
869
870async fn broker_proxy_catalog_body_via(
873 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
874 provider_key: &str,
875 path: &str,
876) -> Result<String, CatalogBrokerError> {
877 let resp = broker
878 .proxy(crate::auth::ProxyRequest {
879 provider: provider_key.to_string(),
880 method: crate::auth::ProxyMethod::Get,
881 path: path.to_string(),
882 body: None,
883 stream: false,
884 body_bytes: None,
885 })
886 .await
887 .map_err(classify_catalog_broker_error)?;
888 if !(200..300).contains(&resp.status) {
889 return Err(CatalogBrokerError::Failed(format!(
890 "model list failed: HTTP {}",
891 resp.status
892 )));
893 }
894 Ok(resp.body)
895}
896
897pub async fn fetch_openrouter_catalog_models(
899 client: &reqwest::Client,
900) -> Result<Vec<CatalogModel>, String> {
901 let resp = catalog_get(client, "https://openrouter.ai/api/v1/models")
902 .send()
903 .await
904 .map_err(|e| format!("request failed: {e}"))?;
905 let body = read_catalog_response(resp).await?;
906 parse_openrouter_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
907}
908
909pub async fn fetch_catalog_models(
914 client: &reqwest::Client,
915 provider_key: &str,
916) -> Result<Vec<CatalogModel>, String> {
917 let provider = catalog_provider_for(provider_key);
918 let models = if provider.provider_key() == "generic" {
919 fetch_generic_catalog_provider_models(provider_key).await?
920 } else {
921 provider.fetch(client).await?
922 };
923 capability_cache::replace_provider(provider_key, &models);
926 Ok(models)
927}
928
929#[cfg(test)]
932mod tests {
933 use super::*;
934
935 #[test]
938 fn catalog_model_rejects_empty_ids() {
939 assert!(CatalogModel::new("openrouter", "OpenRouter", "").is_none());
940 assert!(CatalogModel::new("openrouter", "OpenRouter", " ").is_none());
941 }
942
943 #[test]
944 fn static_seed_sets_fallback_source_and_runtime_id() {
945 let m = from_static_seed("groq", "Groq", "llama-3.3-70b-versatile", "Llama 3.3 70B")
946 .expect("valid seed");
947 assert_eq!(m.runtime_id(), "groq/llama-3.3-70b-versatile");
948 assert_eq!(m.display_label(), "Llama 3.3 70B");
949 assert_eq!(m.source, CatalogSource::StaticFallback);
950 }
951
952 #[test]
953 fn static_seed_empty_label_stores_none() {
954 let m = from_static_seed("groq", "Groq", "model-x", "").expect("valid id");
955 assert_eq!(m.label, None);
956 }
957
958 #[test]
959 fn static_seed_whitespace_label_stores_none() {
960 let m = from_static_seed("groq", "Groq", "model-x", " ").expect("valid id");
961 assert_eq!(m.label, None);
962 }
963
964 #[test]
965 fn static_seeds_from_spec_converts_all_groq_models() {
966 let spec = super::super::registry::providers()
967 .iter()
968 .find(|s| s.key == "groq")
969 .expect("groq spec");
970 let seeds = static_seeds_from_spec(spec);
971 assert_eq!(seeds.len(), spec.models.len());
972 assert!(seeds
973 .iter()
974 .all(|m| m.source == CatalogSource::StaticFallback));
975 assert!(seeds.iter().all(|m| !m.id.is_empty()));
976 assert!(seeds.iter().all(|m| m.runtime_id().starts_with("groq/")));
977 }
978
979 #[test]
980 fn anthropic_runtime_id_is_provider_qualified() {
981 let mut m = CatalogModel::new("anthropic", "Anthropic", "claude-opus-4-7").unwrap();
982 m.provider_kind = CatalogProviderKind::Anthropic;
983 assert_eq!(m.runtime_id(), "anthropic/claude-opus-4-7");
984 }
985
986 #[test]
987 fn pricing_summary_has_internal_reasoning_cost_zero_is_false() {
988 let p = PricingSummary {
989 prompt: None,
990 completion: None,
991 internal_reasoning: Some("0".to_string()),
992 };
993 assert!(!p.has_internal_reasoning_cost());
994 }
995
996 #[test]
997 fn pricing_summary_has_internal_reasoning_cost_nonzero_is_true() {
998 let p = PricingSummary {
999 prompt: None,
1000 completion: None,
1001 internal_reasoning: Some("0.0000035".to_string()),
1002 };
1003 assert!(p.has_internal_reasoning_cost());
1004 }
1005
1006 #[tokio::test]
1007 async fn ui_catalog_fetch_includes_xai_static_models() {
1008 let models = fetch_catalog_models(&reqwest::Client::new(), "xai-auth")
1009 .await
1010 .expect("static xAI catalog");
1011 assert_eq!(models, xai_static_catalog_models());
1012 assert!(models
1013 .iter()
1014 .all(|model| model.runtime_id().starts_with("xai-auth/")));
1015 }
1016
1017 #[tokio::test]
1018 #[serial_test::serial(synaps_base_dir)]
1021 async fn ui_catalog_fetch_github_copilot_returns_prefixed_chat_models() {
1022 let _base = crate::test_env::BaseDirGuard::new();
1024 let models = fetch_catalog_models(&reqwest::Client::new(), "github-copilot")
1026 .await
1027 .expect("GitHub Copilot catalog");
1028 assert!(!models.is_empty());
1029 assert!(models
1030 .iter()
1031 .all(|model| model.runtime_id().starts_with("github-copilot/")));
1032 let ids: std::collections::HashSet<_> = models.iter().map(|m| m.id.as_str()).collect();
1033 for required in ["gpt-5.3-codex", "claude-sonnet-4.6", "gemini-3.5-flash"] {
1036 assert!(ids.contains(required), "missing {required}");
1037 }
1038 assert!(!ids.contains("text-embedding-3-small"));
1039 }
1040
1041 #[tokio::test]
1046 #[serial_test::serial(synaps_base_dir)]
1047 async fn copilot_catalog_without_credentials_is_deterministic_static_fallback() {
1048 let _base = crate::test_env::BaseDirGuard::new();
1049 let models = fetch_catalog_models(&reqwest::Client::new(), "github-copilot")
1050 .await
1051 .expect("credential-less fetch must fall back to static seeds");
1052 assert_eq!(models, copilot_static_catalog_models());
1053 assert!(models
1054 .iter()
1055 .all(|m| m.runtime_id().starts_with("github-copilot/")));
1056 }
1057
1058 #[tokio::test]
1063 #[serial_test::serial(synaps_base_dir)]
1064 async fn copilot_catalog_is_deterministic_under_concurrent_base_dir_churn() {
1065 let base_a = tempfile::TempDir::new().unwrap();
1066 let base_b = tempfile::TempDir::new().unwrap();
1067 let old = std::env::var("SYNAPS_BASE_DIR").ok();
1068 std::env::set_var("SYNAPS_BASE_DIR", base_a.path());
1069
1070 let local = tokio::task::LocalSet::new();
1071 local
1072 .run_until(async {
1073 let churn = {
1074 let a = base_a.path().to_path_buf();
1075 let b = base_b.path().to_path_buf();
1076 tokio::task::spawn_local(async move {
1079 loop {
1080 std::env::set_var("SYNAPS_BASE_DIR", &a);
1081 tokio::task::yield_now().await;
1082 std::env::set_var("SYNAPS_BASE_DIR", &b);
1083 tokio::task::yield_now().await;
1084 }
1085 })
1086 };
1087 for _ in 0..20 {
1088 let models = fetch_catalog_models(&reqwest::Client::new(), "github-copilot")
1089 .await
1090 .expect("fetch must be deterministic under base-dir churn");
1091 assert_eq!(models, copilot_static_catalog_models());
1092 tokio::task::yield_now().await;
1093 }
1094 churn.abort();
1095 })
1096 .await;
1097
1098 match old {
1099 Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
1100 None => std::env::remove_var("SYNAPS_BASE_DIR"),
1101 }
1102 }
1103
1104 #[test]
1105 fn github_copilot_static_catalog_is_available_without_network() {
1106 let models = copilot_static_catalog_models();
1107 assert_eq!(models.len(), COPILOT_FALLBACK_MODELS.len());
1108 assert!(models
1109 .iter()
1110 .all(|m| m.source == CatalogSource::StaticFallback));
1111 }
1112
1113 #[test]
1114 fn catalog_provider_trait_dispatch_selects_specialized_handlers() {
1115 assert_eq!(
1116 catalog_provider_for("openrouter").provider_key(),
1117 "openrouter"
1118 );
1119 assert_eq!(catalog_provider_for("groq").provider_key(), "groq");
1120 assert_eq!(catalog_provider_for("nvidia").provider_key(), "nvidia");
1121 assert_eq!(catalog_provider_for("claude").provider_key(), "claude");
1122 assert_eq!(
1123 catalog_provider_for("openai-codex").provider_key(),
1124 "openai-codex"
1125 );
1126 assert_eq!(catalog_provider_for("xai-auth").provider_key(), "xai-auth");
1127 assert_eq!(
1128 catalog_provider_for("github-copilot").provider_key(),
1129 "github-copilot"
1130 );
1131 assert_eq!(catalog_provider_for("cerebras").provider_key(), "generic");
1132 }
1133
1134 #[test]
1135 fn catalog_request_timeout_is_bounded() {
1136 assert_eq!(CATALOG_REQUEST_TIMEOUT, Duration::from_secs(15));
1137 }
1138
1139 #[test]
1140 fn anthropic_page_metadata_is_exposed_for_pagination() {
1141 let page = parse_anthropic_catalog_page(
1142 r#"{
1143 "data":[{"id":"claude-opus-4-7"}],
1144 "has_more": true,
1145 "last_id": "claude-opus-4-7"
1146 }"#,
1147 )
1148 .expect("parse page");
1149 assert!(page.has_more);
1150 assert_eq!(page.last_id.as_deref(), Some("claude-opus-4-7"));
1151 assert_eq!(page.models.len(), 1);
1152 }
1153
1154 #[test]
1155 fn anthropic_pagination_url_adds_after_id_cursor() {
1156 assert_eq!(
1157 anthropic_models_url(None),
1158 "https://api.anthropic.com/v1/models?limit=100"
1159 );
1160 assert_eq!(
1161 anthropic_models_url(Some("claude-opus-4-7")),
1162 "https://api.anthropic.com/v1/models?limit=100&after_id=claude-opus-4-7"
1163 );
1164 }
1165
1166 #[test]
1167 fn merge_catalog_pages_dedupes_by_id() {
1168 let first =
1169 parse_anthropic_catalog_models(r#"{"data":[{"id":"claude-opus-4-7"}]}"#).unwrap();
1170 let second = parse_anthropic_catalog_models(
1171 r#"{"data":[{"id":"claude-opus-4-7"},{"id":"claude-sonnet-4-6"}]}"#,
1172 )
1173 .unwrap();
1174 let merged = merge_catalog_pages(vec![first, second]);
1175 assert_eq!(merged.len(), 2);
1176 assert_eq!(merged[0].id, "claude-opus-4-7");
1177 assert_eq!(merged[1].id, "claude-sonnet-4-6");
1178 }
1179
1180 mod openrouter {
1183 use super::super::*;
1184
1185 const RICH_FIXTURE: &str = r#"{
1186 "data": [
1187 {
1188 "id": "qwen/qwen3-coder",
1189 "name": "Qwen: Qwen3 Coder",
1190 "context_length": 131072,
1191 "top_provider": { "max_completion_tokens": 32768 },
1192 "supported_parameters": ["temperature", "top_p", "max_tokens"],
1193 "pricing": { "prompt": "0.0000001", "completion": "0.0000004", "internal_reasoning": "0" },
1194 "architecture": { "input_modalities": ["text"] }
1195 },
1196 {
1197 "id": "anthropic/claude-opus-4-7",
1198 "name": "Anthropic: Claude Opus 4.7",
1199 "context_length": 200000,
1200 "supported_parameters": ["temperature", "verbosity", "max_tokens"],
1201 "pricing": { "prompt": "0.000015", "completion": "0.000075" }
1202 },
1203 {
1204 "id": "openai/o4-mini",
1205 "name": "OpenAI: o4-mini",
1206 "context_length": 128000,
1207 "supported_parameters": ["reasoning_effort", "max_tokens"],
1208 "pricing": { "prompt": "0.0000011", "completion": "0.0000044" }
1209 },
1210 {
1211 "id": "google/gemini-2.5-flash",
1212 "name": "Google: Gemini 2.5 Flash",
1213 "context_length": 1048576,
1214 "supported_parameters": ["reasoning", "include_reasoning", "max_tokens"],
1215 "pricing": { "prompt": "0.00000015", "completion": "0.0000035", "internal_reasoning": "0.0000035" },
1216 "architecture": { "input_modalities": ["text", "image", "audio", "video"] }
1217 },
1218 {
1219 "id": "",
1220 "name": "Empty — must be filtered"
1221 }
1222 ]
1223 }"#;
1224
1225 #[test]
1226 fn parses_minimal_model() {
1227 let json = r#"{"data":[{"id":"test/model","name":"Test Model"}]}"#;
1228 let models = parse_openrouter_catalog_models(json).expect("parse ok");
1229 assert_eq!(models.len(), 1);
1230 assert_eq!(models[0].id, "test/model");
1231 assert_eq!(models[0].label.as_deref(), Some("Test Model"));
1232 assert_eq!(models[0].runtime_id(), "openrouter/test/model");
1233 assert_eq!(models[0].provider_key, "openrouter");
1234 assert_eq!(models[0].source, CatalogSource::Live);
1235 }
1236
1237 #[test]
1238 fn parses_context_length_and_max_output() {
1239 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1240 let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
1241 assert_eq!(qwen.context_tokens, Some(131_072));
1242 assert_eq!(qwen.max_output_tokens, Some(32_768));
1243 }
1244
1245 #[test]
1246 fn filters_empty_ids() {
1247 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1248 assert!(!models.iter().any(|m| m.id.is_empty()));
1249 assert_eq!(models.len(), 4);
1250 }
1251
1252 #[test]
1253 fn parses_pricing_fields() {
1254 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1255 let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
1256 assert_eq!(qwen.pricing.prompt.as_deref(), Some("0.0000001"));
1257 assert_eq!(qwen.pricing.completion.as_deref(), Some("0.0000004"));
1258 assert!(!qwen.pricing.has_internal_reasoning_cost());
1259 }
1260
1261 #[test]
1262 fn parses_internal_reasoning_cost_flag() {
1263 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1264 let gemini = models
1265 .iter()
1266 .find(|m| m.id == "google/gemini-2.5-flash")
1267 .unwrap();
1268 assert!(gemini.pricing.has_internal_reasoning_cost());
1269 }
1270
1271 #[test]
1272 fn no_reasoning_params_maps_to_none() {
1273 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1274 let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
1275 assert_eq!(qwen.reasoning, ReasoningSupport::None);
1276 }
1277
1278 #[test]
1279 fn verbosity_param_maps_to_anthropic_adaptive() {
1280 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1281 let claude = models
1282 .iter()
1283 .find(|m| m.id == "anthropic/claude-opus-4-7")
1284 .unwrap();
1285 assert_eq!(
1286 claude.reasoning,
1287 ReasoningSupport::AnthropicAdaptive { adaptive: true }
1288 );
1289 }
1290
1291 #[test]
1292 fn reasoning_effort_maps_to_openrouter_reasoning() {
1293 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1294 let o4 = models.iter().find(|m| m.id == "openai/o4-mini").unwrap();
1295 assert_eq!(
1296 o4.reasoning,
1297 ReasoningSupport::OpenRouter {
1298 include_reasoning: false,
1299 effort: true,
1300 verbosity: false,
1301 internal_reasoning_priced: false,
1302 }
1303 );
1304 }
1305
1306 #[test]
1307 fn reasoning_include_reasoning_maps_correctly() {
1308 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1309 let gemini = models
1310 .iter()
1311 .find(|m| m.id == "google/gemini-2.5-flash")
1312 .unwrap();
1313 assert_eq!(
1314 gemini.reasoning,
1315 ReasoningSupport::OpenRouter {
1316 include_reasoning: true,
1317 effort: false,
1318 verbosity: false,
1319 internal_reasoning_priced: true,
1320 }
1321 );
1322 }
1323
1324 #[test]
1325 fn parses_multimodal_input() {
1326 let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1327 let gemini = models
1328 .iter()
1329 .find(|m| m.id == "google/gemini-2.5-flash")
1330 .unwrap();
1331 assert!(gemini.input_modalities.contains(&Modality::Text));
1332 assert!(gemini.input_modalities.contains(&Modality::Image));
1333 assert!(gemini.input_modalities.contains(&Modality::Audio));
1334 assert!(gemini.input_modalities.contains(&Modality::Video));
1335 }
1336
1337 #[test]
1338 fn missing_modalities_defaults_to_text() {
1339 let json = r#"{"data":[{"id":"test/model"}]}"#;
1340 let models = parse_openrouter_catalog_models(json).expect("parse ok");
1341 assert_eq!(models[0].input_modalities, vec![Modality::Text]);
1342 }
1343
1344 #[test]
1345 fn parses_openrouter_rich_metadata_and_reasoning_flags() {
1346 let json = r#"{
1348 "data": [{
1349 "id": "anthropic/claude-sonnet-4.6",
1350 "name": "Anthropic: Claude Sonnet 4.6",
1351 "context_length": 1000000,
1352 "architecture": { "input_modalities": ["text", "image"] },
1353 "pricing": { "prompt": "0.000003", "completion": "0.000015", "internal_reasoning": "0.000012" },
1354 "top_provider": { "max_completion_tokens": 128000 },
1355 "supported_parameters": ["reasoning", "include_reasoning", "verbosity", "tools"]
1356 }]
1357 }"#;
1358 let models = parse_openrouter_catalog_models(json).expect("parse ok");
1359 assert_eq!(models.len(), 1);
1360 let m = &models[0];
1361 assert_eq!(m.runtime_id(), "openrouter/anthropic/claude-sonnet-4.6");
1362 assert_eq!(m.context_tokens, Some(1_000_000));
1363 assert_eq!(m.max_output_tokens, Some(128_000));
1364 assert!(m.input_modalities.contains(&Modality::Image));
1365 assert_eq!(
1367 m.reasoning,
1368 ReasoningSupport::AnthropicAdaptive { adaptive: true }
1369 );
1370 }
1371
1372 #[test]
1373 fn parses_openrouter_non_reasoning_model_as_none() {
1374 let json = r#"{
1375 "data": [{
1376 "id": "meta-llama/llama-3.3-70b-instruct",
1377 "name": "Meta: Llama 3.3 70B",
1378 "supported_parameters": ["temperature", "tools"]
1379 }]
1380 }"#;
1381 let models = parse_openrouter_catalog_models(json).expect("parse ok");
1382 assert_eq!(models[0].reasoning, ReasoningSupport::None);
1383 }
1384
1385 #[test]
1386 fn invalid_json_returns_error() {
1387 assert!(parse_openrouter_catalog_models("{not json}").is_err());
1388 }
1389
1390 #[test]
1391 fn missing_data_key_returns_error() {
1392 assert!(parse_openrouter_catalog_models(r#"{"models":[]}"#).is_err());
1393 }
1394 }
1395
1396 mod anthropic {
1401 use super::super::*;
1402
1403 #[test]
1404 fn parser_reads_optional_capabilities_and_token_limits() {
1405 let json = r#"{
1406 "data": [{
1407 "id": "claude-opus-4-7",
1408 "display_name": "Claude Opus 4.7",
1409 "max_input_tokens": 200000,
1410 "max_tokens": 32000,
1411 "capabilities": {
1412 "thinking": { "supported": true },
1413 "effort": { "supported": true }
1414 }
1415 }],
1416 "has_more": false
1417 }"#;
1418 let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
1419 assert_eq!(models.len(), 1);
1420 let model = &models[0];
1421 assert_eq!(model.runtime_id(), "anthropic/claude-opus-4-7");
1422 assert_eq!(model.label.as_deref(), Some("Claude Opus 4.7"));
1423 assert_eq!(model.context_tokens, Some(200_000));
1424 assert_eq!(model.max_output_tokens, Some(32_000));
1425 assert_eq!(
1426 model.reasoning,
1427 ReasoningSupport::AnthropicAdaptive { adaptive: true }
1428 );
1429 }
1430
1431 #[test]
1432 fn parser_tolerates_missing_capabilities_as_unknown() {
1433 let json =
1434 r#"{"data":[{"id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku"}]}"#;
1435 let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
1436 assert_eq!(models[0].reasoning, ReasoningSupport::Unknown);
1437 }
1438
1439 #[test]
1440 fn parser_filters_empty_ids() {
1441 let json = r#"{"data":[{"id":""},{"id":"claude-sonnet-4-6"}]}"#;
1442 let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
1443 assert_eq!(models.len(), 1);
1444 assert_eq!(models[0].id, "claude-sonnet-4-6");
1445 }
1446 }
1447
1448 mod codex {
1449 use super::super::*;
1450
1451 #[test]
1452 fn static_catalog_uses_fallback_source_and_prefixed_runtime_ids() {
1453 let models = codex_static_catalog_models();
1454 assert!(models.iter().any(|m| m.id == "gpt-5.5"));
1455 assert!(models.iter().any(|m| m.id == "gpt-5.4"));
1456 assert!(models.iter().any(|m| m.id == "gpt-5.4-mini"));
1457 assert!(!models.iter().any(|m| m.id == "gpt-5.5-pro"));
1458 assert!(!models.iter().any(|m| m.id == "gpt-5.4-nano"));
1459 assert!(!models.iter().any(|m| m.id == "gpt-5.1-codex-mini"));
1460 assert!(models
1461 .iter()
1462 .all(|m| m.source == CatalogSource::StaticFallback));
1463 assert!(models
1464 .iter()
1465 .all(|m| m.runtime_id().starts_with("openai-codex/")));
1466 }
1467
1468 #[test]
1469 fn live_parser_filters_non_list_visibility_from_fixture() {
1470 let fixture = include_str!("fixtures/openai_codex_models.json");
1471 let models = parse_codex_catalog_models(fixture).expect("parse");
1472 let ids: std::collections::HashSet<_> = models.iter().map(|m| m.id.as_str()).collect();
1473 assert!(ids.contains("gpt-5.6-sol"));
1474 assert!(ids.contains("gpt-5.3-codex-spark"));
1475 assert!(!ids.contains("codex-auto-review"));
1476 assert!(!ids.contains("codex-internal-eval"));
1477 assert!(models.iter().all(|m| m.source == CatalogSource::Live));
1478 }
1479
1480 #[test]
1481 fn models_path_carries_pinned_codex_protocol_version() {
1482 let path = codex_models_path(codex::CODEX_CLIENT_VERSION);
1483 assert!(path.starts_with("/codex/models?client_version="));
1484 assert!(path.ends_with(codex::CODEX_CLIENT_VERSION));
1485 assert!(!path.contains(env!("CARGO_PKG_VERSION")));
1488 }
1489
1490 #[test]
1491 fn pinned_codex_protocol_version_meets_astra_floor() {
1492 let parse = |v: &str| -> (u64, u64, u64) {
1495 let mut it = v.split('.').map(|p| p.parse::<u64>().expect("numeric"));
1496 (
1497 it.next().unwrap(),
1498 it.next().unwrap(),
1499 it.next().unwrap_or(0),
1500 )
1501 };
1502 assert!(parse(codex::CODEX_CLIENT_VERSION) >= parse("0.159.0"));
1503 }
1504
1505 #[test]
1506 fn missing_credential_fallback_classifies_real_broker_not_configured_text() {
1507 let not_configured = format!(
1508 "request failed: {}",
1509 crate::auth::BrokerError::NotConfigured("openai-codex".into())
1510 );
1511 assert!(
1512 is_missing_credential_catalog_error(¬_configured),
1513 "got: {not_configured}"
1514 );
1515 let unknown = format!(
1516 "request failed: {}",
1517 crate::auth::BrokerError::UnknownProvider("openai-codex".into())
1518 );
1519 assert!(
1520 is_missing_credential_catalog_error(&unknown),
1521 "got: {unknown}"
1522 );
1523 }
1524
1525 #[test]
1526 fn missing_credential_fallback_classifies_exact_broker_credential_no_credentials_text() {
1527 let raw = crate::auth::BrokerError::Credential(format!(
1532 "No credentials for openai-codex at {}. Run `synaps login`.",
1533 std::path::Path::new("/tmp/auth.json").display()
1534 ));
1535 let wrapped = format!("request failed: {raw}");
1536 assert!(
1537 is_missing_credential_catalog_error(&wrapped),
1538 "exact BrokerError::Credential missing-login text must fall back to static seeds; got: {wrapped}"
1539 );
1540 assert!(is_missing_credential_catalog_error(
1542 "request failed: credential error: No credentials for openai-codex at /home/user/.synaps/auth.json. Run `synaps login`."
1543 ));
1544 }
1545
1546 #[test]
1550 fn typed_classifier_maps_auth_misses_to_fallback_and_operations_to_failed() {
1551 use crate::auth::BrokerError as B;
1552 let auth_misses = [
1553 B::NotConfigured("github-copilot".into()),
1554 B::UnknownProvider("github-copilot".into()),
1555 B::RegistrationRequired {
1556 provider: "github-copilot".into(),
1557 remediation: "run synaps login".into(),
1558 },
1559 B::Credential(
1563 "No credentials for github-copilot at /tmp/x/auth.json. Run `synaps login`."
1564 .into(),
1565 ),
1566 ];
1567 for err in auth_misses {
1568 assert!(
1569 matches!(
1570 classify_catalog_broker_error(err.clone()),
1571 CatalogBrokerError::NotAuthenticated(_)
1572 ),
1573 "must classify as no-live-session: {err}"
1574 );
1575 }
1576
1577 let operational = [
1578 B::Transport("connection reset".into()),
1579 B::Denied("proxy path not allowlisted".into()),
1580 B::Unauthorized,
1581 B::Credential("github-copilot credential is missing chatgpt account id".into()),
1582 B::UnsupportedCapability {
1583 provider: "github-copilot".into(),
1584 capability: "tools".into(),
1585 },
1586 ];
1587 for err in operational {
1588 assert!(
1589 matches!(
1590 classify_catalog_broker_error(err.clone()),
1591 CatalogBrokerError::Failed(_)
1592 ),
1593 "must classify as operational failure: {err}"
1594 );
1595 }
1596 }
1597
1598 #[test]
1599 fn missing_credential_fallback_does_not_hide_transport_http_parse_or_account_errors() {
1600 for err in [
1601 "request failed: broker transport error: connection reset",
1602 "model list failed: HTTP 401",
1603 "model list failed: HTTP 500",
1604 "parse failed: missing field `models`",
1605 "request failed: broker denied request: proxy path '/models' is not in the provider's endpoint allowlist",
1606 "request failed: credential error: openai-codex credential is missing chatgpt account id",
1607 "request failed: broker transport error: provider request failed: 403 Forbidden",
1608 ] {
1609 assert!(
1610 !is_missing_credential_catalog_error(err),
1611 "must not fallback for: {err}"
1612 );
1613 }
1614 }
1615 }
1616
1617 mod anthropic_paths {
1618 use super::super::*;
1619
1620 #[test]
1621 fn proxy_path_round_trips_limit_and_after_id() {
1622 assert_eq!(anthropic_models_proxy_path(None), "/v1/models?limit=100");
1623 assert_eq!(
1624 anthropic_models_proxy_path(Some("claude-opus-4-7")),
1625 "/v1/models?limit=100&after_id=claude-opus-4-7"
1626 );
1627 assert_eq!(
1628 anthropic_models_proxy_path(Some("")),
1629 "/v1/models?limit=100"
1630 );
1631 }
1632 }
1633
1634 mod groq {
1637 use super::super::*;
1638
1639 #[test]
1640 fn parser_extracts_context_window_and_filters_inactive() {
1641 let json = r#"{"data":[
1642 {"id":"llama-3.3-70b-versatile","active":true,"context_window":131072,"owned_by":"Meta"},
1643 {"id":"old-model-v1","active":false,"context_window":8192,"owned_by":"Meta"}
1644 ]}"#;
1645 let models = parse_groq_catalog_models(json).expect("parse groq");
1646 assert_eq!(models.len(), 1);
1647 assert_eq!(models[0].id, "llama-3.3-70b-versatile");
1648 assert_eq!(models[0].context_tokens, Some(131_072));
1649 assert_eq!(models[0].reasoning, ReasoningSupport::None);
1650 }
1651
1652 #[test]
1653 fn inference_maps_reasoning_families() {
1654 assert_eq!(
1655 infer_groq_reasoning("openai/gpt-oss-120b"),
1656 ReasoningSupport::GroqReasoning
1657 );
1658 assert_eq!(
1659 infer_groq_reasoning("qwen/qwen3-32b"),
1660 ReasoningSupport::GroqReasoning
1661 );
1662 assert_eq!(
1663 infer_groq_reasoning("groq/compound-mini"),
1664 ReasoningSupport::GroqReasoning
1665 );
1666 assert_eq!(
1667 infer_groq_reasoning("llama-3.3-70b-versatile"),
1668 ReasoningSupport::None
1669 );
1670 }
1671
1672 #[test]
1673 fn parser_filters_empty_ids() {
1674 let json = r#"{"data":[{"id":"","active":true},{"id":"openai/gpt-oss-20b","active":true,"context_window":131072}]}"#;
1675 let models = parse_groq_catalog_models(json).expect("parse groq");
1676 assert_eq!(models.len(), 1);
1677 assert_eq!(models[0].id, "openai/gpt-oss-20b");
1678 }
1679 }
1680
1681 mod nvidia {
1682 use super::super::*;
1683
1684 #[test]
1685 fn parser_dedupes_and_enriches_known_context() {
1686 let json = r#"{"data":[
1687 {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
1688 {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
1689 {"id":"moonshotai/kimi-k2-thinking","owned_by":"moonshotai"}
1690 ]}"#;
1691 let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
1692 assert_eq!(models.len(), 2);
1693 let ultra = models.iter().find(|m| m.id.contains("ultra")).unwrap();
1694 assert_eq!(ultra.context_tokens, Some(128_000));
1695 assert_eq!(ultra.reasoning, ReasoningSupport::NvidiaInlineThinking);
1696 let kimi = models.iter().find(|m| m.id.contains("kimi")).unwrap();
1697 assert_eq!(kimi.context_tokens, Some(256_000));
1698 }
1699
1700 #[test]
1701 fn inference_detects_thinking_and_standard_models() {
1702 assert_eq!(
1703 infer_nvidia_reasoning("qwen/qwen3-next-80b-a3b-thinking"),
1704 ReasoningSupport::NvidiaInlineThinking
1705 );
1706 assert_eq!(
1707 infer_nvidia_reasoning("nvidia/cosmos-reason2-8b"),
1708 ReasoningSupport::NvidiaInlineThinking
1709 );
1710 assert_eq!(
1711 infer_nvidia_reasoning("meta/llama-3.3-70b-instruct"),
1712 ReasoningSupport::None
1713 );
1714 }
1715
1716 #[test]
1717 fn parser_filters_empty_ids() {
1718 let json = r#"{"data":[{"id":""},{"id":"meta/llama-3.3-70b-instruct"}]}"#;
1719 let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
1720 assert_eq!(models.len(), 1);
1721 assert_eq!(models[0].id, "meta/llama-3.3-70b-instruct");
1722 }
1723 }
1724
1725 mod generic_compat {
1726 use super::super::*;
1727
1728 #[test]
1729 fn parses_generic_catalog_models_and_filters_empty_ids() {
1730 let json = r#"{
1731 "data": [
1732 { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
1733 { "id": "" },
1734 { "id": "openai/gpt-oss-120b" }
1735 ]
1736 }"#;
1737 let models =
1738 parse_generic_catalog_models(json, "openrouter", "OpenRouter").expect("parse ok");
1739 assert_eq!(models.len(), 2);
1740 assert_eq!(models[0].runtime_id(), "openrouter/qwen/qwen3-coder");
1741 assert_eq!(models[0].display_label(), "Qwen: Qwen3 Coder");
1742 assert_eq!(models[1].display_label(), "openai/gpt-oss-120b");
1743 }
1744
1745 #[test]
1746 fn whitespace_only_id_is_filtered() {
1747 let json = r#"{"data":[{"id":" "},{"id":"valid"}]}"#;
1748 let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
1749 assert_eq!(models.len(), 1);
1750 assert_eq!(models[0].id, "valid");
1751 }
1752
1753 #[test]
1754 fn whitespace_label_stored_as_none() {
1755 let json = r#"{"data":[{"id":"m1","name":" "}]}"#;
1756 let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
1757 assert_eq!(models[0].label, None);
1758 }
1759
1760 #[test]
1761 fn generic_catalog_source_is_live() {
1762 let json = r#"{"data":[{"id":"m1","name":"Model One"}]}"#;
1763 let models =
1764 parse_generic_catalog_models(json, "testprovider", "Test").expect("parse ok");
1765 assert_eq!(models[0].source, CatalogSource::Live);
1766 }
1767
1768 #[test]
1769 fn generic_reasoning_is_generic_open_ai() {
1770 let json = r#"{"data":[{"id":"m1"}]}"#;
1771 let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
1772 assert_eq!(models[0].reasoning, ReasoningSupport::GenericOpenAi);
1773 }
1774
1775 #[test]
1776 fn generic_parse_matches_legacy_parse_behavior() {
1777 let json = r#"{
1780 "data": [
1781 { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
1782 { "id": "openai/gpt-oss-120b" }
1783 ]
1784 }"#;
1785 let legacy = super::super::super::registry::parse_provider_models_response(json)
1786 .expect("legacy parse ok");
1787 let catalog = parse_generic_catalog_models(json, "openrouter", "OpenRouter")
1788 .expect("catalog parse ok");
1789 assert_eq!(legacy.len(), catalog.len());
1790 for (l, c) in legacy.iter().zip(catalog.iter()) {
1791 assert_eq!(l.id, c.id, "ids must match");
1792 assert_eq!(l.name, c.label, "labels must match");
1793 }
1794 }
1795
1796 #[test]
1797 fn static_seeds_from_spec_all_providers() {
1798 for spec in super::super::super::registry::providers() {
1800 if spec.models.is_empty() {
1801 continue;
1802 }
1803 let seeds = super::super::static_seeds_from_spec(spec);
1804 assert!(!seeds.is_empty(), "no seeds for {}", spec.key);
1805 assert!(
1806 seeds
1807 .iter()
1808 .all(|m| m.runtime_id().starts_with(&format!("{}/", spec.key))),
1809 "runtime_id prefix wrong for {}",
1810 spec.key
1811 );
1812 }
1813 }
1814 }
1815}
1816
1817#[cfg(test)]
1818mod muse_warmup_tests {
1819 use super::*;
1823 use crate::auth::{
1824 AccessToken, BrokerError, CredentialBroker, ProxyByteStream, ProxyMethod, ProxyRequest,
1825 ProxyResponse,
1826 };
1827 use std::sync::{Arc, Mutex};
1828
1829 struct CatalogBroker {
1830 body: Result<&'static str, BrokerError>,
1831 seen: Mutex<Vec<ProxyRequest>>,
1832 }
1833
1834 #[async_trait::async_trait]
1835 impl CredentialBroker for CatalogBroker {
1836 async fn access_token(
1837 &self,
1838 _: crate::auth::OAuthProviderId,
1839 ) -> Result<AccessToken, BrokerError> {
1840 unreachable!("warm-up never vends a credential to the runtime")
1841 }
1842 async fn proxy(&self, request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
1843 self.seen.lock().unwrap().push(request);
1844 self.body.clone().map(|body| ProxyResponse {
1845 status: 200,
1846 body: body.to_string(),
1847 })
1848 }
1849 async fn proxy_stream(&self, _: ProxyRequest) -> Result<ProxyByteStream, BrokerError> {
1850 unreachable!()
1851 }
1852 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
1853 unreachable!()
1854 }
1855 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
1856 Ok(vec![])
1857 }
1858 }
1859
1860 fn broker(body: Result<&'static str, BrokerError>) -> Arc<CatalogBroker> {
1861 Arc::new(CatalogBroker {
1862 body,
1863 seen: Mutex::new(Vec::new()),
1864 })
1865 }
1866
1867 fn run(model: &str, broker: &Arc<CatalogBroker>) {
1868 let dyn_broker: Arc<dyn CredentialBroker> = broker.clone();
1869 tokio::runtime::Builder::new_current_thread()
1870 .enable_all()
1871 .build()
1872 .unwrap()
1873 .block_on(ensure_muse_catalog_for(model, &dyn_broker));
1874 }
1875
1876 fn fresh() {
1877 capability_cache::replace_provider(MUSE_PROVIDER_KEY, &[]);
1878 reset_muse_warmup_backoff();
1879 }
1880
1881 const CATALOG: &str = r#"{"models":[{"id":"muse-warmup-probe-zz7","display_order":1,
1882 "reasoning_effort_variants":[{"effort":"high"}]}]}"#;
1883
1884 #[test]
1885 fn uncached_id_loads_live_evidence_once_through_the_given_broker() {
1886 let _guard = muse::test_cache_guard();
1887 fresh();
1888 let model = "muse/muse-warmup-probe-zz7";
1889 assert!(crate::runtime::openai::resolve_route(model).is_some());
1891 assert_eq!(muse_supported_levels("muse-warmup-probe-zz7"), None);
1892
1893 let b = broker(Ok(CATALOG));
1894 run(model, &b);
1895 {
1896 let seen = b.seen.lock().unwrap();
1897 assert_eq!(seen.len(), 1);
1898 assert_eq!(seen[0].provider, "muse");
1899 assert_eq!(seen[0].method, ProxyMethod::Get);
1900 assert_eq!(seen[0].path, MUSE_MODELS_PATH);
1901 assert!(seen[0].body.is_none());
1902 }
1903 assert_eq!(
1904 muse_supported_levels("muse-warmup-probe-zz7"),
1905 Some(vec![agent_core::reasoning::ReasoningLevel::High])
1906 );
1907
1908 reset_muse_warmup_backoff();
1910 run(model, &b);
1911 assert_eq!(b.seen.lock().unwrap().len(), 1);
1912 fresh();
1913 }
1914
1915 #[test]
1916 fn warmup_is_backed_off_and_skips_non_candidates() {
1917 let _guard = muse::test_cache_guard();
1918 fresh();
1919 let b = broker(Ok(CATALOG));
1922 run("muse/not-in-live-catalog-zz8", &b);
1923 run("muse/not-in-live-catalog-zz8", &b);
1924 run("muse/another-missing-zz9", &b);
1925 assert_eq!(b.seen.lock().unwrap().len(), 1);
1926
1927 fresh();
1929 let idle = broker(Ok(CATALOG));
1930 for model in [
1931 "xai-auth/grok-4.5",
1932 "anthropic/claude-x",
1933 "muse/Bad Id",
1934 "muse/../x",
1935 "muse/",
1936 ] {
1937 run(model, &idle);
1938 }
1939 assert!(idle.seen.lock().unwrap().is_empty());
1940
1941 for err in [
1943 BrokerError::NotConfigured("muse".into()),
1944 BrokerError::Transport("down".into()),
1945 ] {
1946 fresh();
1947 let failing = broker(Err(err));
1948 run("muse/muse-warmup-probe-zz7", &failing);
1949 run("muse/muse-warmup-probe-zz7", &failing);
1950 assert_eq!(failing.seen.lock().unwrap().len(), 1);
1951 assert_eq!(muse_supported_levels("muse-warmup-probe-zz7"), None);
1952 }
1953 fresh();
1954 }
1955
1956 struct HangingBroker;
1958
1959 #[async_trait::async_trait]
1960 impl CredentialBroker for HangingBroker {
1961 async fn access_token(
1962 &self,
1963 _: crate::auth::OAuthProviderId,
1964 ) -> Result<AccessToken, BrokerError> {
1965 unreachable!()
1966 }
1967 async fn proxy(&self, _: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
1968 std::future::pending().await
1969 }
1970 async fn proxy_stream(&self, _: ProxyRequest) -> Result<ProxyByteStream, BrokerError> {
1971 unreachable!()
1972 }
1973 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
1974 unreachable!()
1975 }
1976 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
1977 Ok(vec![])
1978 }
1979 }
1980
1981 #[test]
1984 fn cancelled_warmup_falls_back_to_the_short_backoff() {
1985 let _guard = muse::test_cache_guard();
1986 fresh();
1987 let dyn_broker: Arc<dyn CredentialBroker> = Arc::new(HangingBroker);
1988 tokio::runtime::Builder::new_current_thread()
1989 .enable_all()
1990 .build()
1991 .unwrap()
1992 .block_on(async {
1993 let warm = ensure_muse_catalog_for("muse/muse-warmup-probe-zz7", &dyn_broker);
1994 assert!(
1995 tokio::time::timeout(std::time::Duration::from_millis(50), warm)
1996 .await
1997 .is_err(),
1998 "the fetch hangs and is dropped"
1999 );
2000 });
2001 let wait = muse_warmup_retry_in().expect("backed off");
2002 assert!(wait <= std::time::Duration::from_secs(30), "{wait:?}");
2003 fresh();
2004 }
2005
2006 #[test]
2010 fn failed_warmup_backs_off_briefly_and_success_backs_off_long() {
2011 let _guard = muse::test_cache_guard();
2012 for err in [
2013 BrokerError::Transport("down".into()),
2014 BrokerError::NotConfigured("muse".into()),
2015 ] {
2016 fresh();
2017 run("muse/muse-warmup-probe-zz7", &broker(Err(err)));
2018 let wait = muse_warmup_retry_in().expect("backed off");
2019 assert!(wait <= std::time::Duration::from_secs(30), "{wait:?}");
2020 assert!(wait >= std::time::Duration::from_secs(10), "{wait:?}");
2021 }
2022 fresh();
2024 run("muse/muse-warmup-probe-zz7", &broker(Ok("not json")));
2025 assert!(muse_warmup_retry_in().unwrap() <= std::time::Duration::from_secs(30));
2026
2027 fresh();
2028 run("muse/not-in-live-catalog-zz8", &broker(Ok(CATALOG)));
2029 let wait = muse_warmup_retry_in().expect("backed off");
2030 assert!(wait > std::time::Duration::from_secs(240), "{wait:?}");
2031 fresh();
2032 }
2033}