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