Skip to main content

agent_engine/runtime/openai/catalog/
mod.rs

1//! Normalized model catalog types and provider-specific parsers.
2//!
3//! This module is intentionally parser-first: unit tests exercise static JSON
4//! fixtures only. Live network fetches are thin wrappers around these parsers.
5//!
6//! ## Provider research notes (spec appendix)
7//!
8//! **OpenRouter** `GET https://openrouter.ai/api/v1/models` — no auth required.
9//! Metadata: id, name, context_length, supported_parameters, pricing, top_provider,
10//! architecture.input_modalities. Reasoning detected from supported_parameters:
11//!   - "reasoning"/"include_reasoning" => OpenRouter reasoning request
12//!   - "reasoning_effort"              => effort-style (o-series via OR)
13//!   - "verbosity"                     => Anthropic-style through OR
14//!   - pricing.internal_reasoning      => Gemini thinking-token pricing
15//!
16//! **Groq** `GET https://api.groq.com/openai/v1/models` — Bearer auth.
17//! Fields: id, active, context_window, owned_by. No reasoning in wire.
18//!
19//! **NVIDIA NIM** `GET https://integrate.api.nvidia.com/v1/models` — no auth for list.
20//! Minimal: id, object, created, owned_by. Thinking via system-prompt injection.
21//!
22//! **Anthropic** `GET https://api.anthropic.com/v1/models` — paginated, Bearer/x-api-key.
23//! Optional capabilities.thinking / capabilities.effort.
24
25use std::future::Future;
26use std::pin::Pin;
27use std::time::Duration;
28
29pub const CATALOG_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
30const ANTHROPIC_MODELS_MAX_PAGES: usize = 20;
31
32mod anthropic;
33pub mod capability_cache;
34mod codex;
35mod generic;
36mod github_copilot;
37mod google_gemini;
38mod groq;
39pub mod kimi;
40mod nvidia;
41mod openrouter;
42pub mod validation;
43mod xai;
44
45pub use anthropic::{
46    anthropic_mode_capabilities, anthropic_models_url, anthropic_static_capability,
47    merge_catalog_pages, parse_anthropic_catalog_models, parse_anthropic_catalog_page,
48    plan_anthropic_execution, plan_standard_anthropic_transport, AnthropicCatalogPage,
49    AnthropicExecutionMode, AnthropicExecutionPlan, AnthropicPlanError, AnthropicPlanErrorCode,
50    AnthropicPlanPrerequisites, AnthropicWireEffort, AnthropicWorkflowPlan,
51};
52pub use codex::{
53    codex_models_path, codex_models_url, codex_static_capability, codex_static_catalog_models,
54    parse_codex_catalog_models, plan_codex_execution, validate_codex_level, CodexCapabilitySource,
55    CodexExecutionMode, CodexExecutionPlan, CodexMultiAgentMode, CodexPlanError,
56    CodexPlanErrorCode, CodexRequestRole, CodexWireEffort, ExecutionRole,
57    PROVIDER_KEY as CODEX_PROVIDER_KEY, PROVIDER_NAME as CODEX_PROVIDER_NAME,
58};
59pub use generic::parse_generic_catalog_models;
60pub use github_copilot::{
61    copilot_model, copilot_static_catalog_models, models_request_headers,
62    parse_copilot_catalog_entries, parse_copilot_catalog_models,
63    preferred_wire_protocol_from_endpoints, runtime_wire_protocol as github_copilot_runtime_model,
64    selectable_copilot_entries, validate_models_endpoint, CopilotCatalogEntry, CopilotEndpoint,
65    CopilotModelDescriptor, CopilotPolicyState, CopilotWire, COPILOT_API_VERSION,
66    COPILOT_FALLBACK_MODELS, MAX_MODELS_BODY_BYTES, MODELS_BASE_URL, MODELS_PATH, MODELS_URL,
67    PROVIDER_KEY as COPILOT_PROVIDER_KEY, PROVIDER_NAME as COPILOT_PROVIDER_NAME,
68};
69pub use google_gemini::{
70    google_gemini_model, google_gemini_static_catalog_models, GoogleGeminiModelDescriptor,
71    GOOGLE_GEMINI_TEXT_MODELS, PROVIDER_KEY as GOOGLE_GEMINI_PROVIDER_KEY,
72    PROVIDER_NAME as GOOGLE_GEMINI_PROVIDER_NAME,
73};
74pub use groq::{infer_groq_reasoning, parse_groq_catalog_models};
75pub use kimi::{apply_kimi_reasoning_params, kimi_static_capability, KimiReasoningCapability};
76pub use nvidia::{infer_nvidia_reasoning, parse_nvidia_catalog_models};
77pub use openrouter::parse_openrouter_catalog_models;
78pub use xai::{
79    xai_model, xai_static_capability, xai_static_catalog_models, XaiModelDescriptor,
80    XaiReasoningCapability, XAI_TEXT_MODELS,
81};
82
83// ─── Modality ────────────────────────────────────────────────────────────────
84
85/// Input/output modality.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum Modality {
88    Text,
89    Image,
90    Audio,
91    Video,
92    File,
93    Other(String),
94}
95
96impl Modality {
97    #[allow(clippy::should_implement_trait)]
98    pub fn from_str(s: &str) -> Self {
99        match s {
100            "text" => Modality::Text,
101            "image" => Modality::Image,
102            "audio" => Modality::Audio,
103            "video" => Modality::Video,
104            "file" => Modality::File,
105            other => Modality::Other(other.to_string()),
106        }
107    }
108}
109
110// ─── PricingSummary ───────────────────────────────────────────────────────────
111
112/// Pricing metadata. Stored as decimal-string USD/token as returned by OpenRouter.
113#[derive(Debug, Clone, PartialEq, Eq, Default)]
114pub struct PricingSummary {
115    /// USD per prompt token, decimal string.
116    pub prompt: Option<String>,
117    /// USD per completion token, decimal string.
118    pub completion: Option<String>,
119    /// Separate Gemini internal-reasoning token cost (OpenRouter).
120    pub internal_reasoning: Option<String>,
121}
122
123impl PricingSummary {
124    /// True when a non-zero internal_reasoning price is present.
125    pub fn has_internal_reasoning_cost(&self) -> bool {
126        self.internal_reasoning
127            .as_deref()
128            .map(|s| s != "0" && !s.trim().is_empty())
129            .unwrap_or(false)
130    }
131}
132
133// ─── ReasoningSupport ─────────────────────────────────────────────────────────
134
135/// Multi-agent protocol version advertised by the exact OpenAI Codex model.
136///
137/// Unknown server strings are retained only as this sanitized sentinel; raw
138/// catalog values never enter diagnostics or authorization decisions.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum CodexMultiAgentVersion {
141    V1,
142    V2,
143    Unknown,
144}
145
146impl CodexMultiAgentVersion {
147    pub fn as_str(self) -> &'static str {
148        match self {
149            Self::V1 => "v1",
150            Self::V2 => "v2",
151            Self::Unknown => "unknown",
152        }
153    }
154}
155
156/// Normalized reasoning/thinking capability for a model.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum ReasoningSupport {
159    /// No reasoning/thinking support confirmed.
160    None,
161    /// Anthropic adaptive: thinking:{type:"adaptive"} ± effort param.
162    AnthropicAdaptive { adaptive: bool },
163    /// OpenRouter: reasoning/include_reasoning/reasoning_effort/verbosity params.
164    OpenRouter {
165        include_reasoning: bool,
166        effort: bool,
167        verbosity: bool,
168        internal_reasoning_priced: bool,
169    },
170    /// Groq family-based reasoning (reasoning_format/reasoning_effort).
171    GroqReasoning,
172    /// NVIDIA inline thinking via system-prompt; <think> in content.
173    NvidiaInlineThinking,
174    /// Generic OpenAI-compatible (capability unknown).
175    GenericOpenAi,
176    /// OpenAI Codex (ChatGPT OAuth): named effort levels, exact set from catalog.
177    CodexNamed {
178        /// Ordered list of supported named reasoning effort strings from the
179        /// live catalog's `supported_reasoning_levels[].effort` field.
180        supported: Vec<agent_core::reasoning::ReasoningLevel>,
181        /// Default level from catalog's `default_reasoning_level`, if present.
182        default_level: Option<agent_core::reasoning::ReasoningLevel>,
183        /// Exact model's collaboration protocol. Ultra requires V2.
184        multi_agent_version: Option<CodexMultiAgentVersion>,
185    },
186    /// Not yet classified.
187    Unknown,
188}
189
190// ─── CatalogSource ────────────────────────────────────────────────────────────
191
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub enum CatalogSource {
194    /// From a live provider API call.
195    Live,
196    /// Bundled static/seed data.
197    StaticFallback,
198    /// Static seed enriched with live fields.
199    StaticWithLive,
200    /// Capability inferred heuristically.
201    Inferred,
202}
203
204// ─── CatalogProviderKind ──────────────────────────────────────────────────────
205
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub enum CatalogProviderKind {
208    Anthropic,
209    OpenRouter,
210    Groq,
211    NvidiaNim,
212    OpenAiCodex,
213    Generic { key: String },
214    Local,
215}
216
217// ─── CatalogModel ─────────────────────────────────────────────────────────────
218
219/// Normalized model catalog entry. Every provider handler produces these.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct CatalogModel {
222    /// Provider key (e.g. "openrouter", "groq").
223    pub provider_key: String,
224    /// Human-readable provider name.
225    pub provider_name: String,
226    /// Provider kind for routing/capability dispatch.
227    pub provider_kind: CatalogProviderKind,
228    /// Model id as used in API requests (no provider prefix).
229    pub id: String,
230    /// Human-readable label.
231    pub label: Option<String>,
232    /// Input context window in tokens.
233    pub context_tokens: Option<u64>,
234    /// Maximum output tokens.
235    pub max_output_tokens: Option<u64>,
236    /// Input modalities.
237    pub input_modalities: Vec<Modality>,
238    /// Pricing summary.
239    pub pricing: PricingSummary,
240    /// Reasoning/thinking capability.
241    pub reasoning: ReasoningSupport,
242    /// Data provenance.
243    pub source: CatalogSource,
244}
245
246impl CatalogModel {
247    /// Construct a minimal entry, returning `None` if the id is blank.
248    pub fn new(
249        provider_key: impl Into<String>,
250        provider_name: impl Into<String>,
251        id: impl Into<String>,
252    ) -> Option<Self> {
253        let id = id.into();
254        if id.trim().is_empty() {
255            return None;
256        }
257        let pk = provider_key.into();
258        Some(Self {
259            provider_kind: CatalogProviderKind::Generic { key: pk.clone() },
260            provider_name: provider_name.into(),
261            provider_key: pk,
262            id,
263            label: None,
264            context_tokens: None,
265            max_output_tokens: None,
266            input_modalities: vec![Modality::Text],
267            pricing: PricingSummary::default(),
268            reasoning: ReasoningSupport::Unknown,
269            source: CatalogSource::Live,
270        })
271    }
272
273    /// Synaps runtime id. Provider identity is always explicit, including Anthropic.
274    pub fn runtime_id(&self) -> String {
275        format!("{}/{}", self.provider_key, self.id)
276    }
277
278    /// Label if present, id otherwise.
279    pub fn display_label(&self) -> &str {
280        self.label.as_deref().unwrap_or(&self.id)
281    }
282
283    /// For Codex models: returns the exact ordered set of supported named levels,
284    /// or `None` if this model has no Codex-named capability data.
285    pub fn codex_supported_levels(&self) -> Option<&[agent_core::reasoning::ReasoningLevel]> {
286        match &self.reasoning {
287            ReasoningSupport::CodexNamed { supported, .. } => Some(supported),
288            _ => None,
289        }
290    }
291
292    /// Exact Codex collaboration protocol, if the authoritative capability
293    /// record advertised one.
294    pub fn codex_multi_agent_version(&self) -> Option<CodexMultiAgentVersion> {
295        match &self.reasoning {
296            ReasoningSupport::CodexNamed {
297                multi_agent_version,
298                ..
299            } => *multi_agent_version,
300            _ => None,
301        }
302    }
303
304    /// For Codex models: true iff the given level is in the supported set.
305    /// Always returns false for models without Codex-named capability data.
306    pub fn codex_supports_level(&self, level: agent_core::reasoning::ReasoningLevel) -> bool {
307        self.codex_supported_levels()
308            .is_some_and(|levels| levels.contains(&level))
309    }
310}
311
312// ─── Static seed helper ───────────────────────────────────────────────────────
313
314/// Build a static-fallback CatalogModel from a (id, label) pair.
315pub fn from_static_seed(
316    provider_key: &str,
317    provider_name: &str,
318    id: &str,
319    label: &str,
320) -> Option<CatalogModel> {
321    let mut m = CatalogModel::new(provider_key, provider_name, id)?;
322    m.label = if label.trim().is_empty() {
323        None
324    } else {
325        Some(label.to_string())
326    };
327    m.source = CatalogSource::StaticFallback;
328    m.reasoning = ReasoningSupport::Unknown;
329    Some(m)
330}
331
332/// Convert all static seeds in a ProviderSpec to CatalogModel entries.
333pub fn static_seeds_from_spec(spec: &super::registry::ProviderSpec) -> Vec<CatalogModel> {
334    spec.models
335        .iter()
336        .filter_map(|(id, label, _tier)| from_static_seed(spec.key, spec.name, id, label))
337        .collect()
338}
339
340// ─── Live fetch helpers ───────────────────────────────────────────────────────
341
342pub trait ModelCatalogProvider: Sync {
343    fn provider_key(&self) -> &'static str;
344
345    fn fetch<'a>(
346        &'a self,
347        client: &'a reqwest::Client,
348    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>>;
349}
350
351pub struct OpenRouterCatalogProvider;
352pub struct GroqCatalogProvider;
353pub struct NvidiaCatalogProvider;
354pub struct AnthropicCatalogProvider;
355pub struct CodexCatalogProvider;
356pub struct XaiCatalogProvider;
357pub struct GitHubCopilotCatalogProvider;
358pub struct GoogleGeminiCatalogProvider;
359pub struct GenericCatalogProvider;
360
361pub fn catalog_provider_for(provider_key: &str) -> &'static dyn ModelCatalogProvider {
362    match provider_key {
363        "openrouter" => &OpenRouterCatalogProvider,
364        "groq" => &GroqCatalogProvider,
365        "nvidia" => &NvidiaCatalogProvider,
366        "claude" | "anthropic" => &AnthropicCatalogProvider,
367        "openai-codex" => &CodexCatalogProvider,
368        "xai-auth" => &XaiCatalogProvider,
369        "github-copilot" => &GitHubCopilotCatalogProvider,
370        "google-gemini" => &GoogleGeminiCatalogProvider,
371        _ => &GenericCatalogProvider,
372    }
373}
374
375fn catalog_get(client: &reqwest::Client, url: &str) -> reqwest::RequestBuilder {
376    client.get(url).timeout(CATALOG_REQUEST_TIMEOUT)
377}
378
379async fn read_catalog_response(resp: reqwest::Response) -> Result<String, String> {
380    let status = resp.status();
381    let body = resp.text().await.map_err(|e| format!("read failed: {e}"))?;
382    if !status.is_success() {
383        return Err(format!("model list failed: HTTP {status}"));
384    }
385    Ok(body)
386}
387
388async fn fetch_anthropic_catalog_models(
389    _client: &reqwest::Client,
390) -> Result<Vec<CatalogModel>, String> {
391    // Paginated live discovery through the broker-owned Anthropic credential.
392    // Tokens never enter this module — each page is a allowlisted ProxyRequest
393    // for `/v1/models` (+ limit/after_id query).
394    let mut pages = Vec::new();
395    let mut after_id: Option<String> = None;
396
397    for _ in 0..ANTHROPIC_MODELS_MAX_PAGES {
398        let path = anthropic_models_proxy_path(after_id.as_deref());
399        let body = broker_proxy_catalog_body("anthropic", &path).await?;
400        let page = parse_anthropic_catalog_page(&body).map_err(|e| format!("parse failed: {e}"))?;
401        let next_after_id = page.last_id.clone();
402        let has_more = page.has_more && next_after_id.is_some();
403        pages.push(page.models);
404        if !has_more {
405            return Ok(merge_catalog_pages(pages));
406        }
407        after_id = next_after_id;
408    }
409
410    Ok(merge_catalog_pages(pages))
411}
412
413/// Relative Anthropic models path for the broker allowlist (no host).
414pub fn anthropic_models_proxy_path(after_id: Option<&str>) -> String {
415    // Keep query shape identical to `anthropic_models_url` without the host.
416    let absolute = anthropic_models_url(after_id);
417    absolute
418        .strip_prefix("https://api.anthropic.com")
419        .unwrap_or(&absolute)
420        .to_string()
421}
422
423impl ModelCatalogProvider for OpenRouterCatalogProvider {
424    fn provider_key(&self) -> &'static str {
425        "openrouter"
426    }
427
428    fn fetch<'a>(
429        &'a self,
430        client: &'a reqwest::Client,
431    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
432        Box::pin(async move { fetch_openrouter_catalog_models(client).await })
433    }
434}
435
436impl ModelCatalogProvider for GroqCatalogProvider {
437    fn provider_key(&self) -> &'static str {
438        "groq"
439    }
440
441    fn fetch<'a>(
442        &'a self,
443        _client: &'a reqwest::Client,
444    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
445        Box::pin(async move {
446            let body = broker_catalog_models_body("groq").await?;
447            parse_groq_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
448        })
449    }
450}
451
452impl ModelCatalogProvider for NvidiaCatalogProvider {
453    fn provider_key(&self) -> &'static str {
454        "nvidia"
455    }
456
457    fn fetch<'a>(
458        &'a self,
459        client: &'a reqwest::Client,
460    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
461        Box::pin(async move {
462            let resp = catalog_get(client, "https://integrate.api.nvidia.com/v1/models")
463                .send()
464                .await
465                .map_err(|e| format!("request failed: {e}"))?;
466            let body = read_catalog_response(resp).await?;
467            parse_nvidia_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
468        })
469    }
470}
471
472impl ModelCatalogProvider for AnthropicCatalogProvider {
473    fn provider_key(&self) -> &'static str {
474        "claude"
475    }
476
477    fn fetch<'a>(
478        &'a self,
479        client: &'a reqwest::Client,
480    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
481        Box::pin(async move { fetch_anthropic_catalog_models(client).await })
482    }
483}
484
485/// True when a broker catalog error means "no credential / not logged in",
486/// so static seeds are an acceptable offline fallback.
487///
488/// Transport, HTTP status, parse, denial, and credential-shape failures must
489/// NOT fall through to static seeds — they are real errors.
490pub fn is_missing_credential_catalog_error(err: &str) -> bool {
491    // Prefer structured BrokerError Display forms when present.
492    // Note: broker_proxy_catalog_body wraps broker errors as
493    // `request failed: {BrokerError}` — so "request failed" alone is NOT
494    // a transport signal.
495    let lower = err.to_lowercase();
496
497    // Hard excludes: real operational failures must never fall back to seeds.
498    if lower.contains("model list failed")
499        || lower.contains("parse failed")
500        || lower.contains("broker denied")
501        || lower.contains("broker transport error")
502        || lower.contains("missing chatgpt account id")
503        || lower.contains("http 4")
504        || lower.contains("http 5")
505        || lower.contains("connection reset")
506        || lower.contains("timed out")
507        || lower.contains("timeout")
508    {
509        return false;
510    }
511
512    // Stable BrokerError::Credential prefix from token.rs load-miss:
513    // "credential error: No credentials for {provider} at {path}. Run `synaps login`."
514    // Match only the stable prefix so account-shape / other Credential variants
515    // (e.g. missing chatgpt account id) remain hard errors above.
516    lower.contains("credential error: no credentials for ")
517        || lower.contains("no credential configured")
518        || lower.contains("unknown provider:")
519        || lower.contains("not logged")
520        || lower.contains("registration required")
521}
522
523impl ModelCatalogProvider for CodexCatalogProvider {
524    fn provider_key(&self) -> &'static str {
525        "openai-codex"
526    }
527
528    fn fetch<'a>(
529        &'a self,
530        _client: &'a reqwest::Client,
531    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
532        // Prefer broker-proxied live discovery against the ChatGPT backend
533        // models endpoint. Static seeds are offline / not-configured fallback
534        // only — never the normal successful result when auth is available.
535        Box::pin(async move {
536            let path = codex_models_path(env!("CARGO_PKG_VERSION"));
537            match broker_proxy_catalog_body("openai-codex", &path).await {
538                Ok(body) => {
539                    let models = parse_codex_catalog_models(&body)
540                        .map_err(|e| format!("parse failed: {e}"))?;
541                    capability_cache::replace_provider(self.provider_key(), &models);
542                    Ok(models)
543                }
544                // Typed decision (fix1 I2a): only a no-live-session outcome
545                // falls back to static seeds; operational failures surface.
546                Err(CatalogBrokerError::NotAuthenticated(_)) => Ok(codex_static_catalog_models()),
547                Err(CatalogBrokerError::Failed(err)) => Err(err),
548            }
549        })
550    }
551}
552
553impl ModelCatalogProvider for XaiCatalogProvider {
554    fn provider_key(&self) -> &'static str {
555        "xai-auth"
556    }
557
558    fn fetch<'a>(
559        &'a self,
560        _client: &'a reqwest::Client,
561    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
562        Box::pin(async move { Ok(xai_static_catalog_models()) })
563    }
564}
565
566impl ModelCatalogProvider for GitHubCopilotCatalogProvider {
567    fn provider_key(&self) -> &'static str {
568        "github-copilot"
569    }
570
571    fn fetch<'a>(
572        &'a self,
573        _client: &'a reqwest::Client,
574    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
575        // Prefer broker-proxied live discovery (session token never enters this
576        // module as a caller-supplied secret). Fall back to curated static seeds
577        // exactly when the TYPED broker outcome says there is no live session
578        // (fix1 I2a) — deterministic for every ambient credential state.
579        Box::pin(async move {
580            match broker_catalog_models_body("github-copilot").await {
581                Ok(body) => {
582                    parse_copilot_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
583                }
584                Err(CatalogBrokerError::NotAuthenticated(_)) => Ok(copilot_static_catalog_models()),
585                Err(CatalogBrokerError::Failed(err)) => Err(err),
586            }
587        })
588    }
589}
590
591impl ModelCatalogProvider for GoogleGeminiCatalogProvider {
592    fn provider_key(&self) -> &'static str {
593        "google-gemini"
594    }
595
596    fn fetch<'a>(
597        &'a self,
598        _client: &'a reqwest::Client,
599    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
600        // The Code Assist model-discovery surface is not documented as a
601        // stable third-party API and there is no reviewed live-listing method
602        // in the broker allowlist yet. Fail closed to the conservative static
603        // catalog: text + tool-capable IDs whose provenance is the official
604        // Gemini CLI reference source.
605        Box::pin(async move { Ok(google_gemini_static_catalog_models()) })
606    }
607}
608
609impl ModelCatalogProvider for GenericCatalogProvider {
610    fn provider_key(&self) -> &'static str {
611        "generic"
612    }
613
614    fn fetch<'a>(
615        &'a self,
616        _client: &'a reqwest::Client,
617    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
618        Box::pin(async move {
619            Err("generic catalog fetch requires provider key; use fetch_generic_catalog_provider_models".to_string())
620        })
621    }
622}
623
624async fn fetch_generic_catalog_provider_models(
625    provider_key: &str,
626) -> Result<Vec<CatalogModel>, String> {
627    let specs = super::registry::providers();
628    let spec = specs
629        .iter()
630        .find(|s| s.key == provider_key)
631        .ok_or_else(|| format!("unknown provider: {provider_key}"))?;
632    if !crate::auth::broker::static_key_configured(provider_key) {
633        return Err(format!("{} is not configured", spec.name));
634    }
635    let body = broker_catalog_models_body(provider_key).await?;
636    parse_generic_catalog_models(&body, provider_key, spec.name)
637        .map_err(|e| format!("parse failed: {e}"))
638}
639
640/// GET `/models` through the credential broker (the key never enters this
641/// module). Returns the body on 2xx, a typed failure otherwise.
642async fn broker_catalog_models_body(provider_key: &str) -> Result<String, CatalogBrokerError> {
643    broker_proxy_catalog_body(provider_key, "/models").await
644}
645
646/// Typed broker catalog failure (fix1 I2a): the static-fallback decision is
647/// made on TYPED broker variants, never by probing Display strings after
648/// the fact — so the outcome is deterministic for every credential state.
649#[derive(Debug, Clone, PartialEq, Eq)]
650pub enum CatalogBrokerError {
651    /// No usable live session for the provider (not configured, unknown to
652    /// the registries, registration required, or a stored-credential miss).
653    /// Providers with curated seeds fall back to them.
654    NotAuthenticated(String),
655    /// A real operational failure (transport, denial, HTTP status,
656    /// account-shape credential errors): fail closed.
657    Failed(String),
658}
659
660impl CatalogBrokerError {
661    fn message(&self) -> &str {
662        match self {
663            Self::NotAuthenticated(msg) | Self::Failed(msg) => msg,
664        }
665    }
666}
667
668impl From<CatalogBrokerError> for String {
669    fn from(err: CatalogBrokerError) -> Self {
670        err.message().to_string()
671    }
672}
673
674/// Classify a typed [`BrokerError`] for catalog fetching. The one string
675/// dependency left is the stable token-store load-miss prefix inside
676/// `BrokerError::Credential` ("No credentials for …"), pinned by a unit
677/// test — account-shape credential errors stay hard failures.
678fn classify_catalog_broker_error(err: agent_core::auth::BrokerError) -> CatalogBrokerError {
679    use agent_core::auth::BrokerError as B;
680    let msg = format!("request failed: {err}");
681    match &err {
682        B::NotConfigured(_) | B::UnknownProvider(_) | B::RegistrationRequired { .. } => {
683            CatalogBrokerError::NotAuthenticated(msg)
684        }
685        B::Credential(detail) if detail.starts_with("No credentials for ") => {
686            CatalogBrokerError::NotAuthenticated(msg)
687        }
688        _ => CatalogBrokerError::Failed(msg),
689    }
690}
691
692/// GET an allowlisted catalog path through the credential broker.
693/// The credential never enters this module — only the provider key + path.
694async fn broker_proxy_catalog_body(
695    provider_key: &str,
696    path: &str,
697) -> Result<String, CatalogBrokerError> {
698    let resp = crate::auth::broker::global_broker()
699        .proxy(crate::auth::ProxyRequest {
700            provider: provider_key.to_string(),
701            method: crate::auth::ProxyMethod::Get,
702            path: path.to_string(),
703            body: None,
704            stream: false,
705            body_bytes: None,
706        })
707        .await
708        .map_err(classify_catalog_broker_error)?;
709    if !(200..300).contains(&resp.status) {
710        return Err(CatalogBrokerError::Failed(format!(
711            "model list failed: HTTP {}",
712            resp.status
713        )));
714    }
715    Ok(resp.body)
716}
717
718/// Fetch the OpenRouter live model list. Auth not required.
719pub async fn fetch_openrouter_catalog_models(
720    client: &reqwest::Client,
721) -> Result<Vec<CatalogModel>, String> {
722    let resp = catalog_get(client, "https://openrouter.ai/api/v1/models")
723        .send()
724        .await
725        .map_err(|e| format!("request failed: {e}"))?;
726    let body = read_catalog_response(resp).await?;
727    parse_openrouter_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
728}
729
730/// Fetch catalog models for any registered provider.
731/// OpenRouter uses its rich parser; all others use the generic parser.
732/// Compatible shim: callers that previously used `registry::fetch_provider_models`
733/// and then mapped to `ExpandedModelEntry` can switch to this.
734pub async fn fetch_catalog_models(
735    client: &reqwest::Client,
736    provider_key: &str,
737) -> Result<Vec<CatalogModel>, String> {
738    let provider = catalog_provider_for(provider_key);
739    if provider.provider_key() == "generic" {
740        return fetch_generic_catalog_provider_models(provider_key).await;
741    }
742    provider.fetch(client).await
743}
744
745// ─── Tests ────────────────────────────────────────────────────────────────────
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    // ── Task 1: Normalized catalog contract ──────────────────────────────────
752
753    #[test]
754    fn catalog_model_rejects_empty_ids() {
755        assert!(CatalogModel::new("openrouter", "OpenRouter", "").is_none());
756        assert!(CatalogModel::new("openrouter", "OpenRouter", "   ").is_none());
757    }
758
759    #[test]
760    fn static_seed_sets_fallback_source_and_runtime_id() {
761        let m = from_static_seed("groq", "Groq", "llama-3.3-70b-versatile", "Llama 3.3 70B")
762            .expect("valid seed");
763        assert_eq!(m.runtime_id(), "groq/llama-3.3-70b-versatile");
764        assert_eq!(m.display_label(), "Llama 3.3 70B");
765        assert_eq!(m.source, CatalogSource::StaticFallback);
766    }
767
768    #[test]
769    fn static_seed_empty_label_stores_none() {
770        let m = from_static_seed("groq", "Groq", "model-x", "").expect("valid id");
771        assert_eq!(m.label, None);
772    }
773
774    #[test]
775    fn static_seed_whitespace_label_stores_none() {
776        let m = from_static_seed("groq", "Groq", "model-x", "   ").expect("valid id");
777        assert_eq!(m.label, None);
778    }
779
780    #[test]
781    fn static_seeds_from_spec_converts_all_groq_models() {
782        let spec = super::super::registry::providers()
783            .iter()
784            .find(|s| s.key == "groq")
785            .expect("groq spec");
786        let seeds = static_seeds_from_spec(spec);
787        assert_eq!(seeds.len(), spec.models.len());
788        assert!(seeds
789            .iter()
790            .all(|m| m.source == CatalogSource::StaticFallback));
791        assert!(seeds.iter().all(|m| !m.id.is_empty()));
792        assert!(seeds.iter().all(|m| m.runtime_id().starts_with("groq/")));
793    }
794
795    #[test]
796    fn anthropic_runtime_id_is_provider_qualified() {
797        let mut m = CatalogModel::new("anthropic", "Anthropic", "claude-opus-4-7").unwrap();
798        m.provider_kind = CatalogProviderKind::Anthropic;
799        assert_eq!(m.runtime_id(), "anthropic/claude-opus-4-7");
800    }
801
802    #[test]
803    fn pricing_summary_has_internal_reasoning_cost_zero_is_false() {
804        let p = PricingSummary {
805            prompt: None,
806            completion: None,
807            internal_reasoning: Some("0".to_string()),
808        };
809        assert!(!p.has_internal_reasoning_cost());
810    }
811
812    #[test]
813    fn pricing_summary_has_internal_reasoning_cost_nonzero_is_true() {
814        let p = PricingSummary {
815            prompt: None,
816            completion: None,
817            internal_reasoning: Some("0.0000035".to_string()),
818        };
819        assert!(p.has_internal_reasoning_cost());
820    }
821
822    #[tokio::test]
823    async fn ui_catalog_fetch_includes_xai_static_models() {
824        let models = fetch_catalog_models(&reqwest::Client::new(), "xai-auth")
825            .await
826            .expect("static xAI catalog");
827        assert_eq!(models, xai_static_catalog_models());
828        assert!(models
829            .iter()
830            .all(|model| model.runtime_id().starts_with("xai-auth/")));
831    }
832
833    #[tokio::test]
834    // Broker-proxied discovery reads ambient credentials via base-dir
835    // resolution; racing SYNAPS_BASE_DIR mutators made this flaky.
836    #[serial_test::serial(synaps_base_dir)]
837    async fn ui_catalog_fetch_github_copilot_returns_prefixed_chat_models() {
838        // When the operator has a live session, broker-proxied discovery wins.
839        // Otherwise the curated static fallback is returned. Either way runtime
840        // ids are github-copilot/<wire-id> and include fixture-established IDs.
841        let models = fetch_catalog_models(&reqwest::Client::new(), "github-copilot")
842            .await
843            .expect("GitHub Copilot catalog");
844        assert!(!models.is_empty());
845        assert!(models
846            .iter()
847            .all(|model| model.runtime_id().starts_with("github-copilot/")));
848        let ids: std::collections::HashSet<_> = models.iter().map(|m| m.id.as_str()).collect();
849        // High-value ids from the curated set must be present for this account
850        // or via static fallback.
851        for required in ["gpt-5.3-codex", "claude-sonnet-4.6", "gemini-3.5-flash"] {
852            assert!(ids.contains(required), "missing {required}");
853        }
854        assert!(!ids.contains("text-embedding-3-small"));
855    }
856
857    /// fix1 I2a: with NO credentials anywhere (fresh empty base dir), the
858    /// documented contract — "otherwise the curated static fallback is
859    /// returned" — must hold DETERMINISTICALLY. The broker's typed
860    /// credential-miss must map to the static seeds, never to an error.
861    #[tokio::test]
862    #[serial_test::serial(synaps_base_dir)]
863    async fn copilot_catalog_without_credentials_is_deterministic_static_fallback() {
864        let _base = crate::test_env::BaseDirGuard::new();
865        let models = fetch_catalog_models(&reqwest::Client::new(), "github-copilot")
866            .await
867            .expect("credential-less fetch must fall back to static seeds");
868        assert_eq!(models, copilot_static_catalog_models());
869        assert!(models
870            .iter()
871            .all(|m| m.runtime_id().starts_with("github-copilot/")));
872    }
873
874    /// fix1 I2a: the fetch outcome must not depend on ambient
875    /// SYNAPS_BASE_DIR churn. A current-thread churn task flips the base
876    /// dir between two credential-less roots at every await point while
877    /// fetches run — every fetch must return the same static catalog.
878    #[tokio::test]
879    #[serial_test::serial(synaps_base_dir)]
880    async fn copilot_catalog_is_deterministic_under_concurrent_base_dir_churn() {
881        let base_a = tempfile::TempDir::new().unwrap();
882        let base_b = tempfile::TempDir::new().unwrap();
883        let old = std::env::var("SYNAPS_BASE_DIR").ok();
884        std::env::set_var("SYNAPS_BASE_DIR", base_a.path());
885
886        let local = tokio::task::LocalSet::new();
887        local
888            .run_until(async {
889                let churn = {
890                    let a = base_a.path().to_path_buf();
891                    let b = base_b.path().to_path_buf();
892                    // Same-thread churn task: env flips interleave at await
893                    // points only (no cross-thread set_var races).
894                    tokio::task::spawn_local(async move {
895                        loop {
896                            std::env::set_var("SYNAPS_BASE_DIR", &a);
897                            tokio::task::yield_now().await;
898                            std::env::set_var("SYNAPS_BASE_DIR", &b);
899                            tokio::task::yield_now().await;
900                        }
901                    })
902                };
903                for _ in 0..20 {
904                    let models = fetch_catalog_models(&reqwest::Client::new(), "github-copilot")
905                        .await
906                        .expect("fetch must be deterministic under base-dir churn");
907                    assert_eq!(models, copilot_static_catalog_models());
908                    tokio::task::yield_now().await;
909                }
910                churn.abort();
911            })
912            .await;
913
914        match old {
915            Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
916            None => std::env::remove_var("SYNAPS_BASE_DIR"),
917        }
918    }
919
920    #[test]
921    fn github_copilot_static_catalog_is_available_without_network() {
922        let models = copilot_static_catalog_models();
923        assert_eq!(models.len(), COPILOT_FALLBACK_MODELS.len());
924        assert!(models
925            .iter()
926            .all(|m| m.source == CatalogSource::StaticFallback));
927    }
928
929    #[test]
930    fn catalog_provider_trait_dispatch_selects_specialized_handlers() {
931        assert_eq!(
932            catalog_provider_for("openrouter").provider_key(),
933            "openrouter"
934        );
935        assert_eq!(catalog_provider_for("groq").provider_key(), "groq");
936        assert_eq!(catalog_provider_for("nvidia").provider_key(), "nvidia");
937        assert_eq!(catalog_provider_for("claude").provider_key(), "claude");
938        assert_eq!(
939            catalog_provider_for("openai-codex").provider_key(),
940            "openai-codex"
941        );
942        assert_eq!(catalog_provider_for("xai-auth").provider_key(), "xai-auth");
943        assert_eq!(
944            catalog_provider_for("github-copilot").provider_key(),
945            "github-copilot"
946        );
947        assert_eq!(catalog_provider_for("cerebras").provider_key(), "generic");
948    }
949
950    #[test]
951    fn catalog_request_timeout_is_bounded() {
952        assert_eq!(CATALOG_REQUEST_TIMEOUT, Duration::from_secs(15));
953    }
954
955    #[test]
956    fn anthropic_page_metadata_is_exposed_for_pagination() {
957        let page = parse_anthropic_catalog_page(
958            r#"{
959            "data":[{"id":"claude-opus-4-7"}],
960            "has_more": true,
961            "last_id": "claude-opus-4-7"
962        }"#,
963        )
964        .expect("parse page");
965        assert!(page.has_more);
966        assert_eq!(page.last_id.as_deref(), Some("claude-opus-4-7"));
967        assert_eq!(page.models.len(), 1);
968    }
969
970    #[test]
971    fn anthropic_pagination_url_adds_after_id_cursor() {
972        assert_eq!(
973            anthropic_models_url(None),
974            "https://api.anthropic.com/v1/models?limit=100"
975        );
976        assert_eq!(
977            anthropic_models_url(Some("claude-opus-4-7")),
978            "https://api.anthropic.com/v1/models?limit=100&after_id=claude-opus-4-7"
979        );
980    }
981
982    #[test]
983    fn merge_catalog_pages_dedupes_by_id() {
984        let first =
985            parse_anthropic_catalog_models(r#"{"data":[{"id":"claude-opus-4-7"}]}"#).unwrap();
986        let second = parse_anthropic_catalog_models(
987            r#"{"data":[{"id":"claude-opus-4-7"},{"id":"claude-sonnet-4-6"}]}"#,
988        )
989        .unwrap();
990        let merged = merge_catalog_pages(vec![first, second]);
991        assert_eq!(merged.len(), 2);
992        assert_eq!(merged[0].id, "claude-opus-4-7");
993        assert_eq!(merged[1].id, "claude-sonnet-4-6");
994    }
995
996    // ── Task 2: OpenRouter rich catalog handler ───────────────────────────────
997
998    mod openrouter {
999        use super::super::*;
1000
1001        const RICH_FIXTURE: &str = r#"{
1002          "data": [
1003            {
1004              "id": "qwen/qwen3-coder",
1005              "name": "Qwen: Qwen3 Coder",
1006              "context_length": 131072,
1007              "top_provider": { "max_completion_tokens": 32768 },
1008              "supported_parameters": ["temperature", "top_p", "max_tokens"],
1009              "pricing": { "prompt": "0.0000001", "completion": "0.0000004", "internal_reasoning": "0" },
1010              "architecture": { "input_modalities": ["text"] }
1011            },
1012            {
1013              "id": "anthropic/claude-opus-4-7",
1014              "name": "Anthropic: Claude Opus 4.7",
1015              "context_length": 200000,
1016              "supported_parameters": ["temperature", "verbosity", "max_tokens"],
1017              "pricing": { "prompt": "0.000015", "completion": "0.000075" }
1018            },
1019            {
1020              "id": "openai/o4-mini",
1021              "name": "OpenAI: o4-mini",
1022              "context_length": 128000,
1023              "supported_parameters": ["reasoning_effort", "max_tokens"],
1024              "pricing": { "prompt": "0.0000011", "completion": "0.0000044" }
1025            },
1026            {
1027              "id": "google/gemini-2.5-flash",
1028              "name": "Google: Gemini 2.5 Flash",
1029              "context_length": 1048576,
1030              "supported_parameters": ["reasoning", "include_reasoning", "max_tokens"],
1031              "pricing": { "prompt": "0.00000015", "completion": "0.0000035", "internal_reasoning": "0.0000035" },
1032              "architecture": { "input_modalities": ["text", "image", "audio", "video"] }
1033            },
1034            {
1035              "id": "",
1036              "name": "Empty — must be filtered"
1037            }
1038          ]
1039        }"#;
1040
1041        #[test]
1042        fn parses_minimal_model() {
1043            let json = r#"{"data":[{"id":"test/model","name":"Test Model"}]}"#;
1044            let models = parse_openrouter_catalog_models(json).expect("parse ok");
1045            assert_eq!(models.len(), 1);
1046            assert_eq!(models[0].id, "test/model");
1047            assert_eq!(models[0].label.as_deref(), Some("Test Model"));
1048            assert_eq!(models[0].runtime_id(), "openrouter/test/model");
1049            assert_eq!(models[0].provider_key, "openrouter");
1050            assert_eq!(models[0].source, CatalogSource::Live);
1051        }
1052
1053        #[test]
1054        fn parses_context_length_and_max_output() {
1055            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1056            let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
1057            assert_eq!(qwen.context_tokens, Some(131_072));
1058            assert_eq!(qwen.max_output_tokens, Some(32_768));
1059        }
1060
1061        #[test]
1062        fn filters_empty_ids() {
1063            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1064            assert!(!models.iter().any(|m| m.id.is_empty()));
1065            assert_eq!(models.len(), 4);
1066        }
1067
1068        #[test]
1069        fn parses_pricing_fields() {
1070            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1071            let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
1072            assert_eq!(qwen.pricing.prompt.as_deref(), Some("0.0000001"));
1073            assert_eq!(qwen.pricing.completion.as_deref(), Some("0.0000004"));
1074            assert!(!qwen.pricing.has_internal_reasoning_cost());
1075        }
1076
1077        #[test]
1078        fn parses_internal_reasoning_cost_flag() {
1079            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1080            let gemini = models
1081                .iter()
1082                .find(|m| m.id == "google/gemini-2.5-flash")
1083                .unwrap();
1084            assert!(gemini.pricing.has_internal_reasoning_cost());
1085        }
1086
1087        #[test]
1088        fn no_reasoning_params_maps_to_none() {
1089            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1090            let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
1091            assert_eq!(qwen.reasoning, ReasoningSupport::None);
1092        }
1093
1094        #[test]
1095        fn verbosity_param_maps_to_anthropic_adaptive() {
1096            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1097            let claude = models
1098                .iter()
1099                .find(|m| m.id == "anthropic/claude-opus-4-7")
1100                .unwrap();
1101            assert_eq!(
1102                claude.reasoning,
1103                ReasoningSupport::AnthropicAdaptive { adaptive: true }
1104            );
1105        }
1106
1107        #[test]
1108        fn reasoning_effort_maps_to_openrouter_reasoning() {
1109            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1110            let o4 = models.iter().find(|m| m.id == "openai/o4-mini").unwrap();
1111            assert_eq!(
1112                o4.reasoning,
1113                ReasoningSupport::OpenRouter {
1114                    include_reasoning: false,
1115                    effort: true,
1116                    verbosity: false,
1117                    internal_reasoning_priced: false,
1118                }
1119            );
1120        }
1121
1122        #[test]
1123        fn reasoning_include_reasoning_maps_correctly() {
1124            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1125            let gemini = models
1126                .iter()
1127                .find(|m| m.id == "google/gemini-2.5-flash")
1128                .unwrap();
1129            assert_eq!(
1130                gemini.reasoning,
1131                ReasoningSupport::OpenRouter {
1132                    include_reasoning: true,
1133                    effort: false,
1134                    verbosity: false,
1135                    internal_reasoning_priced: true,
1136                }
1137            );
1138        }
1139
1140        #[test]
1141        fn parses_multimodal_input() {
1142            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1143            let gemini = models
1144                .iter()
1145                .find(|m| m.id == "google/gemini-2.5-flash")
1146                .unwrap();
1147            assert!(gemini.input_modalities.contains(&Modality::Text));
1148            assert!(gemini.input_modalities.contains(&Modality::Image));
1149            assert!(gemini.input_modalities.contains(&Modality::Audio));
1150            assert!(gemini.input_modalities.contains(&Modality::Video));
1151        }
1152
1153        #[test]
1154        fn missing_modalities_defaults_to_text() {
1155            let json = r#"{"data":[{"id":"test/model"}]}"#;
1156            let models = parse_openrouter_catalog_models(json).expect("parse ok");
1157            assert_eq!(models[0].input_modalities, vec![Modality::Text]);
1158        }
1159
1160        #[test]
1161        fn parses_openrouter_rich_metadata_and_reasoning_flags() {
1162            // Backward-compatible name used by existing test
1163            let json = r#"{
1164              "data": [{
1165                "id": "anthropic/claude-sonnet-4.6",
1166                "name": "Anthropic: Claude Sonnet 4.6",
1167                "context_length": 1000000,
1168                "architecture": { "input_modalities": ["text", "image"] },
1169                "pricing": { "prompt": "0.000003", "completion": "0.000015", "internal_reasoning": "0.000012" },
1170                "top_provider": { "max_completion_tokens": 128000 },
1171                "supported_parameters": ["reasoning", "include_reasoning", "verbosity", "tools"]
1172              }]
1173            }"#;
1174            let models = parse_openrouter_catalog_models(json).expect("parse ok");
1175            assert_eq!(models.len(), 1);
1176            let m = &models[0];
1177            assert_eq!(m.runtime_id(), "openrouter/anthropic/claude-sonnet-4.6");
1178            assert_eq!(m.context_tokens, Some(1_000_000));
1179            assert_eq!(m.max_output_tokens, Some(128_000));
1180            assert!(m.input_modalities.contains(&Modality::Image));
1181            // verbosity wins → AnthropicAdaptive
1182            assert_eq!(
1183                m.reasoning,
1184                ReasoningSupport::AnthropicAdaptive { adaptive: true }
1185            );
1186        }
1187
1188        #[test]
1189        fn parses_openrouter_non_reasoning_model_as_none() {
1190            let json = r#"{
1191              "data": [{
1192                "id": "meta-llama/llama-3.3-70b-instruct",
1193                "name": "Meta: Llama 3.3 70B",
1194                "supported_parameters": ["temperature", "tools"]
1195              }]
1196            }"#;
1197            let models = parse_openrouter_catalog_models(json).expect("parse ok");
1198            assert_eq!(models[0].reasoning, ReasoningSupport::None);
1199        }
1200
1201        #[test]
1202        fn invalid_json_returns_error() {
1203            assert!(parse_openrouter_catalog_models("{not json}").is_err());
1204        }
1205
1206        #[test]
1207        fn missing_data_key_returns_error() {
1208            assert!(parse_openrouter_catalog_models(r#"{"models":[]}"#).is_err());
1209        }
1210    }
1211
1212    // ── Task 3: Generic handler / compat with registry ────────────────────────
1213
1214    // ── Task 5: Anthropic parser and Codex static catalog ───────────────────
1215
1216    mod anthropic {
1217        use super::super::*;
1218
1219        #[test]
1220        fn parser_reads_optional_capabilities_and_token_limits() {
1221            let json = r#"{
1222                "data": [{
1223                    "id": "claude-opus-4-7",
1224                    "display_name": "Claude Opus 4.7",
1225                    "max_input_tokens": 200000,
1226                    "max_tokens": 32000,
1227                    "capabilities": {
1228                        "thinking": { "supported": true },
1229                        "effort": { "supported": true }
1230                    }
1231                }],
1232                "has_more": false
1233            }"#;
1234            let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
1235            assert_eq!(models.len(), 1);
1236            let model = &models[0];
1237            assert_eq!(model.runtime_id(), "anthropic/claude-opus-4-7");
1238            assert_eq!(model.label.as_deref(), Some("Claude Opus 4.7"));
1239            assert_eq!(model.context_tokens, Some(200_000));
1240            assert_eq!(model.max_output_tokens, Some(32_000));
1241            assert_eq!(
1242                model.reasoning,
1243                ReasoningSupport::AnthropicAdaptive { adaptive: true }
1244            );
1245        }
1246
1247        #[test]
1248        fn parser_tolerates_missing_capabilities_as_unknown() {
1249            let json =
1250                r#"{"data":[{"id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku"}]}"#;
1251            let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
1252            assert_eq!(models[0].reasoning, ReasoningSupport::Unknown);
1253        }
1254
1255        #[test]
1256        fn parser_filters_empty_ids() {
1257            let json = r#"{"data":[{"id":""},{"id":"claude-sonnet-4-6"}]}"#;
1258            let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
1259            assert_eq!(models.len(), 1);
1260            assert_eq!(models[0].id, "claude-sonnet-4-6");
1261        }
1262    }
1263
1264    mod codex {
1265        use super::super::*;
1266
1267        #[test]
1268        fn static_catalog_uses_fallback_source_and_prefixed_runtime_ids() {
1269            let models = codex_static_catalog_models();
1270            assert!(models.iter().any(|m| m.id == "gpt-5.5"));
1271            assert!(models.iter().any(|m| m.id == "gpt-5.4"));
1272            assert!(models.iter().any(|m| m.id == "gpt-5.4-mini"));
1273            assert!(!models.iter().any(|m| m.id == "gpt-5.5-pro"));
1274            assert!(!models.iter().any(|m| m.id == "gpt-5.4-nano"));
1275            assert!(!models.iter().any(|m| m.id == "gpt-5.1-codex-mini"));
1276            assert!(models
1277                .iter()
1278                .all(|m| m.source == CatalogSource::StaticFallback));
1279            assert!(models
1280                .iter()
1281                .all(|m| m.runtime_id().starts_with("openai-codex/")));
1282        }
1283
1284        #[test]
1285        fn live_parser_filters_non_list_visibility_from_fixture() {
1286            let fixture = include_str!("fixtures/openai_codex_models.json");
1287            let models = parse_codex_catalog_models(fixture).expect("parse");
1288            let ids: std::collections::HashSet<_> = models.iter().map(|m| m.id.as_str()).collect();
1289            assert!(ids.contains("gpt-5.6-sol"));
1290            assert!(ids.contains("gpt-5.3-codex-spark"));
1291            assert!(!ids.contains("codex-auto-review"));
1292            assert!(!ids.contains("codex-internal-eval"));
1293            assert!(models.iter().all(|m| m.source == CatalogSource::Live));
1294        }
1295
1296        #[test]
1297        fn models_path_carries_package_client_version() {
1298            let path = codex_models_path(env!("CARGO_PKG_VERSION"));
1299            assert!(path.starts_with("/models?client_version="));
1300            assert!(path.contains(env!("CARGO_PKG_VERSION")));
1301        }
1302
1303        #[test]
1304        fn missing_credential_fallback_classifies_real_broker_not_configured_text() {
1305            let not_configured = format!(
1306                "request failed: {}",
1307                crate::auth::BrokerError::NotConfigured("openai-codex".into())
1308            );
1309            assert!(
1310                is_missing_credential_catalog_error(&not_configured),
1311                "got: {not_configured}"
1312            );
1313            let unknown = format!(
1314                "request failed: {}",
1315                crate::auth::BrokerError::UnknownProvider("openai-codex".into())
1316            );
1317            assert!(
1318                is_missing_credential_catalog_error(&unknown),
1319                "got: {unknown}"
1320            );
1321        }
1322
1323        #[test]
1324        fn missing_credential_fallback_classifies_exact_broker_credential_no_credentials_text() {
1325            // Production path: LocalBroker::access_token maps
1326            // ensure_fresh_provider_token's load miss through BrokerError::Credential.
1327            // Display form is exactly:
1328            //   credential error: No credentials for {provider} at {path}. Run `synaps login`.
1329            let raw = crate::auth::BrokerError::Credential(format!(
1330                "No credentials for openai-codex at {}. Run `synaps login`.",
1331                std::path::Path::new("/tmp/auth.json").display()
1332            ));
1333            let wrapped = format!("request failed: {raw}");
1334            assert!(
1335                is_missing_credential_catalog_error(&wrapped),
1336                "exact BrokerError::Credential missing-login text must fall back to static seeds; got: {wrapped}"
1337            );
1338            // Prefix-stable form (path/provider vary; Display prefix does not).
1339            assert!(is_missing_credential_catalog_error(
1340                "request failed: credential error: No credentials for openai-codex at /home/user/.synaps/auth.json. Run `synaps login`."
1341            ));
1342        }
1343
1344        /// fix1 I2a: the TYPED classifier is the authoritative fallback
1345        /// decision. No-live-session broker variants map to
1346        /// NotAuthenticated; everything operational maps to Failed.
1347        #[test]
1348        fn typed_classifier_maps_auth_misses_to_fallback_and_operations_to_failed() {
1349            use crate::auth::BrokerError as B;
1350            let auth_misses = [
1351                B::NotConfigured("github-copilot".into()),
1352                B::UnknownProvider("github-copilot".into()),
1353                B::RegistrationRequired {
1354                    provider: "github-copilot".into(),
1355                    remediation: "run synaps login".into(),
1356                },
1357                // Pinned token-store load-miss prefix (the one string
1358                // dependency, asserted here so token.rs drift breaks THIS
1359                // test instead of production determinism).
1360                B::Credential(
1361                    "No credentials for github-copilot at /tmp/x/auth.json. Run `synaps login`."
1362                        .into(),
1363                ),
1364            ];
1365            for err in auth_misses {
1366                assert!(
1367                    matches!(
1368                        classify_catalog_broker_error(err.clone()),
1369                        CatalogBrokerError::NotAuthenticated(_)
1370                    ),
1371                    "must classify as no-live-session: {err}"
1372                );
1373            }
1374
1375            let operational = [
1376                B::Transport("connection reset".into()),
1377                B::Denied("proxy path not allowlisted".into()),
1378                B::Unauthorized,
1379                B::Credential("github-copilot credential is missing chatgpt account id".into()),
1380                B::UnsupportedCapability {
1381                    provider: "github-copilot".into(),
1382                    capability: "tools".into(),
1383                },
1384            ];
1385            for err in operational {
1386                assert!(
1387                    matches!(
1388                        classify_catalog_broker_error(err.clone()),
1389                        CatalogBrokerError::Failed(_)
1390                    ),
1391                    "must classify as operational failure: {err}"
1392                );
1393            }
1394        }
1395
1396        #[test]
1397        fn missing_credential_fallback_does_not_hide_transport_http_parse_or_account_errors() {
1398            for err in [
1399                "request failed: broker transport error: connection reset",
1400                "model list failed: HTTP 401",
1401                "model list failed: HTTP 500",
1402                "parse failed: missing field `models`",
1403                "request failed: broker denied request: proxy path '/models' is not in the provider's endpoint allowlist",
1404                "request failed: credential error: openai-codex credential is missing chatgpt account id",
1405                "request failed: broker transport error: provider request failed: 403 Forbidden",
1406            ] {
1407                assert!(
1408                    !is_missing_credential_catalog_error(err),
1409                    "must not fallback for: {err}"
1410                );
1411            }
1412        }
1413    }
1414
1415    mod anthropic_paths {
1416        use super::super::*;
1417
1418        #[test]
1419        fn proxy_path_round_trips_limit_and_after_id() {
1420            assert_eq!(anthropic_models_proxy_path(None), "/v1/models?limit=100");
1421            assert_eq!(
1422                anthropic_models_proxy_path(Some("claude-opus-4-7")),
1423                "/v1/models?limit=100&after_id=claude-opus-4-7"
1424            );
1425            assert_eq!(
1426                anthropic_models_proxy_path(Some("")),
1427                "/v1/models?limit=100"
1428            );
1429        }
1430    }
1431
1432    // ── Task 4: Groq and NVIDIA enrichment ──────────────────────────────────
1433
1434    mod groq {
1435        use super::super::*;
1436
1437        #[test]
1438        fn parser_extracts_context_window_and_filters_inactive() {
1439            let json = r#"{"data":[
1440                {"id":"llama-3.3-70b-versatile","active":true,"context_window":131072,"owned_by":"Meta"},
1441                {"id":"old-model-v1","active":false,"context_window":8192,"owned_by":"Meta"}
1442            ]}"#;
1443            let models = parse_groq_catalog_models(json).expect("parse groq");
1444            assert_eq!(models.len(), 1);
1445            assert_eq!(models[0].id, "llama-3.3-70b-versatile");
1446            assert_eq!(models[0].context_tokens, Some(131_072));
1447            assert_eq!(models[0].reasoning, ReasoningSupport::None);
1448        }
1449
1450        #[test]
1451        fn inference_maps_reasoning_families() {
1452            assert_eq!(
1453                infer_groq_reasoning("openai/gpt-oss-120b"),
1454                ReasoningSupport::GroqReasoning
1455            );
1456            assert_eq!(
1457                infer_groq_reasoning("qwen/qwen3-32b"),
1458                ReasoningSupport::GroqReasoning
1459            );
1460            assert_eq!(
1461                infer_groq_reasoning("groq/compound-mini"),
1462                ReasoningSupport::GroqReasoning
1463            );
1464            assert_eq!(
1465                infer_groq_reasoning("llama-3.3-70b-versatile"),
1466                ReasoningSupport::None
1467            );
1468        }
1469
1470        #[test]
1471        fn parser_filters_empty_ids() {
1472            let json = r#"{"data":[{"id":"","active":true},{"id":"openai/gpt-oss-20b","active":true,"context_window":131072}]}"#;
1473            let models = parse_groq_catalog_models(json).expect("parse groq");
1474            assert_eq!(models.len(), 1);
1475            assert_eq!(models[0].id, "openai/gpt-oss-20b");
1476        }
1477    }
1478
1479    mod nvidia {
1480        use super::super::*;
1481
1482        #[test]
1483        fn parser_dedupes_and_enriches_known_context() {
1484            let json = r#"{"data":[
1485                {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
1486                {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
1487                {"id":"moonshotai/kimi-k2-thinking","owned_by":"moonshotai"}
1488            ]}"#;
1489            let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
1490            assert_eq!(models.len(), 2);
1491            let ultra = models.iter().find(|m| m.id.contains("ultra")).unwrap();
1492            assert_eq!(ultra.context_tokens, Some(128_000));
1493            assert_eq!(ultra.reasoning, ReasoningSupport::NvidiaInlineThinking);
1494            let kimi = models.iter().find(|m| m.id.contains("kimi")).unwrap();
1495            assert_eq!(kimi.context_tokens, Some(256_000));
1496        }
1497
1498        #[test]
1499        fn inference_detects_thinking_and_standard_models() {
1500            assert_eq!(
1501                infer_nvidia_reasoning("qwen/qwen3-next-80b-a3b-thinking"),
1502                ReasoningSupport::NvidiaInlineThinking
1503            );
1504            assert_eq!(
1505                infer_nvidia_reasoning("nvidia/cosmos-reason2-8b"),
1506                ReasoningSupport::NvidiaInlineThinking
1507            );
1508            assert_eq!(
1509                infer_nvidia_reasoning("meta/llama-3.3-70b-instruct"),
1510                ReasoningSupport::None
1511            );
1512        }
1513
1514        #[test]
1515        fn parser_filters_empty_ids() {
1516            let json = r#"{"data":[{"id":""},{"id":"meta/llama-3.3-70b-instruct"}]}"#;
1517            let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
1518            assert_eq!(models.len(), 1);
1519            assert_eq!(models[0].id, "meta/llama-3.3-70b-instruct");
1520        }
1521    }
1522
1523    mod generic_compat {
1524        use super::super::*;
1525
1526        #[test]
1527        fn parses_generic_catalog_models_and_filters_empty_ids() {
1528            let json = r#"{
1529                "data": [
1530                    { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
1531                    { "id": "" },
1532                    { "id": "openai/gpt-oss-120b" }
1533                ]
1534            }"#;
1535            let models =
1536                parse_generic_catalog_models(json, "openrouter", "OpenRouter").expect("parse ok");
1537            assert_eq!(models.len(), 2);
1538            assert_eq!(models[0].runtime_id(), "openrouter/qwen/qwen3-coder");
1539            assert_eq!(models[0].display_label(), "Qwen: Qwen3 Coder");
1540            assert_eq!(models[1].display_label(), "openai/gpt-oss-120b");
1541        }
1542
1543        #[test]
1544        fn whitespace_only_id_is_filtered() {
1545            let json = r#"{"data":[{"id":"   "},{"id":"valid"}]}"#;
1546            let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
1547            assert_eq!(models.len(), 1);
1548            assert_eq!(models[0].id, "valid");
1549        }
1550
1551        #[test]
1552        fn whitespace_label_stored_as_none() {
1553            let json = r#"{"data":[{"id":"m1","name":"   "}]}"#;
1554            let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
1555            assert_eq!(models[0].label, None);
1556        }
1557
1558        #[test]
1559        fn generic_catalog_source_is_live() {
1560            let json = r#"{"data":[{"id":"m1","name":"Model One"}]}"#;
1561            let models =
1562                parse_generic_catalog_models(json, "testprovider", "Test").expect("parse ok");
1563            assert_eq!(models[0].source, CatalogSource::Live);
1564        }
1565
1566        #[test]
1567        fn generic_reasoning_is_generic_open_ai() {
1568            let json = r#"{"data":[{"id":"m1"}]}"#;
1569            let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
1570            assert_eq!(models[0].reasoning, ReasoningSupport::GenericOpenAi);
1571        }
1572
1573        #[test]
1574        fn generic_parse_matches_legacy_parse_behavior() {
1575            // parse_generic_catalog_models should produce same ids/labels
1576            // as the legacy registry::parse_provider_models_response
1577            let json = r#"{
1578                "data": [
1579                    { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
1580                    { "id": "openai/gpt-oss-120b" }
1581                ]
1582            }"#;
1583            let legacy = super::super::super::registry::parse_provider_models_response(json)
1584                .expect("legacy parse ok");
1585            let catalog = parse_generic_catalog_models(json, "openrouter", "OpenRouter")
1586                .expect("catalog parse ok");
1587            assert_eq!(legacy.len(), catalog.len());
1588            for (l, c) in legacy.iter().zip(catalog.iter()) {
1589                assert_eq!(l.id, c.id, "ids must match");
1590                assert_eq!(l.name, c.label, "labels must match");
1591            }
1592        }
1593
1594        #[test]
1595        fn static_seeds_from_spec_all_providers() {
1596            // Every registered provider should produce at least one seed
1597            for spec in super::super::super::registry::providers() {
1598                if spec.models.is_empty() {
1599                    continue;
1600                }
1601                let seeds = super::super::static_seeds_from_spec(spec);
1602                assert!(!seeds.is_empty(), "no seeds for {}", spec.key);
1603                assert!(
1604                    seeds
1605                        .iter()
1606                        .all(|m| m.runtime_id().starts_with(&format!("{}/", spec.key))),
1607                    "runtime_id prefix wrong for {}",
1608                    spec.key
1609                );
1610            }
1611        }
1612    }
1613}