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