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;
40pub mod muse;
41mod nvidia;
42mod openrouter;
43pub mod validation;
44mod xai;
45
46pub use anthropic::{
47    anthropic_mode_capabilities, anthropic_models_url, anthropic_static_capability,
48    merge_catalog_pages, parse_anthropic_catalog_models, parse_anthropic_catalog_page,
49    plan_anthropic_execution, plan_standard_anthropic_transport, AnthropicCatalogPage,
50    AnthropicExecutionMode, AnthropicExecutionPlan, AnthropicPlanError, AnthropicPlanErrorCode,
51    AnthropicPlanPrerequisites, AnthropicWireEffort, AnthropicWorkflowPlan,
52};
53pub use codex::{
54    codex_models_path, codex_models_url, codex_static_capability, codex_static_catalog_models,
55    parse_codex_catalog_models, plan_codex_execution, validate_codex_level, CodexCapabilitySource,
56    CodexExecutionMode, CodexExecutionPlan, CodexMultiAgentMode, CodexPlanError,
57    CodexPlanErrorCode, CodexRequestRole, CodexWireEffort, ExecutionRole, CODEX_CLIENT_VERSION,
58    PROVIDER_KEY as CODEX_PROVIDER_KEY, PROVIDER_NAME as CODEX_PROVIDER_NAME,
59};
60pub use generic::parse_generic_catalog_models;
61pub use github_copilot::{
62    copilot_model, copilot_static_catalog_models, models_request_headers,
63    parse_copilot_catalog_entries, parse_copilot_catalog_models,
64    preferred_wire_protocol_from_endpoints, runtime_wire_protocol as github_copilot_runtime_model,
65    selectable_copilot_entries, validate_models_endpoint, CopilotCatalogEntry, CopilotEndpoint,
66    CopilotModelDescriptor, CopilotPolicyState, CopilotWire, COPILOT_API_VERSION,
67    COPILOT_FALLBACK_MODELS, MAX_MODELS_BODY_BYTES, MODELS_BASE_URL, MODELS_PATH, MODELS_URL,
68    PROVIDER_KEY as COPILOT_PROVIDER_KEY, PROVIDER_NAME as COPILOT_PROVIDER_NAME,
69};
70pub use google_gemini::{
71    google_gemini_model, google_gemini_static_catalog_models, GoogleGeminiModelDescriptor,
72    GOOGLE_GEMINI_TEXT_MODELS, PROVIDER_KEY as GOOGLE_GEMINI_PROVIDER_KEY,
73    PROVIDER_NAME as GOOGLE_GEMINI_PROVIDER_NAME,
74};
75pub use groq::{infer_groq_reasoning, parse_groq_catalog_models};
76pub use kimi::{
77    apply_kimi_reasoning_params, kimi_code_capability, kimi_code_model,
78    kimi_code_static_catalog_models, kimi_static_capability, KimiCodeModelDescriptor,
79    KimiReasoningCapability, KIMI_CODE_TEXT_MODELS,
80};
81pub use muse::{
82    muse_default_level, muse_effort_label, muse_routable_id, muse_runtime_model,
83    muse_static_catalog_models, muse_static_model, muse_supported_levels,
84    parse_muse_catalog_models, MuseModelDescriptor, MUSE_MODELS_PATH, MUSE_PROVIDER_KEY,
85    MUSE_PROVIDER_NAME, MUSE_TEXT_MODELS,
86};
87pub use nvidia::{infer_nvidia_reasoning, parse_nvidia_catalog_models};
88pub use openrouter::parse_openrouter_catalog_models;
89pub use xai::{
90    xai_model, xai_static_capability, xai_static_catalog_models, XaiModelDescriptor,
91    XaiReasoningCapability, XAI_TEXT_MODELS,
92};
93
94// ─── Modality ────────────────────────────────────────────────────────────────
95
96/// Input/output modality.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum Modality {
99    Text,
100    Image,
101    Audio,
102    Video,
103    File,
104    Other(String),
105}
106
107impl Modality {
108    #[allow(clippy::should_implement_trait)]
109    pub fn from_str(s: &str) -> Self {
110        match s {
111            "text" => Modality::Text,
112            "image" => Modality::Image,
113            "audio" => Modality::Audio,
114            "video" => Modality::Video,
115            "file" => Modality::File,
116            other => Modality::Other(other.to_string()),
117        }
118    }
119}
120
121// ─── PricingSummary ───────────────────────────────────────────────────────────
122
123/// Pricing metadata. Stored as decimal-string USD/token as returned by OpenRouter.
124#[derive(Debug, Clone, PartialEq, Eq, Default)]
125pub struct PricingSummary {
126    /// USD per prompt token, decimal string.
127    pub prompt: Option<String>,
128    /// USD per completion token, decimal string.
129    pub completion: Option<String>,
130    /// Separate Gemini internal-reasoning token cost (OpenRouter).
131    pub internal_reasoning: Option<String>,
132}
133
134impl PricingSummary {
135    /// True when a non-zero internal_reasoning price is present.
136    pub fn has_internal_reasoning_cost(&self) -> bool {
137        self.internal_reasoning
138            .as_deref()
139            .map(|s| s != "0" && !s.trim().is_empty())
140            .unwrap_or(false)
141    }
142}
143
144// ─── ReasoningSupport ─────────────────────────────────────────────────────────
145
146/// Multi-agent protocol version advertised by the exact OpenAI Codex model.
147///
148/// Unknown server strings are retained only as this sanitized sentinel; raw
149/// catalog values never enter diagnostics or authorization decisions.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum CodexMultiAgentVersion {
152    V1,
153    V2,
154    Unknown,
155}
156
157impl CodexMultiAgentVersion {
158    pub fn as_str(self) -> &'static str {
159        match self {
160            Self::V1 => "v1",
161            Self::V2 => "v2",
162            Self::Unknown => "unknown",
163        }
164    }
165}
166
167/// Normalized reasoning/thinking capability for a model.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum ReasoningSupport {
170    /// No reasoning/thinking support confirmed.
171    None,
172    /// Anthropic adaptive: thinking:{type:"adaptive"} ± effort param.
173    AnthropicAdaptive { adaptive: bool },
174    /// OpenRouter: reasoning/include_reasoning/reasoning_effort/verbosity params.
175    OpenRouter {
176        include_reasoning: bool,
177        effort: bool,
178        verbosity: bool,
179        internal_reasoning_priced: bool,
180    },
181    /// Groq family-based reasoning (reasoning_format/reasoning_effort).
182    GroqReasoning,
183    /// NVIDIA inline thinking via system-prompt; <think> in content.
184    NvidiaInlineThinking,
185    /// Generic OpenAI-compatible (capability unknown).
186    GenericOpenAi,
187    /// OpenAI Codex (ChatGPT OAuth): named effort levels, exact set from catalog.
188    CodexNamed {
189        /// Ordered list of supported named reasoning effort strings from the
190        /// live catalog's `supported_reasoning_levels[].effort` field.
191        supported: Vec<agent_core::reasoning::ReasoningLevel>,
192        /// Default level from catalog's `default_reasoning_level`, if present.
193        default_level: Option<agent_core::reasoning::ReasoningLevel>,
194        /// Wire effort for Ultra from the exact model catalog. Not a worker-only hint.
195        multi_agent_reasoning_effort: Option<agent_core::reasoning::ReasoningLevel>,
196        /// Exact model's collaboration protocol. Ultra requires V2.
197        multi_agent_version: Option<CodexMultiAgentVersion>,
198    },
199    /// Exact named effort levels advertised by a live provider catalog
200    /// (Meta Muse `metadata["muse-code"].variants[*].reasoningEffort`).
201    /// Only these levels (plus provider-default `adaptive`) may be sent.
202    NamedEffort {
203        supported: Vec<agent_core::reasoning::ReasoningLevel>,
204        /// Catalog default (Muse `options.reasoningEffort`); always a member
205        /// of `supported` when present.
206        default_level: Option<agent_core::reasoning::ReasoningLevel>,
207    },
208    /// Not yet classified.
209    Unknown,
210}
211
212// ─── CatalogSource ────────────────────────────────────────────────────────────
213
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum CatalogSource {
216    /// From a live provider API call.
217    Live,
218    /// Bundled static/seed data.
219    StaticFallback,
220    /// Static seed enriched with live fields.
221    StaticWithLive,
222    /// Capability inferred heuristically.
223    Inferred,
224}
225
226// ─── CatalogProviderKind ──────────────────────────────────────────────────────
227
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub enum CatalogProviderKind {
230    Anthropic,
231    OpenRouter,
232    Groq,
233    NvidiaNim,
234    OpenAiCodex,
235    Generic { key: String },
236    Local,
237}
238
239// ─── CatalogModel ─────────────────────────────────────────────────────────────
240
241/// Normalized model catalog entry. Every provider handler produces these.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct CatalogModel {
244    /// Provider key (e.g. "openrouter", "groq").
245    pub provider_key: String,
246    /// Human-readable provider name.
247    pub provider_name: String,
248    /// Provider kind for routing/capability dispatch.
249    pub provider_kind: CatalogProviderKind,
250    /// Model id as used in API requests (no provider prefix).
251    pub id: String,
252    /// Human-readable label.
253    pub label: Option<String>,
254    /// Input context window in tokens.
255    pub context_tokens: Option<u64>,
256    /// Maximum output tokens.
257    pub max_output_tokens: Option<u64>,
258    /// Input modalities.
259    pub input_modalities: Vec<Modality>,
260    /// Pricing summary.
261    pub pricing: PricingSummary,
262    /// Reasoning/thinking capability.
263    pub reasoning: ReasoningSupport,
264    /// Data provenance.
265    pub source: CatalogSource,
266}
267
268impl CatalogModel {
269    /// Construct a minimal entry, returning `None` if the id is blank.
270    pub fn new(
271        provider_key: impl Into<String>,
272        provider_name: impl Into<String>,
273        id: impl Into<String>,
274    ) -> Option<Self> {
275        let id = id.into();
276        if id.trim().is_empty() {
277            return None;
278        }
279        let pk = provider_key.into();
280        Some(Self {
281            provider_kind: CatalogProviderKind::Generic { key: pk.clone() },
282            provider_name: provider_name.into(),
283            provider_key: pk,
284            id,
285            label: None,
286            context_tokens: None,
287            max_output_tokens: None,
288            input_modalities: vec![Modality::Text],
289            pricing: PricingSummary::default(),
290            reasoning: ReasoningSupport::Unknown,
291            source: CatalogSource::Live,
292        })
293    }
294
295    /// Synaps runtime id. Provider identity is always explicit, including Anthropic.
296    pub fn runtime_id(&self) -> String {
297        format!("{}/{}", self.provider_key, self.id)
298    }
299
300    /// Label if present, id otherwise.
301    pub fn display_label(&self) -> &str {
302        self.label.as_deref().unwrap_or(&self.id)
303    }
304
305    /// For Codex models: returns the exact ordered set of supported named levels,
306    /// or `None` if this model has no Codex-named capability data.
307    pub fn codex_supported_levels(&self) -> Option<&[agent_core::reasoning::ReasoningLevel]> {
308        match &self.reasoning {
309            ReasoningSupport::CodexNamed { supported, .. } => Some(supported),
310            _ => None,
311        }
312    }
313
314    /// Exact Codex collaboration protocol, if the authoritative capability
315    /// record advertised one.
316    pub fn codex_multi_agent_version(&self) -> Option<CodexMultiAgentVersion> {
317        match &self.reasoning {
318            ReasoningSupport::CodexNamed {
319                multi_agent_version,
320                ..
321            } => *multi_agent_version,
322            _ => None,
323        }
324    }
325
326    /// For Codex models: true iff the given level is in the supported set.
327    /// Always returns false for models without Codex-named capability data.
328    pub fn codex_supports_level(&self, level: agent_core::reasoning::ReasoningLevel) -> bool {
329        self.codex_supported_levels()
330            .is_some_and(|levels| levels.contains(&level))
331    }
332}
333
334// ─── Static seed helper ───────────────────────────────────────────────────────
335
336/// Build a static-fallback CatalogModel from a (id, label) pair.
337pub fn from_static_seed(
338    provider_key: &str,
339    provider_name: &str,
340    id: &str,
341    label: &str,
342) -> Option<CatalogModel> {
343    let mut m = CatalogModel::new(provider_key, provider_name, id)?;
344    m.label = if label.trim().is_empty() {
345        None
346    } else {
347        Some(label.to_string())
348    };
349    m.source = CatalogSource::StaticFallback;
350    m.reasoning = ReasoningSupport::Unknown;
351    Some(m)
352}
353
354/// Convert all static seeds in a ProviderSpec to CatalogModel entries.
355pub fn static_seeds_from_spec(spec: &super::registry::ProviderSpec) -> Vec<CatalogModel> {
356    spec.models
357        .iter()
358        .filter_map(|(id, label, _tier)| from_static_seed(spec.key, spec.name, id, label))
359        .collect()
360}
361
362// ─── Live fetch helpers ───────────────────────────────────────────────────────
363
364pub trait ModelCatalogProvider: Sync {
365    fn provider_key(&self) -> &'static str;
366
367    fn fetch<'a>(
368        &'a self,
369        client: &'a reqwest::Client,
370    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>>;
371}
372
373pub struct OpenRouterCatalogProvider;
374pub struct GroqCatalogProvider;
375pub struct NvidiaCatalogProvider;
376pub struct AnthropicCatalogProvider;
377pub struct CodexCatalogProvider;
378pub struct XaiCatalogProvider;
379pub struct GitHubCopilotCatalogProvider;
380pub struct GoogleGeminiCatalogProvider;
381pub struct MuseCatalogProvider;
382pub struct GenericCatalogProvider;
383
384pub fn catalog_provider_for(provider_key: &str) -> &'static dyn ModelCatalogProvider {
385    match provider_key {
386        "openrouter" => &OpenRouterCatalogProvider,
387        "groq" => &GroqCatalogProvider,
388        "nvidia" => &NvidiaCatalogProvider,
389        "claude" | "anthropic" => &AnthropicCatalogProvider,
390        "openai-codex" => &CodexCatalogProvider,
391        "xai-auth" => &XaiCatalogProvider,
392        "github-copilot" => &GitHubCopilotCatalogProvider,
393        "google-gemini" => &GoogleGeminiCatalogProvider,
394        "muse" => &MuseCatalogProvider,
395        _ => &GenericCatalogProvider,
396    }
397}
398
399fn catalog_get(client: &reqwest::Client, url: &str) -> reqwest::RequestBuilder {
400    client.get(url).timeout(CATALOG_REQUEST_TIMEOUT)
401}
402
403async fn read_catalog_response(resp: reqwest::Response) -> Result<String, String> {
404    let status = resp.status();
405    let body = resp.text().await.map_err(|e| format!("read failed: {e}"))?;
406    if !status.is_success() {
407        return Err(format!("model list failed: HTTP {status}"));
408    }
409    Ok(body)
410}
411
412async fn fetch_anthropic_catalog_models(
413    _client: &reqwest::Client,
414) -> Result<Vec<CatalogModel>, String> {
415    // Paginated live discovery through the broker-owned Anthropic credential.
416    // Tokens never enter this module — each page is a allowlisted ProxyRequest
417    // for `/v1/models` (+ limit/after_id query).
418    let mut pages = Vec::new();
419    let mut after_id: Option<String> = None;
420
421    for _ in 0..ANTHROPIC_MODELS_MAX_PAGES {
422        let path = anthropic_models_proxy_path(after_id.as_deref());
423        let body = broker_proxy_catalog_body("anthropic", &path).await?;
424        let page = parse_anthropic_catalog_page(&body).map_err(|e| format!("parse failed: {e}"))?;
425        let next_after_id = page.last_id.clone();
426        let has_more = page.has_more && next_after_id.is_some();
427        pages.push(page.models);
428        if !has_more {
429            return Ok(merge_catalog_pages(pages));
430        }
431        after_id = next_after_id;
432    }
433
434    Ok(merge_catalog_pages(pages))
435}
436
437/// Relative Anthropic models path for the broker allowlist (no host).
438pub fn anthropic_models_proxy_path(after_id: Option<&str>) -> String {
439    // Keep query shape identical to `anthropic_models_url` without the host.
440    let absolute = anthropic_models_url(after_id);
441    absolute
442        .strip_prefix("https://api.anthropic.com")
443        .unwrap_or(&absolute)
444        .to_string()
445}
446
447impl ModelCatalogProvider for OpenRouterCatalogProvider {
448    fn provider_key(&self) -> &'static str {
449        "openrouter"
450    }
451
452    fn fetch<'a>(
453        &'a self,
454        client: &'a reqwest::Client,
455    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
456        Box::pin(async move { fetch_openrouter_catalog_models(client).await })
457    }
458}
459
460impl ModelCatalogProvider for GroqCatalogProvider {
461    fn provider_key(&self) -> &'static str {
462        "groq"
463    }
464
465    fn fetch<'a>(
466        &'a self,
467        _client: &'a reqwest::Client,
468    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
469        Box::pin(async move {
470            let body = broker_catalog_models_body("groq").await?;
471            parse_groq_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
472        })
473    }
474}
475
476impl ModelCatalogProvider for NvidiaCatalogProvider {
477    fn provider_key(&self) -> &'static str {
478        "nvidia"
479    }
480
481    fn fetch<'a>(
482        &'a self,
483        client: &'a reqwest::Client,
484    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
485        Box::pin(async move {
486            let resp = catalog_get(client, "https://integrate.api.nvidia.com/v1/models")
487                .send()
488                .await
489                .map_err(|e| format!("request failed: {e}"))?;
490            let body = read_catalog_response(resp).await?;
491            parse_nvidia_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
492        })
493    }
494}
495
496impl ModelCatalogProvider for AnthropicCatalogProvider {
497    fn provider_key(&self) -> &'static str {
498        "claude"
499    }
500
501    fn fetch<'a>(
502        &'a self,
503        client: &'a reqwest::Client,
504    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
505        Box::pin(async move { fetch_anthropic_catalog_models(client).await })
506    }
507}
508
509/// True when a broker catalog error means "no credential / not logged in",
510/// so static seeds are an acceptable offline fallback.
511///
512/// Transport, HTTP status, parse, denial, and credential-shape failures must
513/// NOT fall through to static seeds — they are real errors.
514pub fn is_missing_credential_catalog_error(err: &str) -> bool {
515    // Prefer structured BrokerError Display forms when present.
516    // Note: broker_proxy_catalog_body wraps broker errors as
517    // `request failed: {BrokerError}` — so "request failed" alone is NOT
518    // a transport signal.
519    let lower = err.to_lowercase();
520
521    // Hard excludes: real operational failures must never fall back to seeds.
522    if lower.contains("model list failed")
523        || lower.contains("parse failed")
524        || lower.contains("broker denied")
525        || lower.contains("broker transport error")
526        || lower.contains("missing chatgpt account id")
527        || lower.contains("http 4")
528        || lower.contains("http 5")
529        || lower.contains("connection reset")
530        || lower.contains("timed out")
531        || lower.contains("timeout")
532    {
533        return false;
534    }
535
536    // Stable BrokerError::Credential prefix from token.rs load-miss:
537    // "credential error: No credentials for {provider} at {path}. Run `synaps login`."
538    // Match only the stable prefix so account-shape / other Credential variants
539    // (e.g. missing chatgpt account id) remain hard errors above.
540    lower.contains("credential error: no credentials for ")
541        || lower.contains("no credential configured")
542        || lower.contains("unknown provider:")
543        || lower.contains("not logged")
544        || lower.contains("registration required")
545}
546
547impl ModelCatalogProvider for CodexCatalogProvider {
548    fn provider_key(&self) -> &'static str {
549        "openai-codex"
550    }
551
552    fn fetch<'a>(
553        &'a self,
554        _client: &'a reqwest::Client,
555    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
556        // Prefer broker-proxied live discovery against the ChatGPT backend
557        // models endpoint. Static seeds are offline / not-configured fallback
558        // only — never the normal successful result when auth is available.
559        Box::pin(async move {
560            // Advertise the pinned Codex protocol version, not SynapsCLI's
561            // crate version: the backend filters the catalog by
562            // `minimal_client_version` and returns nothing for unknown values.
563            let path = codex_models_path(codex::CODEX_CLIENT_VERSION);
564            match broker_proxy_catalog_body("openai-codex", &path).await {
565                Ok(body) => {
566                    let models = parse_codex_catalog_models(&body)
567                        .map_err(|e| format!("parse failed: {e}"))?;
568                    capability_cache::replace_provider(self.provider_key(), &models);
569                    Ok(models)
570                }
571                // Typed decision (fix1 I2a): only a no-live-session outcome
572                // falls back to static seeds; operational failures surface.
573                Err(CatalogBrokerError::NotAuthenticated(_)) => Ok(codex_static_catalog_models()),
574                Err(CatalogBrokerError::Failed(err)) => Err(err),
575            }
576        })
577    }
578}
579
580impl ModelCatalogProvider for XaiCatalogProvider {
581    fn provider_key(&self) -> &'static str {
582        "xai-auth"
583    }
584
585    fn fetch<'a>(
586        &'a self,
587        _client: &'a reqwest::Client,
588    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
589        Box::pin(async move { Ok(xai_static_catalog_models()) })
590    }
591}
592
593impl ModelCatalogProvider for GitHubCopilotCatalogProvider {
594    fn provider_key(&self) -> &'static str {
595        "github-copilot"
596    }
597
598    fn fetch<'a>(
599        &'a self,
600        _client: &'a reqwest::Client,
601    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
602        // Prefer broker-proxied live discovery (session token never enters this
603        // module as a caller-supplied secret). Fall back to curated static seeds
604        // exactly when the TYPED broker outcome says there is no live session
605        // (fix1 I2a) — deterministic for every ambient credential state.
606        Box::pin(async move {
607            match broker_catalog_models_body("github-copilot").await {
608                Ok(body) => {
609                    parse_copilot_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
610                }
611                Err(CatalogBrokerError::NotAuthenticated(_)) => Ok(copilot_static_catalog_models()),
612                Err(CatalogBrokerError::Failed(err)) => Err(err),
613            }
614        })
615    }
616}
617
618impl ModelCatalogProvider for GoogleGeminiCatalogProvider {
619    fn provider_key(&self) -> &'static str {
620        "google-gemini"
621    }
622
623    fn fetch<'a>(
624        &'a self,
625        _client: &'a reqwest::Client,
626    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
627        // The Code Assist model-discovery surface is not documented as a
628        // stable third-party API and there is no reviewed live-listing method
629        // in the broker allowlist yet. Fail closed to the conservative static
630        // catalog: text + tool-capable IDs whose provenance is the official
631        // Gemini CLI reference source.
632        Box::pin(async move { Ok(google_gemini_static_catalog_models()) })
633    }
634}
635
636impl ModelCatalogProvider for MuseCatalogProvider {
637    fn provider_key(&self) -> &'static str {
638        MUSE_PROVIDER_KEY
639    }
640
641    fn fetch<'a>(
642        &'a self,
643        _client: &'a reqwest::Client,
644    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
645        // Live, server-driven catalog through the broker (the Model API key
646        // never enters this module). Static seed only when there is no live
647        // session (typed broker outcome); operational failures surface.
648        Box::pin(async move {
649            match broker_proxy_catalog_body(MUSE_PROVIDER_KEY, MUSE_MODELS_PATH).await {
650                Ok(body) => {
651                    let models = parse_muse_catalog_models(&body)
652                        .map_err(|e| format!("parse failed: {e}"))?;
653                    capability_cache::replace_provider(self.provider_key(), &models);
654                    Ok(models)
655                }
656                Err(CatalogBrokerError::NotAuthenticated(_)) => Ok(muse_static_catalog_models()),
657                Err(CatalogBrokerError::Failed(err)) => Err(err),
658            }
659        })
660    }
661}
662
663/// Spacing after a SUCCESSFUL Muse catalog warm-up, so an id absent from the
664/// live catalog can never turn every turn or setting change into a catalog
665/// request.
666const MUSE_WARMUP_BACKOFF: std::time::Duration = std::time::Duration::from_secs(300);
667/// Spacing after a FAILED warm-up (timeout, broker error, not logged in,
668/// unparseable catalog). Short, because without evidence a saved effort
669/// level fails the turn: one failed fetch must not block Muse for 5 minutes.
670const MUSE_WARMUP_FAILURE_BACKOFF: std::time::Duration = std::time::Duration::from_secs(20);
671/// Upper bound on one warm-up: it runs on the model-change / settings path.
672const MUSE_WARMUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
673
674/// Earliest time the next warm-up may start.
675static MUSE_WARMUP_NEXT: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
676
677fn set_muse_warmup_backoff(backoff: std::time::Duration) {
678    *MUSE_WARMUP_NEXT.lock().unwrap_or_else(|e| e.into_inner()) =
679        Some(std::time::Instant::now() + backoff);
680}
681
682#[cfg(test)]
683pub(crate) fn reset_muse_warmup_backoff() {
684    *MUSE_WARMUP_NEXT.lock().unwrap_or_else(|e| e.into_inner()) = None;
685}
686
687#[cfg(test)]
688pub(crate) fn muse_warmup_retry_in() -> Option<std::time::Duration> {
689    let next = (*MUSE_WARMUP_NEXT.lock().unwrap_or_else(|e| e.into_inner()))?;
690    next.checked_duration_since(std::time::Instant::now())
691}
692
693/// Load live Muse capability evidence for `model` into THIS process.
694///
695/// The capability cache is process-local: the `/models` picker fills the
696/// TUI's, but a daemon-hosted session (or a resumed / fresh process) has
697/// none, so exact effort levels would be unknown there. When `model` is a
698/// well-formed `muse/` id with no live row in this process, fetch the live
699/// catalog once through `broker` and refresh the cache. Bounded: a no-op for
700/// non-Muse / malformed ids or ids already known live; at most one attempt
701/// per [`MUSE_WARMUP_BACKOFF`] after a success, or per
702/// [`MUSE_WARMUP_FAILURE_BACKOFF`] after a failure; each attempt capped at
703/// [`MUSE_WARMUP_TIMEOUT`]. Never affects routing (shape-based) and never
704/// fails the caller — errors are logged; validation reports the outcome.
705pub async fn ensure_muse_catalog_for(
706    model: &str,
707    broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
708) {
709    let Some(id) = model.strip_prefix("muse/") else {
710        return;
711    };
712    if !muse_routable_id(id)
713        || capability_cache::get(&format!("{MUSE_PROVIDER_KEY}/{id}"))
714            .is_some_and(|row| row.source == CatalogSource::Live)
715    {
716        return;
717    }
718    {
719        let mut next = MUSE_WARMUP_NEXT.lock().unwrap_or_else(|e| e.into_inner());
720        let now = std::time::Instant::now();
721        if next.is_some_and(|at| now < at) {
722            return;
723        }
724        // Claimed BEFORE the request (long, covering the in-flight fetch):
725        // concurrent callers don't stampede. A failure shortens it below.
726        *next = Some(now + MUSE_WARMUP_BACKOFF);
727    }
728    // Anything but a successful load (including this future being dropped
729    // mid-fetch when the turn is aborted) leaves only the short backoff.
730    let mut failure = WarmupFailureGuard(true);
731    let fetch = broker_proxy_catalog_body_via(broker, MUSE_PROVIDER_KEY, MUSE_MODELS_PATH);
732    let loaded = match tokio::time::timeout(MUSE_WARMUP_TIMEOUT, fetch).await {
733        Ok(Ok(body)) => match parse_muse_catalog_models(&body) {
734            Ok(models) => {
735                let row = models.iter().find(|m| m.id == id);
736                tracing::info!(
737                    models = models.len(),
738                    listed = row.is_some(),
739                    efforts = ?row.map(|m| &m.reasoning),
740                    "muse catalog loaded for {model}"
741                );
742                capability_cache::replace_provider(MUSE_PROVIDER_KEY, &models);
743                true
744            }
745            Err(err) => {
746                tracing::warn!("muse catalog warm-up parse failed: {err}");
747                false
748            }
749        },
750        Ok(Err(CatalogBrokerError::NotAuthenticated(_))) => {
751            tracing::info!("muse catalog warm-up skipped: no Muse login or META_API_KEY");
752            false
753        }
754        Ok(Err(CatalogBrokerError::Failed(err))) => {
755            tracing::warn!("muse catalog warm-up failed: {err}");
756            false
757        }
758        Err(_) => {
759            tracing::warn!("muse catalog warm-up timed out");
760            false
761        }
762    };
763    failure.0 = !loaded;
764}
765
766/// Shortens the in-flight warm-up claim to the failure backoff on drop,
767/// unless the warm-up loaded the catalog.
768struct WarmupFailureGuard(bool);
769
770impl Drop for WarmupFailureGuard {
771    fn drop(&mut self) {
772        if self.0 {
773            set_muse_warmup_backoff(MUSE_WARMUP_FAILURE_BACKOFF);
774        }
775    }
776}
777
778impl ModelCatalogProvider for GenericCatalogProvider {
779    fn provider_key(&self) -> &'static str {
780        "generic"
781    }
782
783    fn fetch<'a>(
784        &'a self,
785        _client: &'a reqwest::Client,
786    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
787        Box::pin(async move {
788            Err("generic catalog fetch requires provider key; use fetch_generic_catalog_provider_models".to_string())
789        })
790    }
791}
792
793async fn fetch_generic_catalog_provider_models(
794    provider_key: &str,
795) -> Result<Vec<CatalogModel>, String> {
796    let specs = super::registry::providers();
797    let spec = specs
798        .iter()
799        .find(|s| s.key == provider_key)
800        .ok_or_else(|| format!("unknown provider: {provider_key}"))?;
801    if !crate::auth::broker::static_key_configured(provider_key) {
802        return Err(format!("{} is not configured", spec.name));
803    }
804    let body = broker_catalog_models_body(provider_key).await?;
805    parse_generic_catalog_models(&body, provider_key, spec.name)
806        .map_err(|e| format!("parse failed: {e}"))
807}
808
809/// GET `/models` through the credential broker (the key never enters this
810/// module). Returns the body on 2xx, a typed failure otherwise.
811async fn broker_catalog_models_body(provider_key: &str) -> Result<String, CatalogBrokerError> {
812    broker_proxy_catalog_body(provider_key, "/models").await
813}
814
815/// Typed broker catalog failure (fix1 I2a): the static-fallback decision is
816/// made on TYPED broker variants, never by probing Display strings after
817/// the fact — so the outcome is deterministic for every credential state.
818#[derive(Debug, Clone, PartialEq, Eq)]
819pub enum CatalogBrokerError {
820    /// No usable live session for the provider (not configured, unknown to
821    /// the registries, registration required, or a stored-credential miss).
822    /// Providers with curated seeds fall back to them.
823    NotAuthenticated(String),
824    /// A real operational failure (transport, denial, HTTP status,
825    /// account-shape credential errors): fail closed.
826    Failed(String),
827}
828
829impl CatalogBrokerError {
830    fn message(&self) -> &str {
831        match self {
832            Self::NotAuthenticated(msg) | Self::Failed(msg) => msg,
833        }
834    }
835}
836
837impl From<CatalogBrokerError> for String {
838    fn from(err: CatalogBrokerError) -> Self {
839        err.message().to_string()
840    }
841}
842
843/// Classify a typed [`BrokerError`] for catalog fetching. The one string
844/// dependency left is the stable token-store load-miss prefix inside
845/// `BrokerError::Credential` ("No credentials for …"), pinned by a unit
846/// test — account-shape credential errors stay hard failures.
847fn classify_catalog_broker_error(err: agent_core::auth::BrokerError) -> CatalogBrokerError {
848    use agent_core::auth::BrokerError as B;
849    let msg = format!("request failed: {err}");
850    match &err {
851        B::NotConfigured(_) | B::UnknownProvider(_) | B::RegistrationRequired { .. } => {
852            CatalogBrokerError::NotAuthenticated(msg)
853        }
854        B::Credential(detail) if detail.starts_with("No credentials for ") => {
855            CatalogBrokerError::NotAuthenticated(msg)
856        }
857        _ => CatalogBrokerError::Failed(msg),
858    }
859}
860
861/// GET an allowlisted catalog path through the credential broker.
862/// The credential never enters this module — only the provider key + path.
863async fn broker_proxy_catalog_body(
864    provider_key: &str,
865    path: &str,
866) -> Result<String, CatalogBrokerError> {
867    broker_proxy_catalog_body_via(&crate::auth::broker::global_broker(), provider_key, path).await
868}
869
870/// [`broker_proxy_catalog_body`] through an explicit broker (e.g. the turn's
871/// own, which may be a remote daemon rather than the process-global one).
872async fn broker_proxy_catalog_body_via(
873    broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
874    provider_key: &str,
875    path: &str,
876) -> Result<String, CatalogBrokerError> {
877    let resp = broker
878        .proxy(crate::auth::ProxyRequest {
879            provider: provider_key.to_string(),
880            method: crate::auth::ProxyMethod::Get,
881            path: path.to_string(),
882            body: None,
883            stream: false,
884            body_bytes: None,
885        })
886        .await
887        .map_err(classify_catalog_broker_error)?;
888    if !(200..300).contains(&resp.status) {
889        return Err(CatalogBrokerError::Failed(format!(
890            "model list failed: HTTP {}",
891            resp.status
892        )));
893    }
894    Ok(resp.body)
895}
896
897/// Fetch the OpenRouter live model list. Auth not required.
898pub async fn fetch_openrouter_catalog_models(
899    client: &reqwest::Client,
900) -> Result<Vec<CatalogModel>, String> {
901    let resp = catalog_get(client, "https://openrouter.ai/api/v1/models")
902        .send()
903        .await
904        .map_err(|e| format!("request failed: {e}"))?;
905    let body = read_catalog_response(resp).await?;
906    parse_openrouter_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
907}
908
909/// Fetch catalog models for any registered provider.
910/// OpenRouter uses its rich parser; all others use the generic parser.
911/// Compatible shim: callers that previously used `registry::fetch_provider_models`
912/// and then mapped to `ExpandedModelEntry` can switch to this.
913pub async fn fetch_catalog_models(
914    client: &reqwest::Client,
915    provider_key: &str,
916) -> Result<Vec<CatalogModel>, String> {
917    let provider = catalog_provider_for(provider_key);
918    let models = if provider.provider_key() == "generic" {
919        fetch_generic_catalog_provider_models(provider_key).await?
920    } else {
921        provider.fetch(client).await?
922    };
923    // Retain exact live modality evidence for request-time attachment checks,
924    // including OpenRouter and Copilot (not only the Codex reasoning picker).
925    capability_cache::replace_provider(provider_key, &models);
926    Ok(models)
927}
928
929// ─── Tests ────────────────────────────────────────────────────────────────────
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934
935    // ── Task 1: Normalized catalog contract ──────────────────────────────────
936
937    #[test]
938    fn catalog_model_rejects_empty_ids() {
939        assert!(CatalogModel::new("openrouter", "OpenRouter", "").is_none());
940        assert!(CatalogModel::new("openrouter", "OpenRouter", "   ").is_none());
941    }
942
943    #[test]
944    fn static_seed_sets_fallback_source_and_runtime_id() {
945        let m = from_static_seed("groq", "Groq", "llama-3.3-70b-versatile", "Llama 3.3 70B")
946            .expect("valid seed");
947        assert_eq!(m.runtime_id(), "groq/llama-3.3-70b-versatile");
948        assert_eq!(m.display_label(), "Llama 3.3 70B");
949        assert_eq!(m.source, CatalogSource::StaticFallback);
950    }
951
952    #[test]
953    fn static_seed_empty_label_stores_none() {
954        let m = from_static_seed("groq", "Groq", "model-x", "").expect("valid id");
955        assert_eq!(m.label, None);
956    }
957
958    #[test]
959    fn static_seed_whitespace_label_stores_none() {
960        let m = from_static_seed("groq", "Groq", "model-x", "   ").expect("valid id");
961        assert_eq!(m.label, None);
962    }
963
964    #[test]
965    fn static_seeds_from_spec_converts_all_groq_models() {
966        let spec = super::super::registry::providers()
967            .iter()
968            .find(|s| s.key == "groq")
969            .expect("groq spec");
970        let seeds = static_seeds_from_spec(spec);
971        assert_eq!(seeds.len(), spec.models.len());
972        assert!(seeds
973            .iter()
974            .all(|m| m.source == CatalogSource::StaticFallback));
975        assert!(seeds.iter().all(|m| !m.id.is_empty()));
976        assert!(seeds.iter().all(|m| m.runtime_id().starts_with("groq/")));
977    }
978
979    #[test]
980    fn anthropic_runtime_id_is_provider_qualified() {
981        let mut m = CatalogModel::new("anthropic", "Anthropic", "claude-opus-4-7").unwrap();
982        m.provider_kind = CatalogProviderKind::Anthropic;
983        assert_eq!(m.runtime_id(), "anthropic/claude-opus-4-7");
984    }
985
986    #[test]
987    fn pricing_summary_has_internal_reasoning_cost_zero_is_false() {
988        let p = PricingSummary {
989            prompt: None,
990            completion: None,
991            internal_reasoning: Some("0".to_string()),
992        };
993        assert!(!p.has_internal_reasoning_cost());
994    }
995
996    #[test]
997    fn pricing_summary_has_internal_reasoning_cost_nonzero_is_true() {
998        let p = PricingSummary {
999            prompt: None,
1000            completion: None,
1001            internal_reasoning: Some("0.0000035".to_string()),
1002        };
1003        assert!(p.has_internal_reasoning_cost());
1004    }
1005
1006    #[tokio::test]
1007    async fn ui_catalog_fetch_includes_xai_static_models() {
1008        let models = fetch_catalog_models(&reqwest::Client::new(), "xai-auth")
1009            .await
1010            .expect("static xAI catalog");
1011        assert_eq!(models, xai_static_catalog_models());
1012        assert!(models
1013            .iter()
1014            .all(|model| model.runtime_id().starts_with("xai-auth/")));
1015    }
1016
1017    #[tokio::test]
1018    // Broker-proxied discovery reads ambient credentials via base-dir
1019    // resolution; racing SYNAPS_BASE_DIR mutators made this flaky.
1020    #[serial_test::serial(synaps_base_dir)]
1021    async fn ui_catalog_fetch_github_copilot_returns_prefixed_chat_models() {
1022        // Offline test must not depend on a developer account's live picker.
1023        let _base = crate::test_env::BaseDirGuard::new();
1024        // Runtime ids use github-copilot/<wire-id> and fixture-established IDs.
1025        let models = fetch_catalog_models(&reqwest::Client::new(), "github-copilot")
1026            .await
1027            .expect("GitHub Copilot catalog");
1028        assert!(!models.is_empty());
1029        assert!(models
1030            .iter()
1031            .all(|model| model.runtime_id().starts_with("github-copilot/")));
1032        let ids: std::collections::HashSet<_> = models.iter().map(|m| m.id.as_str()).collect();
1033        // High-value ids from the curated set must be present for this account
1034        // or via static fallback.
1035        for required in ["gpt-5.3-codex", "claude-sonnet-4.6", "gemini-3.5-flash"] {
1036            assert!(ids.contains(required), "missing {required}");
1037        }
1038        assert!(!ids.contains("text-embedding-3-small"));
1039    }
1040
1041    /// fix1 I2a: with NO credentials anywhere (fresh empty base dir), the
1042    /// documented contract — "otherwise the curated static fallback is
1043    /// returned" — must hold DETERMINISTICALLY. The broker's typed
1044    /// credential-miss must map to the static seeds, never to an error.
1045    #[tokio::test]
1046    #[serial_test::serial(synaps_base_dir)]
1047    async fn copilot_catalog_without_credentials_is_deterministic_static_fallback() {
1048        let _base = crate::test_env::BaseDirGuard::new();
1049        let models = fetch_catalog_models(&reqwest::Client::new(), "github-copilot")
1050            .await
1051            .expect("credential-less fetch must fall back to static seeds");
1052        assert_eq!(models, copilot_static_catalog_models());
1053        assert!(models
1054            .iter()
1055            .all(|m| m.runtime_id().starts_with("github-copilot/")));
1056    }
1057
1058    /// fix1 I2a: the fetch outcome must not depend on ambient
1059    /// SYNAPS_BASE_DIR churn. A current-thread churn task flips the base
1060    /// dir between two credential-less roots at every await point while
1061    /// fetches run — every fetch must return the same static catalog.
1062    #[tokio::test]
1063    #[serial_test::serial(synaps_base_dir)]
1064    async fn copilot_catalog_is_deterministic_under_concurrent_base_dir_churn() {
1065        let base_a = tempfile::TempDir::new().unwrap();
1066        let base_b = tempfile::TempDir::new().unwrap();
1067        let old = std::env::var("SYNAPS_BASE_DIR").ok();
1068        std::env::set_var("SYNAPS_BASE_DIR", base_a.path());
1069
1070        let local = tokio::task::LocalSet::new();
1071        local
1072            .run_until(async {
1073                let churn = {
1074                    let a = base_a.path().to_path_buf();
1075                    let b = base_b.path().to_path_buf();
1076                    // Same-thread churn task: env flips interleave at await
1077                    // points only (no cross-thread set_var races).
1078                    tokio::task::spawn_local(async move {
1079                        loop {
1080                            std::env::set_var("SYNAPS_BASE_DIR", &a);
1081                            tokio::task::yield_now().await;
1082                            std::env::set_var("SYNAPS_BASE_DIR", &b);
1083                            tokio::task::yield_now().await;
1084                        }
1085                    })
1086                };
1087                for _ in 0..20 {
1088                    let models = fetch_catalog_models(&reqwest::Client::new(), "github-copilot")
1089                        .await
1090                        .expect("fetch must be deterministic under base-dir churn");
1091                    assert_eq!(models, copilot_static_catalog_models());
1092                    tokio::task::yield_now().await;
1093                }
1094                churn.abort();
1095            })
1096            .await;
1097
1098        match old {
1099            Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
1100            None => std::env::remove_var("SYNAPS_BASE_DIR"),
1101        }
1102    }
1103
1104    #[test]
1105    fn github_copilot_static_catalog_is_available_without_network() {
1106        let models = copilot_static_catalog_models();
1107        assert_eq!(models.len(), COPILOT_FALLBACK_MODELS.len());
1108        assert!(models
1109            .iter()
1110            .all(|m| m.source == CatalogSource::StaticFallback));
1111    }
1112
1113    #[test]
1114    fn catalog_provider_trait_dispatch_selects_specialized_handlers() {
1115        assert_eq!(
1116            catalog_provider_for("openrouter").provider_key(),
1117            "openrouter"
1118        );
1119        assert_eq!(catalog_provider_for("groq").provider_key(), "groq");
1120        assert_eq!(catalog_provider_for("nvidia").provider_key(), "nvidia");
1121        assert_eq!(catalog_provider_for("claude").provider_key(), "claude");
1122        assert_eq!(
1123            catalog_provider_for("openai-codex").provider_key(),
1124            "openai-codex"
1125        );
1126        assert_eq!(catalog_provider_for("xai-auth").provider_key(), "xai-auth");
1127        assert_eq!(
1128            catalog_provider_for("github-copilot").provider_key(),
1129            "github-copilot"
1130        );
1131        assert_eq!(catalog_provider_for("cerebras").provider_key(), "generic");
1132    }
1133
1134    #[test]
1135    fn catalog_request_timeout_is_bounded() {
1136        assert_eq!(CATALOG_REQUEST_TIMEOUT, Duration::from_secs(15));
1137    }
1138
1139    #[test]
1140    fn anthropic_page_metadata_is_exposed_for_pagination() {
1141        let page = parse_anthropic_catalog_page(
1142            r#"{
1143            "data":[{"id":"claude-opus-4-7"}],
1144            "has_more": true,
1145            "last_id": "claude-opus-4-7"
1146        }"#,
1147        )
1148        .expect("parse page");
1149        assert!(page.has_more);
1150        assert_eq!(page.last_id.as_deref(), Some("claude-opus-4-7"));
1151        assert_eq!(page.models.len(), 1);
1152    }
1153
1154    #[test]
1155    fn anthropic_pagination_url_adds_after_id_cursor() {
1156        assert_eq!(
1157            anthropic_models_url(None),
1158            "https://api.anthropic.com/v1/models?limit=100"
1159        );
1160        assert_eq!(
1161            anthropic_models_url(Some("claude-opus-4-7")),
1162            "https://api.anthropic.com/v1/models?limit=100&after_id=claude-opus-4-7"
1163        );
1164    }
1165
1166    #[test]
1167    fn merge_catalog_pages_dedupes_by_id() {
1168        let first =
1169            parse_anthropic_catalog_models(r#"{"data":[{"id":"claude-opus-4-7"}]}"#).unwrap();
1170        let second = parse_anthropic_catalog_models(
1171            r#"{"data":[{"id":"claude-opus-4-7"},{"id":"claude-sonnet-4-6"}]}"#,
1172        )
1173        .unwrap();
1174        let merged = merge_catalog_pages(vec![first, second]);
1175        assert_eq!(merged.len(), 2);
1176        assert_eq!(merged[0].id, "claude-opus-4-7");
1177        assert_eq!(merged[1].id, "claude-sonnet-4-6");
1178    }
1179
1180    // ── Task 2: OpenRouter rich catalog handler ───────────────────────────────
1181
1182    mod openrouter {
1183        use super::super::*;
1184
1185        const RICH_FIXTURE: &str = r#"{
1186          "data": [
1187            {
1188              "id": "qwen/qwen3-coder",
1189              "name": "Qwen: Qwen3 Coder",
1190              "context_length": 131072,
1191              "top_provider": { "max_completion_tokens": 32768 },
1192              "supported_parameters": ["temperature", "top_p", "max_tokens"],
1193              "pricing": { "prompt": "0.0000001", "completion": "0.0000004", "internal_reasoning": "0" },
1194              "architecture": { "input_modalities": ["text"] }
1195            },
1196            {
1197              "id": "anthropic/claude-opus-4-7",
1198              "name": "Anthropic: Claude Opus 4.7",
1199              "context_length": 200000,
1200              "supported_parameters": ["temperature", "verbosity", "max_tokens"],
1201              "pricing": { "prompt": "0.000015", "completion": "0.000075" }
1202            },
1203            {
1204              "id": "openai/o4-mini",
1205              "name": "OpenAI: o4-mini",
1206              "context_length": 128000,
1207              "supported_parameters": ["reasoning_effort", "max_tokens"],
1208              "pricing": { "prompt": "0.0000011", "completion": "0.0000044" }
1209            },
1210            {
1211              "id": "google/gemini-2.5-flash",
1212              "name": "Google: Gemini 2.5 Flash",
1213              "context_length": 1048576,
1214              "supported_parameters": ["reasoning", "include_reasoning", "max_tokens"],
1215              "pricing": { "prompt": "0.00000015", "completion": "0.0000035", "internal_reasoning": "0.0000035" },
1216              "architecture": { "input_modalities": ["text", "image", "audio", "video"] }
1217            },
1218            {
1219              "id": "",
1220              "name": "Empty — must be filtered"
1221            }
1222          ]
1223        }"#;
1224
1225        #[test]
1226        fn parses_minimal_model() {
1227            let json = r#"{"data":[{"id":"test/model","name":"Test Model"}]}"#;
1228            let models = parse_openrouter_catalog_models(json).expect("parse ok");
1229            assert_eq!(models.len(), 1);
1230            assert_eq!(models[0].id, "test/model");
1231            assert_eq!(models[0].label.as_deref(), Some("Test Model"));
1232            assert_eq!(models[0].runtime_id(), "openrouter/test/model");
1233            assert_eq!(models[0].provider_key, "openrouter");
1234            assert_eq!(models[0].source, CatalogSource::Live);
1235        }
1236
1237        #[test]
1238        fn parses_context_length_and_max_output() {
1239            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1240            let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
1241            assert_eq!(qwen.context_tokens, Some(131_072));
1242            assert_eq!(qwen.max_output_tokens, Some(32_768));
1243        }
1244
1245        #[test]
1246        fn filters_empty_ids() {
1247            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1248            assert!(!models.iter().any(|m| m.id.is_empty()));
1249            assert_eq!(models.len(), 4);
1250        }
1251
1252        #[test]
1253        fn parses_pricing_fields() {
1254            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1255            let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
1256            assert_eq!(qwen.pricing.prompt.as_deref(), Some("0.0000001"));
1257            assert_eq!(qwen.pricing.completion.as_deref(), Some("0.0000004"));
1258            assert!(!qwen.pricing.has_internal_reasoning_cost());
1259        }
1260
1261        #[test]
1262        fn parses_internal_reasoning_cost_flag() {
1263            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1264            let gemini = models
1265                .iter()
1266                .find(|m| m.id == "google/gemini-2.5-flash")
1267                .unwrap();
1268            assert!(gemini.pricing.has_internal_reasoning_cost());
1269        }
1270
1271        #[test]
1272        fn no_reasoning_params_maps_to_none() {
1273            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1274            let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
1275            assert_eq!(qwen.reasoning, ReasoningSupport::None);
1276        }
1277
1278        #[test]
1279        fn verbosity_param_maps_to_anthropic_adaptive() {
1280            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1281            let claude = models
1282                .iter()
1283                .find(|m| m.id == "anthropic/claude-opus-4-7")
1284                .unwrap();
1285            assert_eq!(
1286                claude.reasoning,
1287                ReasoningSupport::AnthropicAdaptive { adaptive: true }
1288            );
1289        }
1290
1291        #[test]
1292        fn reasoning_effort_maps_to_openrouter_reasoning() {
1293            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1294            let o4 = models.iter().find(|m| m.id == "openai/o4-mini").unwrap();
1295            assert_eq!(
1296                o4.reasoning,
1297                ReasoningSupport::OpenRouter {
1298                    include_reasoning: false,
1299                    effort: true,
1300                    verbosity: false,
1301                    internal_reasoning_priced: false,
1302                }
1303            );
1304        }
1305
1306        #[test]
1307        fn reasoning_include_reasoning_maps_correctly() {
1308            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1309            let gemini = models
1310                .iter()
1311                .find(|m| m.id == "google/gemini-2.5-flash")
1312                .unwrap();
1313            assert_eq!(
1314                gemini.reasoning,
1315                ReasoningSupport::OpenRouter {
1316                    include_reasoning: true,
1317                    effort: false,
1318                    verbosity: false,
1319                    internal_reasoning_priced: true,
1320                }
1321            );
1322        }
1323
1324        #[test]
1325        fn parses_multimodal_input() {
1326            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
1327            let gemini = models
1328                .iter()
1329                .find(|m| m.id == "google/gemini-2.5-flash")
1330                .unwrap();
1331            assert!(gemini.input_modalities.contains(&Modality::Text));
1332            assert!(gemini.input_modalities.contains(&Modality::Image));
1333            assert!(gemini.input_modalities.contains(&Modality::Audio));
1334            assert!(gemini.input_modalities.contains(&Modality::Video));
1335        }
1336
1337        #[test]
1338        fn missing_modalities_defaults_to_text() {
1339            let json = r#"{"data":[{"id":"test/model"}]}"#;
1340            let models = parse_openrouter_catalog_models(json).expect("parse ok");
1341            assert_eq!(models[0].input_modalities, vec![Modality::Text]);
1342        }
1343
1344        #[test]
1345        fn parses_openrouter_rich_metadata_and_reasoning_flags() {
1346            // Backward-compatible name used by existing test
1347            let json = r#"{
1348              "data": [{
1349                "id": "anthropic/claude-sonnet-4.6",
1350                "name": "Anthropic: Claude Sonnet 4.6",
1351                "context_length": 1000000,
1352                "architecture": { "input_modalities": ["text", "image"] },
1353                "pricing": { "prompt": "0.000003", "completion": "0.000015", "internal_reasoning": "0.000012" },
1354                "top_provider": { "max_completion_tokens": 128000 },
1355                "supported_parameters": ["reasoning", "include_reasoning", "verbosity", "tools"]
1356              }]
1357            }"#;
1358            let models = parse_openrouter_catalog_models(json).expect("parse ok");
1359            assert_eq!(models.len(), 1);
1360            let m = &models[0];
1361            assert_eq!(m.runtime_id(), "openrouter/anthropic/claude-sonnet-4.6");
1362            assert_eq!(m.context_tokens, Some(1_000_000));
1363            assert_eq!(m.max_output_tokens, Some(128_000));
1364            assert!(m.input_modalities.contains(&Modality::Image));
1365            // verbosity wins → AnthropicAdaptive
1366            assert_eq!(
1367                m.reasoning,
1368                ReasoningSupport::AnthropicAdaptive { adaptive: true }
1369            );
1370        }
1371
1372        #[test]
1373        fn parses_openrouter_non_reasoning_model_as_none() {
1374            let json = r#"{
1375              "data": [{
1376                "id": "meta-llama/llama-3.3-70b-instruct",
1377                "name": "Meta: Llama 3.3 70B",
1378                "supported_parameters": ["temperature", "tools"]
1379              }]
1380            }"#;
1381            let models = parse_openrouter_catalog_models(json).expect("parse ok");
1382            assert_eq!(models[0].reasoning, ReasoningSupport::None);
1383        }
1384
1385        #[test]
1386        fn invalid_json_returns_error() {
1387            assert!(parse_openrouter_catalog_models("{not json}").is_err());
1388        }
1389
1390        #[test]
1391        fn missing_data_key_returns_error() {
1392            assert!(parse_openrouter_catalog_models(r#"{"models":[]}"#).is_err());
1393        }
1394    }
1395
1396    // ── Task 3: Generic handler / compat with registry ────────────────────────
1397
1398    // ── Task 5: Anthropic parser and Codex static catalog ───────────────────
1399
1400    mod anthropic {
1401        use super::super::*;
1402
1403        #[test]
1404        fn parser_reads_optional_capabilities_and_token_limits() {
1405            let json = r#"{
1406                "data": [{
1407                    "id": "claude-opus-4-7",
1408                    "display_name": "Claude Opus 4.7",
1409                    "max_input_tokens": 200000,
1410                    "max_tokens": 32000,
1411                    "capabilities": {
1412                        "thinking": { "supported": true },
1413                        "effort": { "supported": true }
1414                    }
1415                }],
1416                "has_more": false
1417            }"#;
1418            let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
1419            assert_eq!(models.len(), 1);
1420            let model = &models[0];
1421            assert_eq!(model.runtime_id(), "anthropic/claude-opus-4-7");
1422            assert_eq!(model.label.as_deref(), Some("Claude Opus 4.7"));
1423            assert_eq!(model.context_tokens, Some(200_000));
1424            assert_eq!(model.max_output_tokens, Some(32_000));
1425            assert_eq!(
1426                model.reasoning,
1427                ReasoningSupport::AnthropicAdaptive { adaptive: true }
1428            );
1429        }
1430
1431        #[test]
1432        fn parser_tolerates_missing_capabilities_as_unknown() {
1433            let json =
1434                r#"{"data":[{"id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku"}]}"#;
1435            let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
1436            assert_eq!(models[0].reasoning, ReasoningSupport::Unknown);
1437        }
1438
1439        #[test]
1440        fn parser_filters_empty_ids() {
1441            let json = r#"{"data":[{"id":""},{"id":"claude-sonnet-4-6"}]}"#;
1442            let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
1443            assert_eq!(models.len(), 1);
1444            assert_eq!(models[0].id, "claude-sonnet-4-6");
1445        }
1446    }
1447
1448    mod codex {
1449        use super::super::*;
1450
1451        #[test]
1452        fn static_catalog_uses_fallback_source_and_prefixed_runtime_ids() {
1453            let models = codex_static_catalog_models();
1454            assert!(models.iter().any(|m| m.id == "gpt-5.5"));
1455            assert!(models.iter().any(|m| m.id == "gpt-5.4"));
1456            assert!(models.iter().any(|m| m.id == "gpt-5.4-mini"));
1457            assert!(!models.iter().any(|m| m.id == "gpt-5.5-pro"));
1458            assert!(!models.iter().any(|m| m.id == "gpt-5.4-nano"));
1459            assert!(!models.iter().any(|m| m.id == "gpt-5.1-codex-mini"));
1460            assert!(models
1461                .iter()
1462                .all(|m| m.source == CatalogSource::StaticFallback));
1463            assert!(models
1464                .iter()
1465                .all(|m| m.runtime_id().starts_with("openai-codex/")));
1466        }
1467
1468        #[test]
1469        fn live_parser_filters_non_list_visibility_from_fixture() {
1470            let fixture = include_str!("fixtures/openai_codex_models.json");
1471            let models = parse_codex_catalog_models(fixture).expect("parse");
1472            let ids: std::collections::HashSet<_> = models.iter().map(|m| m.id.as_str()).collect();
1473            assert!(ids.contains("gpt-5.6-sol"));
1474            assert!(ids.contains("gpt-5.3-codex-spark"));
1475            assert!(!ids.contains("codex-auto-review"));
1476            assert!(!ids.contains("codex-internal-eval"));
1477            assert!(models.iter().all(|m| m.source == CatalogSource::Live));
1478        }
1479
1480        #[test]
1481        fn models_path_carries_pinned_codex_protocol_version() {
1482            let path = codex_models_path(codex::CODEX_CLIENT_VERSION);
1483            assert!(path.starts_with("/codex/models?client_version="));
1484            assert!(path.ends_with(codex::CODEX_CLIENT_VERSION));
1485            // The crate version must never leak onto the wire: the backend
1486            // treats it as an unknown client and returns an empty catalog.
1487            assert!(!path.contains(env!("CARGO_PKG_VERSION")));
1488        }
1489
1490        #[test]
1491        fn pinned_codex_protocol_version_meets_astra_floor() {
1492            // gpt-6-astra publishes minimal_client_version 0.153.0, but the
1493            // live backend only lists gpt-6.1-sol from client_version 0.159.0.
1494            let parse = |v: &str| -> (u64, u64, u64) {
1495                let mut it = v.split('.').map(|p| p.parse::<u64>().expect("numeric"));
1496                (
1497                    it.next().unwrap(),
1498                    it.next().unwrap(),
1499                    it.next().unwrap_or(0),
1500                )
1501            };
1502            assert!(parse(codex::CODEX_CLIENT_VERSION) >= parse("0.159.0"));
1503        }
1504
1505        #[test]
1506        fn missing_credential_fallback_classifies_real_broker_not_configured_text() {
1507            let not_configured = format!(
1508                "request failed: {}",
1509                crate::auth::BrokerError::NotConfigured("openai-codex".into())
1510            );
1511            assert!(
1512                is_missing_credential_catalog_error(&not_configured),
1513                "got: {not_configured}"
1514            );
1515            let unknown = format!(
1516                "request failed: {}",
1517                crate::auth::BrokerError::UnknownProvider("openai-codex".into())
1518            );
1519            assert!(
1520                is_missing_credential_catalog_error(&unknown),
1521                "got: {unknown}"
1522            );
1523        }
1524
1525        #[test]
1526        fn missing_credential_fallback_classifies_exact_broker_credential_no_credentials_text() {
1527            // Production path: LocalBroker::access_token maps
1528            // ensure_fresh_provider_token's load miss through BrokerError::Credential.
1529            // Display form is exactly:
1530            //   credential error: No credentials for {provider} at {path}. Run `synaps login`.
1531            let raw = crate::auth::BrokerError::Credential(format!(
1532                "No credentials for openai-codex at {}. Run `synaps login`.",
1533                std::path::Path::new("/tmp/auth.json").display()
1534            ));
1535            let wrapped = format!("request failed: {raw}");
1536            assert!(
1537                is_missing_credential_catalog_error(&wrapped),
1538                "exact BrokerError::Credential missing-login text must fall back to static seeds; got: {wrapped}"
1539            );
1540            // Prefix-stable form (path/provider vary; Display prefix does not).
1541            assert!(is_missing_credential_catalog_error(
1542                "request failed: credential error: No credentials for openai-codex at /home/user/.synaps/auth.json. Run `synaps login`."
1543            ));
1544        }
1545
1546        /// fix1 I2a: the TYPED classifier is the authoritative fallback
1547        /// decision. No-live-session broker variants map to
1548        /// NotAuthenticated; everything operational maps to Failed.
1549        #[test]
1550        fn typed_classifier_maps_auth_misses_to_fallback_and_operations_to_failed() {
1551            use crate::auth::BrokerError as B;
1552            let auth_misses = [
1553                B::NotConfigured("github-copilot".into()),
1554                B::UnknownProvider("github-copilot".into()),
1555                B::RegistrationRequired {
1556                    provider: "github-copilot".into(),
1557                    remediation: "run synaps login".into(),
1558                },
1559                // Pinned token-store load-miss prefix (the one string
1560                // dependency, asserted here so token.rs drift breaks THIS
1561                // test instead of production determinism).
1562                B::Credential(
1563                    "No credentials for github-copilot at /tmp/x/auth.json. Run `synaps login`."
1564                        .into(),
1565                ),
1566            ];
1567            for err in auth_misses {
1568                assert!(
1569                    matches!(
1570                        classify_catalog_broker_error(err.clone()),
1571                        CatalogBrokerError::NotAuthenticated(_)
1572                    ),
1573                    "must classify as no-live-session: {err}"
1574                );
1575            }
1576
1577            let operational = [
1578                B::Transport("connection reset".into()),
1579                B::Denied("proxy path not allowlisted".into()),
1580                B::Unauthorized,
1581                B::Credential("github-copilot credential is missing chatgpt account id".into()),
1582                B::UnsupportedCapability {
1583                    provider: "github-copilot".into(),
1584                    capability: "tools".into(),
1585                },
1586            ];
1587            for err in operational {
1588                assert!(
1589                    matches!(
1590                        classify_catalog_broker_error(err.clone()),
1591                        CatalogBrokerError::Failed(_)
1592                    ),
1593                    "must classify as operational failure: {err}"
1594                );
1595            }
1596        }
1597
1598        #[test]
1599        fn missing_credential_fallback_does_not_hide_transport_http_parse_or_account_errors() {
1600            for err in [
1601                "request failed: broker transport error: connection reset",
1602                "model list failed: HTTP 401",
1603                "model list failed: HTTP 500",
1604                "parse failed: missing field `models`",
1605                "request failed: broker denied request: proxy path '/models' is not in the provider's endpoint allowlist",
1606                "request failed: credential error: openai-codex credential is missing chatgpt account id",
1607                "request failed: broker transport error: provider request failed: 403 Forbidden",
1608            ] {
1609                assert!(
1610                    !is_missing_credential_catalog_error(err),
1611                    "must not fallback for: {err}"
1612                );
1613            }
1614        }
1615    }
1616
1617    mod anthropic_paths {
1618        use super::super::*;
1619
1620        #[test]
1621        fn proxy_path_round_trips_limit_and_after_id() {
1622            assert_eq!(anthropic_models_proxy_path(None), "/v1/models?limit=100");
1623            assert_eq!(
1624                anthropic_models_proxy_path(Some("claude-opus-4-7")),
1625                "/v1/models?limit=100&after_id=claude-opus-4-7"
1626            );
1627            assert_eq!(
1628                anthropic_models_proxy_path(Some("")),
1629                "/v1/models?limit=100"
1630            );
1631        }
1632    }
1633
1634    // ── Task 4: Groq and NVIDIA enrichment ──────────────────────────────────
1635
1636    mod groq {
1637        use super::super::*;
1638
1639        #[test]
1640        fn parser_extracts_context_window_and_filters_inactive() {
1641            let json = r#"{"data":[
1642                {"id":"llama-3.3-70b-versatile","active":true,"context_window":131072,"owned_by":"Meta"},
1643                {"id":"old-model-v1","active":false,"context_window":8192,"owned_by":"Meta"}
1644            ]}"#;
1645            let models = parse_groq_catalog_models(json).expect("parse groq");
1646            assert_eq!(models.len(), 1);
1647            assert_eq!(models[0].id, "llama-3.3-70b-versatile");
1648            assert_eq!(models[0].context_tokens, Some(131_072));
1649            assert_eq!(models[0].reasoning, ReasoningSupport::None);
1650        }
1651
1652        #[test]
1653        fn inference_maps_reasoning_families() {
1654            assert_eq!(
1655                infer_groq_reasoning("openai/gpt-oss-120b"),
1656                ReasoningSupport::GroqReasoning
1657            );
1658            assert_eq!(
1659                infer_groq_reasoning("qwen/qwen3-32b"),
1660                ReasoningSupport::GroqReasoning
1661            );
1662            assert_eq!(
1663                infer_groq_reasoning("groq/compound-mini"),
1664                ReasoningSupport::GroqReasoning
1665            );
1666            assert_eq!(
1667                infer_groq_reasoning("llama-3.3-70b-versatile"),
1668                ReasoningSupport::None
1669            );
1670        }
1671
1672        #[test]
1673        fn parser_filters_empty_ids() {
1674            let json = r#"{"data":[{"id":"","active":true},{"id":"openai/gpt-oss-20b","active":true,"context_window":131072}]}"#;
1675            let models = parse_groq_catalog_models(json).expect("parse groq");
1676            assert_eq!(models.len(), 1);
1677            assert_eq!(models[0].id, "openai/gpt-oss-20b");
1678        }
1679    }
1680
1681    mod nvidia {
1682        use super::super::*;
1683
1684        #[test]
1685        fn parser_dedupes_and_enriches_known_context() {
1686            let json = r#"{"data":[
1687                {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
1688                {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
1689                {"id":"moonshotai/kimi-k2-thinking","owned_by":"moonshotai"}
1690            ]}"#;
1691            let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
1692            assert_eq!(models.len(), 2);
1693            let ultra = models.iter().find(|m| m.id.contains("ultra")).unwrap();
1694            assert_eq!(ultra.context_tokens, Some(128_000));
1695            assert_eq!(ultra.reasoning, ReasoningSupport::NvidiaInlineThinking);
1696            let kimi = models.iter().find(|m| m.id.contains("kimi")).unwrap();
1697            assert_eq!(kimi.context_tokens, Some(256_000));
1698        }
1699
1700        #[test]
1701        fn inference_detects_thinking_and_standard_models() {
1702            assert_eq!(
1703                infer_nvidia_reasoning("qwen/qwen3-next-80b-a3b-thinking"),
1704                ReasoningSupport::NvidiaInlineThinking
1705            );
1706            assert_eq!(
1707                infer_nvidia_reasoning("nvidia/cosmos-reason2-8b"),
1708                ReasoningSupport::NvidiaInlineThinking
1709            );
1710            assert_eq!(
1711                infer_nvidia_reasoning("meta/llama-3.3-70b-instruct"),
1712                ReasoningSupport::None
1713            );
1714        }
1715
1716        #[test]
1717        fn parser_filters_empty_ids() {
1718            let json = r#"{"data":[{"id":""},{"id":"meta/llama-3.3-70b-instruct"}]}"#;
1719            let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
1720            assert_eq!(models.len(), 1);
1721            assert_eq!(models[0].id, "meta/llama-3.3-70b-instruct");
1722        }
1723    }
1724
1725    mod generic_compat {
1726        use super::super::*;
1727
1728        #[test]
1729        fn parses_generic_catalog_models_and_filters_empty_ids() {
1730            let json = r#"{
1731                "data": [
1732                    { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
1733                    { "id": "" },
1734                    { "id": "openai/gpt-oss-120b" }
1735                ]
1736            }"#;
1737            let models =
1738                parse_generic_catalog_models(json, "openrouter", "OpenRouter").expect("parse ok");
1739            assert_eq!(models.len(), 2);
1740            assert_eq!(models[0].runtime_id(), "openrouter/qwen/qwen3-coder");
1741            assert_eq!(models[0].display_label(), "Qwen: Qwen3 Coder");
1742            assert_eq!(models[1].display_label(), "openai/gpt-oss-120b");
1743        }
1744
1745        #[test]
1746        fn whitespace_only_id_is_filtered() {
1747            let json = r#"{"data":[{"id":"   "},{"id":"valid"}]}"#;
1748            let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
1749            assert_eq!(models.len(), 1);
1750            assert_eq!(models[0].id, "valid");
1751        }
1752
1753        #[test]
1754        fn whitespace_label_stored_as_none() {
1755            let json = r#"{"data":[{"id":"m1","name":"   "}]}"#;
1756            let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
1757            assert_eq!(models[0].label, None);
1758        }
1759
1760        #[test]
1761        fn generic_catalog_source_is_live() {
1762            let json = r#"{"data":[{"id":"m1","name":"Model One"}]}"#;
1763            let models =
1764                parse_generic_catalog_models(json, "testprovider", "Test").expect("parse ok");
1765            assert_eq!(models[0].source, CatalogSource::Live);
1766        }
1767
1768        #[test]
1769        fn generic_reasoning_is_generic_open_ai() {
1770            let json = r#"{"data":[{"id":"m1"}]}"#;
1771            let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
1772            assert_eq!(models[0].reasoning, ReasoningSupport::GenericOpenAi);
1773        }
1774
1775        #[test]
1776        fn generic_parse_matches_legacy_parse_behavior() {
1777            // parse_generic_catalog_models should produce same ids/labels
1778            // as the legacy registry::parse_provider_models_response
1779            let json = r#"{
1780                "data": [
1781                    { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
1782                    { "id": "openai/gpt-oss-120b" }
1783                ]
1784            }"#;
1785            let legacy = super::super::super::registry::parse_provider_models_response(json)
1786                .expect("legacy parse ok");
1787            let catalog = parse_generic_catalog_models(json, "openrouter", "OpenRouter")
1788                .expect("catalog parse ok");
1789            assert_eq!(legacy.len(), catalog.len());
1790            for (l, c) in legacy.iter().zip(catalog.iter()) {
1791                assert_eq!(l.id, c.id, "ids must match");
1792                assert_eq!(l.name, c.label, "labels must match");
1793            }
1794        }
1795
1796        #[test]
1797        fn static_seeds_from_spec_all_providers() {
1798            // Every registered provider should produce at least one seed
1799            for spec in super::super::super::registry::providers() {
1800                if spec.models.is_empty() {
1801                    continue;
1802                }
1803                let seeds = super::super::static_seeds_from_spec(spec);
1804                assert!(!seeds.is_empty(), "no seeds for {}", spec.key);
1805                assert!(
1806                    seeds
1807                        .iter()
1808                        .all(|m| m.runtime_id().starts_with(&format!("{}/", spec.key))),
1809                    "runtime_id prefix wrong for {}",
1810                    spec.key
1811                );
1812            }
1813        }
1814    }
1815}
1816
1817#[cfg(test)]
1818mod muse_warmup_tests {
1819    //! `ensure_muse_catalog_for`: at most one catalog GET (backed off)
1820    //! through the given broker, only for a well-formed `muse/` id with no
1821    //! live row in this process; never affects routing.
1822    use super::*;
1823    use crate::auth::{
1824        AccessToken, BrokerError, CredentialBroker, ProxyByteStream, ProxyMethod, ProxyRequest,
1825        ProxyResponse,
1826    };
1827    use std::sync::{Arc, Mutex};
1828
1829    struct CatalogBroker {
1830        body: Result<&'static str, BrokerError>,
1831        seen: Mutex<Vec<ProxyRequest>>,
1832    }
1833
1834    #[async_trait::async_trait]
1835    impl CredentialBroker for CatalogBroker {
1836        async fn access_token(
1837            &self,
1838            _: crate::auth::OAuthProviderId,
1839        ) -> Result<AccessToken, BrokerError> {
1840            unreachable!("warm-up never vends a credential to the runtime")
1841        }
1842        async fn proxy(&self, request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
1843            self.seen.lock().unwrap().push(request);
1844            self.body.clone().map(|body| ProxyResponse {
1845                status: 200,
1846                body: body.to_string(),
1847            })
1848        }
1849        async fn proxy_stream(&self, _: ProxyRequest) -> Result<ProxyByteStream, BrokerError> {
1850            unreachable!()
1851        }
1852        async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
1853            unreachable!()
1854        }
1855        async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
1856            Ok(vec![])
1857        }
1858    }
1859
1860    fn broker(body: Result<&'static str, BrokerError>) -> Arc<CatalogBroker> {
1861        Arc::new(CatalogBroker {
1862            body,
1863            seen: Mutex::new(Vec::new()),
1864        })
1865    }
1866
1867    fn run(model: &str, broker: &Arc<CatalogBroker>) {
1868        let dyn_broker: Arc<dyn CredentialBroker> = broker.clone();
1869        tokio::runtime::Builder::new_current_thread()
1870            .enable_all()
1871            .build()
1872            .unwrap()
1873            .block_on(ensure_muse_catalog_for(model, &dyn_broker));
1874    }
1875
1876    fn fresh() {
1877        capability_cache::replace_provider(MUSE_PROVIDER_KEY, &[]);
1878        reset_muse_warmup_backoff();
1879    }
1880
1881    const CATALOG: &str = r#"{"models":[{"id":"muse-warmup-probe-zz7","display_order":1,
1882        "reasoning_effort_variants":[{"effort":"high"}]}]}"#;
1883
1884    #[test]
1885    fn uncached_id_loads_live_evidence_once_through_the_given_broker() {
1886        let _guard = muse::test_cache_guard();
1887        fresh();
1888        let model = "muse/muse-warmup-probe-zz7";
1889        // Routes before any catalog (shape-based), but no effort evidence.
1890        assert!(crate::runtime::openai::resolve_route(model).is_some());
1891        assert_eq!(muse_supported_levels("muse-warmup-probe-zz7"), None);
1892
1893        let b = broker(Ok(CATALOG));
1894        run(model, &b);
1895        {
1896            let seen = b.seen.lock().unwrap();
1897            assert_eq!(seen.len(), 1);
1898            assert_eq!(seen[0].provider, "muse");
1899            assert_eq!(seen[0].method, ProxyMethod::Get);
1900            assert_eq!(seen[0].path, MUSE_MODELS_PATH);
1901            assert!(seen[0].body.is_none());
1902        }
1903        assert_eq!(
1904            muse_supported_levels("muse-warmup-probe-zz7"),
1905            Some(vec![agent_core::reasoning::ReasoningLevel::High])
1906        );
1907
1908        // Live row now known → no second fetch, even with the backoff reset.
1909        reset_muse_warmup_backoff();
1910        run(model, &b);
1911        assert_eq!(b.seen.lock().unwrap().len(), 1);
1912        fresh();
1913    }
1914
1915    #[test]
1916    fn warmup_is_backed_off_and_skips_non_candidates() {
1917        let _guard = muse::test_cache_guard();
1918        fresh();
1919        // Id absent from the live catalog: one fetch, then backed off — an
1920        // unknown id can never turn every turn into a catalog request.
1921        let b = broker(Ok(CATALOG));
1922        run("muse/not-in-live-catalog-zz8", &b);
1923        run("muse/not-in-live-catalog-zz8", &b);
1924        run("muse/another-missing-zz9", &b);
1925        assert_eq!(b.seen.lock().unwrap().len(), 1);
1926
1927        // No network for: non-Muse models and malformed ids.
1928        fresh();
1929        let idle = broker(Ok(CATALOG));
1930        for model in [
1931            "xai-auth/grok-4.5",
1932            "anthropic/claude-x",
1933            "muse/Bad Id",
1934            "muse/../x",
1935            "muse/",
1936        ] {
1937            run(model, &idle);
1938        }
1939        assert!(idle.seen.lock().unwrap().is_empty());
1940
1941        // Not logged in / broker failure: swallowed, attempt still backed off.
1942        for err in [
1943            BrokerError::NotConfigured("muse".into()),
1944            BrokerError::Transport("down".into()),
1945        ] {
1946            fresh();
1947            let failing = broker(Err(err));
1948            run("muse/muse-warmup-probe-zz7", &failing);
1949            run("muse/muse-warmup-probe-zz7", &failing);
1950            assert_eq!(failing.seen.lock().unwrap().len(), 1);
1951            assert_eq!(muse_supported_levels("muse-warmup-probe-zz7"), None);
1952        }
1953        fresh();
1954    }
1955
1956    /// Broker whose catalog GET never answers.
1957    struct HangingBroker;
1958
1959    #[async_trait::async_trait]
1960    impl CredentialBroker for HangingBroker {
1961        async fn access_token(
1962            &self,
1963            _: crate::auth::OAuthProviderId,
1964        ) -> Result<AccessToken, BrokerError> {
1965            unreachable!()
1966        }
1967        async fn proxy(&self, _: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
1968            std::future::pending().await
1969        }
1970        async fn proxy_stream(&self, _: ProxyRequest) -> Result<ProxyByteStream, BrokerError> {
1971            unreachable!()
1972        }
1973        async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
1974            unreachable!()
1975        }
1976        async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
1977            Ok(vec![])
1978        }
1979    }
1980
1981    /// Round-2 m-e: a warm-up dropped mid-fetch (turn aborted) must not keep
1982    /// the 300s in-flight claim; it falls back to the short backoff.
1983    #[test]
1984    fn cancelled_warmup_falls_back_to_the_short_backoff() {
1985        let _guard = muse::test_cache_guard();
1986        fresh();
1987        let dyn_broker: Arc<dyn CredentialBroker> = Arc::new(HangingBroker);
1988        tokio::runtime::Builder::new_current_thread()
1989            .enable_all()
1990            .build()
1991            .unwrap()
1992            .block_on(async {
1993                let warm = ensure_muse_catalog_for("muse/muse-warmup-probe-zz7", &dyn_broker);
1994                assert!(
1995                    tokio::time::timeout(std::time::Duration::from_millis(50), warm)
1996                        .await
1997                        .is_err(),
1998                    "the fetch hangs and is dropped"
1999                );
2000            });
2001        let wait = muse_warmup_retry_in().expect("backed off");
2002        assert!(wait <= std::time::Duration::from_secs(30), "{wait:?}");
2003        fresh();
2004    }
2005
2006    /// 216 I5: one failed warm-up must not block Muse turns that use a
2007    /// saved effort level for the full 5 minutes. A failure backs off
2008    /// briefly; only a successful load backs off long.
2009    #[test]
2010    fn failed_warmup_backs_off_briefly_and_success_backs_off_long() {
2011        let _guard = muse::test_cache_guard();
2012        for err in [
2013            BrokerError::Transport("down".into()),
2014            BrokerError::NotConfigured("muse".into()),
2015        ] {
2016            fresh();
2017            run("muse/muse-warmup-probe-zz7", &broker(Err(err)));
2018            let wait = muse_warmup_retry_in().expect("backed off");
2019            assert!(wait <= std::time::Duration::from_secs(30), "{wait:?}");
2020            assert!(wait >= std::time::Duration::from_secs(10), "{wait:?}");
2021        }
2022        // Unparseable catalog: also a failure.
2023        fresh();
2024        run("muse/muse-warmup-probe-zz7", &broker(Ok("not json")));
2025        assert!(muse_warmup_retry_in().unwrap() <= std::time::Duration::from_secs(30));
2026
2027        fresh();
2028        run("muse/not-in-live-catalog-zz8", &broker(Ok(CATALOG)));
2029        let wait = muse_warmup_retry_in().expect("backed off");
2030        assert!(wait > std::time::Duration::from_secs(240), "{wait:?}");
2031        fresh();
2032    }
2033}