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