Skip to main content

synaps_cli/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::collections::BTreeMap;
26use std::future::Future;
27use std::pin::Pin;
28use std::time::Duration;
29
30pub const CATALOG_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
31const ANTHROPIC_MODELS_MAX_PAGES: usize = 20;
32
33mod anthropic;
34mod codex;
35mod generic;
36mod groq;
37mod nvidia;
38mod openrouter;
39
40pub use anthropic::{
41    anthropic_models_url, merge_catalog_pages, parse_anthropic_catalog_models,
42    parse_anthropic_catalog_page, AnthropicCatalogPage,
43};
44pub use codex::codex_static_catalog_models;
45pub use generic::parse_generic_catalog_models;
46pub use groq::{infer_groq_reasoning, parse_groq_catalog_models};
47pub use nvidia::{infer_nvidia_reasoning, parse_nvidia_catalog_models};
48pub use openrouter::parse_openrouter_catalog_models;
49
50// ─── Modality ────────────────────────────────────────────────────────────────
51
52/// Input/output modality.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Modality {
55    Text,
56    Image,
57    Audio,
58    Video,
59    File,
60    Other(String),
61}
62
63impl Modality {
64    #[allow(clippy::should_implement_trait)]
65    pub fn from_str(s: &str) -> Self {
66        match s {
67            "text"  => Modality::Text,
68            "image" => Modality::Image,
69            "audio" => Modality::Audio,
70            "video" => Modality::Video,
71            "file"  => Modality::File,
72            other   => Modality::Other(other.to_string()),
73        }
74    }
75}
76
77// ─── PricingSummary ───────────────────────────────────────────────────────────
78
79/// Pricing metadata. Stored as decimal-string USD/token as returned by OpenRouter.
80#[derive(Debug, Clone, PartialEq, Eq, Default)]
81pub struct PricingSummary {
82    /// USD per prompt token, decimal string.
83    pub prompt: Option<String>,
84    /// USD per completion token, decimal string.
85    pub completion: Option<String>,
86    /// Separate Gemini internal-reasoning token cost (OpenRouter).
87    pub internal_reasoning: Option<String>,
88}
89
90impl PricingSummary {
91    /// True when a non-zero internal_reasoning price is present.
92    pub fn has_internal_reasoning_cost(&self) -> bool {
93        self.internal_reasoning
94            .as_deref()
95            .map(|s| s != "0" && !s.trim().is_empty())
96            .unwrap_or(false)
97    }
98}
99
100// ─── ReasoningSupport ─────────────────────────────────────────────────────────
101
102/// Normalized reasoning/thinking capability for a model.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum ReasoningSupport {
105    /// No reasoning/thinking support confirmed.
106    None,
107    /// Anthropic adaptive: thinking:{type:"adaptive"} ± effort param.
108    AnthropicAdaptive { adaptive: bool },
109    /// OpenRouter: reasoning/include_reasoning/reasoning_effort/verbosity params.
110    OpenRouter {
111        include_reasoning: bool,
112        effort: bool,
113        verbosity: bool,
114        internal_reasoning_priced: bool,
115    },
116    /// Groq family-based reasoning (reasoning_format/reasoning_effort).
117    GroqReasoning,
118    /// NVIDIA inline thinking via system-prompt; <think> in content.
119    NvidiaInlineThinking,
120    /// Generic OpenAI-compatible (capability unknown).
121    GenericOpenAi,
122    /// Not yet classified.
123    Unknown,
124}
125
126// ─── CatalogSource ────────────────────────────────────────────────────────────
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum CatalogSource {
130    /// From a live provider API call.
131    Live,
132    /// Bundled static/seed data.
133    StaticFallback,
134    /// Static seed enriched with live fields.
135    StaticWithLive,
136    /// Capability inferred heuristically.
137    Inferred,
138}
139
140// ─── CatalogProviderKind ──────────────────────────────────────────────────────
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum CatalogProviderKind {
144    Anthropic,
145    OpenRouter,
146    Groq,
147    NvidiaNim,
148    OpenAiCodex,
149    Generic { key: String },
150    Local,
151}
152
153// ─── CatalogModel ─────────────────────────────────────────────────────────────
154
155/// Normalized model catalog entry. Every provider handler produces these.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct CatalogModel {
158    /// Provider key (e.g. "openrouter", "groq").
159    pub provider_key: String,
160    /// Human-readable provider name.
161    pub provider_name: String,
162    /// Provider kind for routing/capability dispatch.
163    pub provider_kind: CatalogProviderKind,
164    /// Model id as used in API requests (no provider prefix).
165    pub id: String,
166    /// Human-readable label.
167    pub label: Option<String>,
168    /// Input context window in tokens.
169    pub context_tokens: Option<u64>,
170    /// Maximum output tokens.
171    pub max_output_tokens: Option<u64>,
172    /// Input modalities.
173    pub input_modalities: Vec<Modality>,
174    /// Pricing summary.
175    pub pricing: PricingSummary,
176    /// Reasoning/thinking capability.
177    pub reasoning: ReasoningSupport,
178    /// Data provenance.
179    pub source: CatalogSource,
180}
181
182impl CatalogModel {
183    /// Construct a minimal entry, returning `None` if the id is blank.
184    pub fn new(
185        provider_key: impl Into<String>,
186        provider_name: impl Into<String>,
187        id: impl Into<String>,
188    ) -> Option<Self> {
189        let id = id.into();
190        if id.trim().is_empty() {
191            return None;
192        }
193        let pk = provider_key.into();
194        Some(Self {
195            provider_kind: CatalogProviderKind::Generic { key: pk.clone() },
196            provider_name: provider_name.into(),
197            provider_key: pk,
198            id,
199            label: None,
200            context_tokens: None,
201            max_output_tokens: None,
202            input_modalities: vec![Modality::Text],
203            pricing: PricingSummary::default(),
204            reasoning: ReasoningSupport::Unknown,
205            source: CatalogSource::Live,
206        })
207    }
208
209    /// Synaps runtime id: bare for Anthropic/Claude, "provider/id" otherwise.
210    pub fn runtime_id(&self) -> String {
211        match &self.provider_kind {
212            CatalogProviderKind::Anthropic => self.id.clone(),
213            _ => format!("{}/{}", self.provider_key, self.id),
214        }
215    }
216
217    /// Label if present, id otherwise.
218    pub fn display_label(&self) -> &str {
219        self.label.as_deref().unwrap_or(&self.id)
220    }
221}
222
223// ─── Static seed helper ───────────────────────────────────────────────────────
224
225/// Build a static-fallback CatalogModel from a (id, label) pair.
226pub fn from_static_seed(
227    provider_key: &str,
228    provider_name: &str,
229    id: &str,
230    label: &str,
231) -> Option<CatalogModel> {
232    let mut m = CatalogModel::new(provider_key, provider_name, id)?;
233    m.label = if label.trim().is_empty() { None } else { Some(label.to_string()) };
234    m.source = CatalogSource::StaticFallback;
235    m.reasoning = ReasoningSupport::Unknown;
236    Some(m)
237}
238
239/// Convert all static seeds in a ProviderSpec to CatalogModel entries.
240pub fn static_seeds_from_spec(
241    spec: &super::registry::ProviderSpec,
242) -> Vec<CatalogModel> {
243    spec.models
244        .iter()
245        .filter_map(|(id, label, _tier)| {
246            from_static_seed(spec.key, spec.name, id, label)
247        })
248        .collect()
249}
250
251// ─── Live fetch helpers ───────────────────────────────────────────────────────
252
253pub trait ModelCatalogProvider: Sync {
254    fn provider_key(&self) -> &'static str;
255
256    fn fetch<'a>(
257        &'a self,
258        client: &'a reqwest::Client,
259        overrides: &'a BTreeMap<String, String>,
260    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>>;
261}
262
263pub struct OpenRouterCatalogProvider;
264pub struct GroqCatalogProvider;
265pub struct NvidiaCatalogProvider;
266pub struct AnthropicCatalogProvider;
267pub struct CodexCatalogProvider;
268pub struct GenericCatalogProvider;
269
270pub fn catalog_provider_for(provider_key: &str) -> &'static dyn ModelCatalogProvider {
271    match provider_key {
272        "openrouter" => &OpenRouterCatalogProvider,
273        "groq" => &GroqCatalogProvider,
274        "nvidia" => &NvidiaCatalogProvider,
275        "claude" | "anthropic" => &AnthropicCatalogProvider,
276        "openai-codex" => &CodexCatalogProvider,
277        _ => &GenericCatalogProvider,
278    }
279}
280
281fn catalog_get(client: &reqwest::Client, url: &str) -> reqwest::RequestBuilder {
282    client.get(url).timeout(CATALOG_REQUEST_TIMEOUT)
283}
284
285async fn read_catalog_response(resp: reqwest::Response) -> Result<String, String> {
286    let status = resp.status();
287    let body = resp.text().await.map_err(|e| format!("read failed: {e}"))?;
288    if !status.is_success() {
289        return Err(format!("model list failed: HTTP {status}"));
290    }
291    Ok(body)
292}
293
294async fn fetch_anthropic_catalog_models(
295    client: &reqwest::Client,
296) -> Result<Vec<CatalogModel>, String> {
297    let creds = crate::auth::ensure_fresh_token(client)
298        .await
299        .map_err(|e| format!("Anthropic is not configured: {e}"))?;
300    let mut pages = Vec::new();
301    let mut after_id: Option<String> = None;
302
303    for _ in 0..ANTHROPIC_MODELS_MAX_PAGES {
304        let url = anthropic_models_url(after_id.as_deref());
305        let resp = catalog_get(client, &url)
306            .bearer_auth(&creds.access)
307            .header("x-api-key", &creds.access)
308            .header("anthropic-version", "2023-06-01")
309            .send()
310            .await
311            .map_err(|e| format!("request failed: {e}"))?;
312        let body = read_catalog_response(resp).await?;
313        let page = parse_anthropic_catalog_page(&body).map_err(|e| format!("parse failed: {e}"))?;
314        let next_after_id = page.last_id.clone();
315        let has_more = page.has_more && next_after_id.is_some();
316        pages.push(page.models);
317        if !has_more {
318            return Ok(merge_catalog_pages(pages));
319        }
320        after_id = next_after_id;
321    }
322
323    Ok(merge_catalog_pages(pages))
324}
325
326impl ModelCatalogProvider for OpenRouterCatalogProvider {
327    fn provider_key(&self) -> &'static str { "openrouter" }
328
329    fn fetch<'a>(
330        &'a self,
331        client: &'a reqwest::Client,
332        _overrides: &'a BTreeMap<String, String>,
333    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
334        Box::pin(async move { fetch_openrouter_catalog_models(client).await })
335    }
336}
337
338impl ModelCatalogProvider for GroqCatalogProvider {
339    fn provider_key(&self) -> &'static str { "groq" }
340
341    fn fetch<'a>(
342        &'a self,
343        client: &'a reqwest::Client,
344        overrides: &'a BTreeMap<String, String>,
345    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
346        Box::pin(async move {
347            let spec = super::registry::providers()
348                .iter()
349                .find(|s| s.key == "groq")
350                .ok_or_else(|| "unknown provider: groq".to_string())?;
351            let api_key = super::registry::resolve_provider("groq", overrides)
352                .map(|(cfg, _)| cfg.api_key)
353                .ok_or_else(|| format!("{} is not configured", spec.name))?;
354            let url = format!("{}/models", spec.base_url.trim_end_matches('/'));
355            let resp = catalog_get(client, &url)
356                .bearer_auth(api_key)
357                .send()
358                .await
359                .map_err(|e| format!("request failed: {e}"))?;
360            let body = read_catalog_response(resp).await?;
361            parse_groq_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
362        })
363    }
364}
365
366impl ModelCatalogProvider for NvidiaCatalogProvider {
367    fn provider_key(&self) -> &'static str { "nvidia" }
368
369    fn fetch<'a>(
370        &'a self,
371        client: &'a reqwest::Client,
372        _overrides: &'a BTreeMap<String, String>,
373    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
374        Box::pin(async move {
375            let resp = catalog_get(client, "https://integrate.api.nvidia.com/v1/models")
376                .send()
377                .await
378                .map_err(|e| format!("request failed: {e}"))?;
379            let body = read_catalog_response(resp).await?;
380            parse_nvidia_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
381        })
382    }
383}
384
385impl ModelCatalogProvider for AnthropicCatalogProvider {
386    fn provider_key(&self) -> &'static str { "claude" }
387
388    fn fetch<'a>(
389        &'a self,
390        client: &'a reqwest::Client,
391        _overrides: &'a BTreeMap<String, String>,
392    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
393        Box::pin(async move { fetch_anthropic_catalog_models(client).await })
394    }
395}
396
397impl ModelCatalogProvider for CodexCatalogProvider {
398    fn provider_key(&self) -> &'static str { "openai-codex" }
399
400    fn fetch<'a>(
401        &'a self,
402        _client: &'a reqwest::Client,
403        _overrides: &'a BTreeMap<String, String>,
404    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
405        Box::pin(async move { Ok(codex_static_catalog_models()) })
406    }
407}
408
409impl ModelCatalogProvider for GenericCatalogProvider {
410    fn provider_key(&self) -> &'static str { "generic" }
411
412    fn fetch<'a>(
413        &'a self,
414        _client: &'a reqwest::Client,
415        _overrides: &'a BTreeMap<String, String>,
416    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
417        Box::pin(async move {
418            Err("generic catalog fetch requires provider key; use fetch_generic_catalog_provider_models".to_string())
419        })
420    }
421}
422
423async fn fetch_generic_catalog_provider_models(
424    client: &reqwest::Client,
425    provider_key: &str,
426    overrides: &BTreeMap<String, String>,
427) -> Result<Vec<CatalogModel>, String> {
428    let specs = super::registry::providers();
429    let spec = specs
430        .iter()
431        .find(|s| s.key == provider_key)
432        .ok_or_else(|| format!("unknown provider: {provider_key}"))?;
433
434    let api_key = super::registry::resolve_provider(provider_key, overrides)
435        .map(|(cfg, _)| cfg.api_key)
436        .ok_or_else(|| format!("{} is not configured", spec.name))?;
437
438    fetch_generic_catalog_models(
439        client,
440        provider_key,
441        spec.name,
442        spec.base_url,
443        &api_key,
444    ).await
445}
446
447/// Fetch the OpenRouter live model list. Auth not required.
448pub async fn fetch_openrouter_catalog_models(
449    client: &reqwest::Client,
450) -> Result<Vec<CatalogModel>, String> {
451    let resp = catalog_get(client, "https://openrouter.ai/api/v1/models")
452        .send()
453        .await
454        .map_err(|e| format!("request failed: {e}"))?;
455    let body = read_catalog_response(resp).await?;
456    parse_openrouter_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
457}
458
459/// Fetch a generic provider's `/models` endpoint.
460pub async fn fetch_generic_catalog_models(
461    client: &reqwest::Client,
462    provider_key: &str,
463    provider_name: &str,
464    base_url: &str,
465    api_key: &str,
466) -> Result<Vec<CatalogModel>, String> {
467    let url = format!("{}/models", base_url.trim_end_matches('/'));
468    let resp = catalog_get(client, &url)
469        .bearer_auth(api_key)
470        .send()
471        .await
472        .map_err(|e| format!("request failed: {e}"))?;
473    let body = read_catalog_response(resp).await?;
474    parse_generic_catalog_models(&body, provider_key, provider_name)
475        .map_err(|e| format!("parse failed: {e}"))
476}
477
478/// Fetch catalog models for any registered provider.
479/// OpenRouter uses its rich parser; all others use the generic parser.
480/// Compatible shim: callers that previously used `registry::fetch_provider_models`
481/// and then mapped to `ExpandedModelEntry` can switch to this.
482pub async fn fetch_catalog_models(
483    client: &reqwest::Client,
484    provider_key: &str,
485    overrides: &BTreeMap<String, String>,
486) -> Result<Vec<CatalogModel>, String> {
487    let provider = catalog_provider_for(provider_key);
488    if provider.provider_key() == "generic" {
489        return fetch_generic_catalog_provider_models(client, provider_key, overrides).await;
490    }
491    provider.fetch(client, overrides).await
492}
493
494// ─── Tests ────────────────────────────────────────────────────────────────────
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    // ── Task 1: Normalized catalog contract ──────────────────────────────────
501
502    #[test]
503    fn catalog_model_rejects_empty_ids() {
504        assert!(CatalogModel::new("openrouter", "OpenRouter", "").is_none());
505        assert!(CatalogModel::new("openrouter", "OpenRouter", "   ").is_none());
506    }
507
508    #[test]
509    fn static_seed_sets_fallback_source_and_runtime_id() {
510        let m = from_static_seed("groq", "Groq", "llama-3.3-70b-versatile", "Llama 3.3 70B")
511            .expect("valid seed");
512        assert_eq!(m.runtime_id(), "groq/llama-3.3-70b-versatile");
513        assert_eq!(m.display_label(), "Llama 3.3 70B");
514        assert_eq!(m.source, CatalogSource::StaticFallback);
515    }
516
517    #[test]
518    fn static_seed_empty_label_stores_none() {
519        let m = from_static_seed("groq", "Groq", "model-x", "").expect("valid id");
520        assert_eq!(m.label, None);
521    }
522
523    #[test]
524    fn static_seed_whitespace_label_stores_none() {
525        let m = from_static_seed("groq", "Groq", "model-x", "   ").expect("valid id");
526        assert_eq!(m.label, None);
527    }
528
529    #[test]
530    fn static_seeds_from_spec_converts_all_groq_models() {
531        let spec = super::super::registry::providers()
532            .iter()
533            .find(|s| s.key == "groq")
534            .expect("groq spec");
535        let seeds = static_seeds_from_spec(spec);
536        assert_eq!(seeds.len(), spec.models.len());
537        assert!(seeds.iter().all(|m| m.source == CatalogSource::StaticFallback));
538        assert!(seeds.iter().all(|m| !m.id.is_empty()));
539        assert!(seeds.iter().all(|m| m.runtime_id().starts_with("groq/")));
540    }
541
542    #[test]
543    fn anthropic_runtime_id_is_bare() {
544        let mut m = CatalogModel::new("anthropic", "Anthropic", "claude-opus-4-7").unwrap();
545        m.provider_kind = CatalogProviderKind::Anthropic;
546        assert_eq!(m.runtime_id(), "claude-opus-4-7");
547    }
548
549    #[test]
550    fn pricing_summary_has_internal_reasoning_cost_zero_is_false() {
551        let p = PricingSummary {
552            prompt: None, completion: None,
553            internal_reasoning: Some("0".to_string()),
554        };
555        assert!(!p.has_internal_reasoning_cost());
556    }
557
558    #[test]
559    fn pricing_summary_has_internal_reasoning_cost_nonzero_is_true() {
560        let p = PricingSummary {
561            prompt: None, completion: None,
562            internal_reasoning: Some("0.0000035".to_string()),
563        };
564        assert!(p.has_internal_reasoning_cost());
565    }
566
567    #[test]
568    fn catalog_provider_trait_dispatch_selects_specialized_handlers() {
569        assert_eq!(catalog_provider_for("openrouter").provider_key(), "openrouter");
570        assert_eq!(catalog_provider_for("groq").provider_key(), "groq");
571        assert_eq!(catalog_provider_for("nvidia").provider_key(), "nvidia");
572        assert_eq!(catalog_provider_for("claude").provider_key(), "claude");
573        assert_eq!(catalog_provider_for("openai-codex").provider_key(), "openai-codex");
574        assert_eq!(catalog_provider_for("cerebras").provider_key(), "generic");
575    }
576
577    #[test]
578    fn catalog_request_timeout_is_bounded() {
579        assert_eq!(CATALOG_REQUEST_TIMEOUT, Duration::from_secs(15));
580    }
581
582    #[test]
583    fn anthropic_page_metadata_is_exposed_for_pagination() {
584        let page = parse_anthropic_catalog_page(r#"{
585            "data":[{"id":"claude-opus-4-7"}],
586            "has_more": true,
587            "last_id": "claude-opus-4-7"
588        }"#).expect("parse page");
589        assert!(page.has_more);
590        assert_eq!(page.last_id.as_deref(), Some("claude-opus-4-7"));
591        assert_eq!(page.models.len(), 1);
592    }
593
594    #[test]
595    fn anthropic_pagination_url_adds_after_id_cursor() {
596        assert_eq!(
597            anthropic_models_url(None),
598            "https://api.anthropic.com/v1/models?limit=100"
599        );
600        assert_eq!(
601            anthropic_models_url(Some("claude-opus-4-7")),
602            "https://api.anthropic.com/v1/models?limit=100&after_id=claude-opus-4-7"
603        );
604    }
605
606    #[test]
607    fn merge_catalog_pages_dedupes_by_id() {
608        let first = parse_anthropic_catalog_models(r#"{"data":[{"id":"claude-opus-4-7"}]}"#).unwrap();
609        let second = parse_anthropic_catalog_models(r#"{"data":[{"id":"claude-opus-4-7"},{"id":"claude-sonnet-4-6"}]}"#).unwrap();
610        let merged = merge_catalog_pages(vec![first, second]);
611        assert_eq!(merged.len(), 2);
612        assert_eq!(merged[0].id, "claude-opus-4-7");
613        assert_eq!(merged[1].id, "claude-sonnet-4-6");
614    }
615
616    // ── Task 2: OpenRouter rich catalog handler ───────────────────────────────
617
618    mod openrouter {
619        use super::super::*;
620
621        const RICH_FIXTURE: &str = r#"{
622          "data": [
623            {
624              "id": "qwen/qwen3-coder",
625              "name": "Qwen: Qwen3 Coder",
626              "context_length": 131072,
627              "top_provider": { "max_completion_tokens": 32768 },
628              "supported_parameters": ["temperature", "top_p", "max_tokens"],
629              "pricing": { "prompt": "0.0000001", "completion": "0.0000004", "internal_reasoning": "0" },
630              "architecture": { "input_modalities": ["text"] }
631            },
632            {
633              "id": "anthropic/claude-opus-4-7",
634              "name": "Anthropic: Claude Opus 4.7",
635              "context_length": 200000,
636              "supported_parameters": ["temperature", "verbosity", "max_tokens"],
637              "pricing": { "prompt": "0.000015", "completion": "0.000075" }
638            },
639            {
640              "id": "openai/o4-mini",
641              "name": "OpenAI: o4-mini",
642              "context_length": 128000,
643              "supported_parameters": ["reasoning_effort", "max_tokens"],
644              "pricing": { "prompt": "0.0000011", "completion": "0.0000044" }
645            },
646            {
647              "id": "google/gemini-2.5-flash",
648              "name": "Google: Gemini 2.5 Flash",
649              "context_length": 1048576,
650              "supported_parameters": ["reasoning", "include_reasoning", "max_tokens"],
651              "pricing": { "prompt": "0.00000015", "completion": "0.0000035", "internal_reasoning": "0.0000035" },
652              "architecture": { "input_modalities": ["text", "image", "audio", "video"] }
653            },
654            {
655              "id": "",
656              "name": "Empty — must be filtered"
657            }
658          ]
659        }"#;
660
661        #[test]
662        fn parses_minimal_model() {
663            let json = r#"{"data":[{"id":"test/model","name":"Test Model"}]}"#;
664            let models = parse_openrouter_catalog_models(json).expect("parse ok");
665            assert_eq!(models.len(), 1);
666            assert_eq!(models[0].id, "test/model");
667            assert_eq!(models[0].label.as_deref(), Some("Test Model"));
668            assert_eq!(models[0].runtime_id(), "openrouter/test/model");
669            assert_eq!(models[0].provider_key, "openrouter");
670            assert_eq!(models[0].source, CatalogSource::Live);
671        }
672
673        #[test]
674        fn parses_context_length_and_max_output() {
675            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
676            let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
677            assert_eq!(qwen.context_tokens, Some(131_072));
678            assert_eq!(qwen.max_output_tokens, Some(32_768));
679        }
680
681        #[test]
682        fn filters_empty_ids() {
683            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
684            assert!(!models.iter().any(|m| m.id.is_empty()));
685            assert_eq!(models.len(), 4);
686        }
687
688        #[test]
689        fn parses_pricing_fields() {
690            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
691            let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
692            assert_eq!(qwen.pricing.prompt.as_deref(), Some("0.0000001"));
693            assert_eq!(qwen.pricing.completion.as_deref(), Some("0.0000004"));
694            assert!(!qwen.pricing.has_internal_reasoning_cost());
695        }
696
697        #[test]
698        fn parses_internal_reasoning_cost_flag() {
699            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
700            let gemini = models.iter().find(|m| m.id == "google/gemini-2.5-flash").unwrap();
701            assert!(gemini.pricing.has_internal_reasoning_cost());
702        }
703
704        #[test]
705        fn no_reasoning_params_maps_to_none() {
706            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
707            let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
708            assert_eq!(qwen.reasoning, ReasoningSupport::None);
709        }
710
711        #[test]
712        fn verbosity_param_maps_to_anthropic_adaptive() {
713            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
714            let claude = models.iter().find(|m| m.id == "anthropic/claude-opus-4-7").unwrap();
715            assert_eq!(claude.reasoning, ReasoningSupport::AnthropicAdaptive { adaptive: true });
716        }
717
718        #[test]
719        fn reasoning_effort_maps_to_openrouter_reasoning() {
720            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
721            let o4 = models.iter().find(|m| m.id == "openai/o4-mini").unwrap();
722            assert_eq!(o4.reasoning, ReasoningSupport::OpenRouter {
723                include_reasoning: false,
724                effort: true,
725                verbosity: false,
726                internal_reasoning_priced: false,
727            });
728        }
729
730        #[test]
731        fn reasoning_include_reasoning_maps_correctly() {
732            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
733            let gemini = models.iter().find(|m| m.id == "google/gemini-2.5-flash").unwrap();
734            assert_eq!(gemini.reasoning, ReasoningSupport::OpenRouter {
735                include_reasoning: true,
736                effort: false,
737                verbosity: false,
738                internal_reasoning_priced: true,
739            });
740        }
741
742        #[test]
743        fn parses_multimodal_input() {
744            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
745            let gemini = models.iter().find(|m| m.id == "google/gemini-2.5-flash").unwrap();
746            assert!(gemini.input_modalities.contains(&Modality::Text));
747            assert!(gemini.input_modalities.contains(&Modality::Image));
748            assert!(gemini.input_modalities.contains(&Modality::Audio));
749            assert!(gemini.input_modalities.contains(&Modality::Video));
750        }
751
752        #[test]
753        fn missing_modalities_defaults_to_text() {
754            let json = r#"{"data":[{"id":"test/model"}]}"#;
755            let models = parse_openrouter_catalog_models(json).expect("parse ok");
756            assert_eq!(models[0].input_modalities, vec![Modality::Text]);
757        }
758
759        #[test]
760        fn parses_openrouter_rich_metadata_and_reasoning_flags() {
761            // Backward-compatible name used by existing test
762            let json = r#"{
763              "data": [{
764                "id": "anthropic/claude-sonnet-4.6",
765                "name": "Anthropic: Claude Sonnet 4.6",
766                "context_length": 1000000,
767                "architecture": { "input_modalities": ["text", "image"] },
768                "pricing": { "prompt": "0.000003", "completion": "0.000015", "internal_reasoning": "0.000012" },
769                "top_provider": { "max_completion_tokens": 128000 },
770                "supported_parameters": ["reasoning", "include_reasoning", "verbosity", "tools"]
771              }]
772            }"#;
773            let models = parse_openrouter_catalog_models(json).expect("parse ok");
774            assert_eq!(models.len(), 1);
775            let m = &models[0];
776            assert_eq!(m.runtime_id(), "openrouter/anthropic/claude-sonnet-4.6");
777            assert_eq!(m.context_tokens, Some(1_000_000));
778            assert_eq!(m.max_output_tokens, Some(128_000));
779            assert!(m.input_modalities.contains(&Modality::Image));
780            // verbosity wins → AnthropicAdaptive
781            assert_eq!(m.reasoning, ReasoningSupport::AnthropicAdaptive { adaptive: true });
782        }
783
784        #[test]
785        fn parses_openrouter_non_reasoning_model_as_none() {
786            let json = r#"{
787              "data": [{
788                "id": "meta-llama/llama-3.3-70b-instruct",
789                "name": "Meta: Llama 3.3 70B",
790                "supported_parameters": ["temperature", "tools"]
791              }]
792            }"#;
793            let models = parse_openrouter_catalog_models(json).expect("parse ok");
794            assert_eq!(models[0].reasoning, ReasoningSupport::None);
795        }
796
797        #[test]
798        fn invalid_json_returns_error() {
799            assert!(parse_openrouter_catalog_models("{not json}").is_err());
800        }
801
802        #[test]
803        fn missing_data_key_returns_error() {
804            assert!(parse_openrouter_catalog_models(r#"{"models":[]}"#).is_err());
805        }
806    }
807
808    // ── Task 3: Generic handler / compat with registry ────────────────────────
809
810
811
812
813    // ── Task 5: Anthropic parser and Codex static catalog ───────────────────
814
815    mod anthropic {
816        use super::super::*;
817
818        #[test]
819        fn parser_reads_optional_capabilities_and_token_limits() {
820            let json = r#"{
821                "data": [{
822                    "id": "claude-opus-4-7",
823                    "display_name": "Claude Opus 4.7",
824                    "max_input_tokens": 200000,
825                    "max_tokens": 32000,
826                    "capabilities": {
827                        "thinking": { "supported": true },
828                        "effort": { "supported": true }
829                    }
830                }],
831                "has_more": false
832            }"#;
833            let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
834            assert_eq!(models.len(), 1);
835            let model = &models[0];
836            assert_eq!(model.runtime_id(), "claude-opus-4-7");
837            assert_eq!(model.label.as_deref(), Some("Claude Opus 4.7"));
838            assert_eq!(model.context_tokens, Some(200_000));
839            assert_eq!(model.max_output_tokens, Some(32_000));
840            assert_eq!(model.reasoning, ReasoningSupport::AnthropicAdaptive { adaptive: true });
841        }
842
843        #[test]
844        fn parser_tolerates_missing_capabilities_as_unknown() {
845            let json = r#"{"data":[{"id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku"}]}"#;
846            let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
847            assert_eq!(models[0].reasoning, ReasoningSupport::Unknown);
848        }
849
850        #[test]
851        fn parser_filters_empty_ids() {
852            let json = r#"{"data":[{"id":""},{"id":"claude-sonnet-4-6"}]}"#;
853            let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
854            assert_eq!(models.len(), 1);
855            assert_eq!(models[0].id, "claude-sonnet-4-6");
856        }
857    }
858
859    mod codex {
860        use super::super::*;
861
862        #[test]
863        fn static_catalog_uses_fallback_source_and_prefixed_runtime_ids() {
864            let models = codex_static_catalog_models();
865            assert!(models.iter().any(|m| m.id == "gpt-5.5"));
866            assert!(models.iter().any(|m| m.id == "gpt-5.4"));
867            assert!(models.iter().any(|m| m.id == "gpt-5.4-mini"));
868            assert!(!models.iter().any(|m| m.id == "gpt-5.5-pro"));
869            assert!(!models.iter().any(|m| m.id == "gpt-5.4-nano"));
870            assert!(!models.iter().any(|m| m.id == "gpt-5.1-codex-mini"));
871            assert!(models.iter().all(|m| m.source == CatalogSource::StaticFallback));
872            assert!(models.iter().all(|m| m.runtime_id().starts_with("openai-codex/")));
873        }
874    }
875
876    // ── Task 4: Groq and NVIDIA enrichment ──────────────────────────────────
877
878    mod groq {
879        use super::super::*;
880
881        #[test]
882        fn parser_extracts_context_window_and_filters_inactive() {
883            let json = r#"{"data":[
884                {"id":"llama-3.3-70b-versatile","active":true,"context_window":131072,"owned_by":"Meta"},
885                {"id":"old-model-v1","active":false,"context_window":8192,"owned_by":"Meta"}
886            ]}"#;
887            let models = parse_groq_catalog_models(json).expect("parse groq");
888            assert_eq!(models.len(), 1);
889            assert_eq!(models[0].id, "llama-3.3-70b-versatile");
890            assert_eq!(models[0].context_tokens, Some(131_072));
891            assert_eq!(models[0].reasoning, ReasoningSupport::None);
892        }
893
894        #[test]
895        fn inference_maps_reasoning_families() {
896            assert_eq!(infer_groq_reasoning("openai/gpt-oss-120b"), ReasoningSupport::GroqReasoning);
897            assert_eq!(infer_groq_reasoning("qwen/qwen3-32b"), ReasoningSupport::GroqReasoning);
898            assert_eq!(infer_groq_reasoning("groq/compound-mini"), ReasoningSupport::GroqReasoning);
899            assert_eq!(infer_groq_reasoning("llama-3.3-70b-versatile"), ReasoningSupport::None);
900        }
901
902        #[test]
903        fn parser_filters_empty_ids() {
904            let json = r#"{"data":[{"id":"","active":true},{"id":"openai/gpt-oss-20b","active":true,"context_window":131072}]}"#;
905            let models = parse_groq_catalog_models(json).expect("parse groq");
906            assert_eq!(models.len(), 1);
907            assert_eq!(models[0].id, "openai/gpt-oss-20b");
908        }
909    }
910
911    mod nvidia {
912        use super::super::*;
913
914        #[test]
915        fn parser_dedupes_and_enriches_known_context() {
916            let json = r#"{"data":[
917                {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
918                {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
919                {"id":"moonshotai/kimi-k2-thinking","owned_by":"moonshotai"}
920            ]}"#;
921            let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
922            assert_eq!(models.len(), 2);
923            let ultra = models.iter().find(|m| m.id.contains("ultra")).unwrap();
924            assert_eq!(ultra.context_tokens, Some(128_000));
925            assert_eq!(ultra.reasoning, ReasoningSupport::NvidiaInlineThinking);
926            let kimi = models.iter().find(|m| m.id.contains("kimi")).unwrap();
927            assert_eq!(kimi.context_tokens, Some(256_000));
928        }
929
930        #[test]
931        fn inference_detects_thinking_and_standard_models() {
932            assert_eq!(infer_nvidia_reasoning("qwen/qwen3-next-80b-a3b-thinking"), ReasoningSupport::NvidiaInlineThinking);
933            assert_eq!(infer_nvidia_reasoning("nvidia/cosmos-reason2-8b"), ReasoningSupport::NvidiaInlineThinking);
934            assert_eq!(infer_nvidia_reasoning("meta/llama-3.3-70b-instruct"), ReasoningSupport::None);
935        }
936
937        #[test]
938        fn parser_filters_empty_ids() {
939            let json = r#"{"data":[{"id":""},{"id":"meta/llama-3.3-70b-instruct"}]}"#;
940            let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
941            assert_eq!(models.len(), 1);
942            assert_eq!(models[0].id, "meta/llama-3.3-70b-instruct");
943        }
944    }
945
946
947    mod generic_compat {
948        use super::super::*;
949
950        #[test]
951        fn parses_generic_catalog_models_and_filters_empty_ids() {
952            let json = r#"{
953                "data": [
954                    { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
955                    { "id": "" },
956                    { "id": "openai/gpt-oss-120b" }
957                ]
958            }"#;
959            let models = parse_generic_catalog_models(json, "openrouter", "OpenRouter")
960                .expect("parse ok");
961            assert_eq!(models.len(), 2);
962            assert_eq!(models[0].runtime_id(), "openrouter/qwen/qwen3-coder");
963            assert_eq!(models[0].display_label(), "Qwen: Qwen3 Coder");
964            assert_eq!(models[1].display_label(), "openai/gpt-oss-120b");
965        }
966
967        #[test]
968        fn whitespace_only_id_is_filtered() {
969            let json = r#"{"data":[{"id":"   "},{"id":"valid"}]}"#;
970            let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
971            assert_eq!(models.len(), 1);
972            assert_eq!(models[0].id, "valid");
973        }
974
975        #[test]
976        fn whitespace_label_stored_as_none() {
977            let json = r#"{"data":[{"id":"m1","name":"   "}]}"#;
978            let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
979            assert_eq!(models[0].label, None);
980        }
981
982        #[test]
983        fn generic_catalog_source_is_live() {
984            let json = r#"{"data":[{"id":"m1","name":"Model One"}]}"#;
985            let models = parse_generic_catalog_models(json, "testprovider", "Test")
986                .expect("parse ok");
987            assert_eq!(models[0].source, CatalogSource::Live);
988        }
989
990        #[test]
991        fn generic_reasoning_is_generic_open_ai() {
992            let json = r#"{"data":[{"id":"m1"}]}"#;
993            let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
994            assert_eq!(models[0].reasoning, ReasoningSupport::GenericOpenAi);
995        }
996
997        #[test]
998        fn generic_parse_matches_legacy_parse_behavior() {
999            // parse_generic_catalog_models should produce same ids/labels
1000            // as the legacy registry::parse_provider_models_response
1001            let json = r#"{
1002                "data": [
1003                    { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
1004                    { "id": "openai/gpt-oss-120b" }
1005                ]
1006            }"#;
1007            let legacy = super::super::super::registry::parse_provider_models_response(json)
1008                .expect("legacy parse ok");
1009            let catalog = parse_generic_catalog_models(json, "openrouter", "OpenRouter")
1010                .expect("catalog parse ok");
1011            assert_eq!(legacy.len(), catalog.len());
1012            for (l, c) in legacy.iter().zip(catalog.iter()) {
1013                assert_eq!(l.id, c.id, "ids must match");
1014                assert_eq!(l.name, c.label, "labels must match");
1015            }
1016        }
1017
1018        #[test]
1019        fn static_seeds_from_spec_all_providers() {
1020            // Every registered provider should produce at least one seed
1021            for spec in super::super::super::registry::providers() {
1022                if spec.models.is_empty() { continue; }
1023                let seeds = super::super::static_seeds_from_spec(spec);
1024                assert!(!seeds.is_empty(), "no seeds for {}", spec.key);
1025                assert!(
1026                    seeds.iter().all(|m| m.runtime_id().starts_with(&format!("{}/", spec.key))),
1027                    "runtime_id prefix wrong for {}",
1028                    spec.key
1029                );
1030            }
1031        }
1032    }
1033}