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::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    // Honor the credential source so a Remote client lists models via a
298    // broker-issued token instead of refreshing (and rotating) auth.json
299    // client-side — which would lock the broker out. (#158 A4)
300    let config = crate::config::load_config();
301    let source = config.auth.credential_source();
302    let cache = crate::auth::TokenCache::new();
303    let access = crate::auth::resolve_access_token("anthropic", &source, &cache, client)
304        .await
305        .map_err(|e| format!("Anthropic is not configured: {e}"))?;
306    let mut pages = Vec::new();
307    let mut after_id: Option<String> = None;
308
309    for _ in 0..ANTHROPIC_MODELS_MAX_PAGES {
310        let url = anthropic_models_url(after_id.as_deref());
311        let resp = catalog_get(client, &url)
312            .bearer_auth(&access)
313            .header("x-api-key", &access)
314            .header("anthropic-version", "2023-06-01")
315            .send()
316            .await
317            .map_err(|e| format!("request failed: {e}"))?;
318        let body = read_catalog_response(resp).await?;
319        let page = parse_anthropic_catalog_page(&body).map_err(|e| format!("parse failed: {e}"))?;
320        let next_after_id = page.last_id.clone();
321        let has_more = page.has_more && next_after_id.is_some();
322        pages.push(page.models);
323        if !has_more {
324            return Ok(merge_catalog_pages(pages));
325        }
326        after_id = next_after_id;
327    }
328
329    Ok(merge_catalog_pages(pages))
330}
331
332impl ModelCatalogProvider for OpenRouterCatalogProvider {
333    fn provider_key(&self) -> &'static str { "openrouter" }
334
335    fn fetch<'a>(
336        &'a self,
337        client: &'a reqwest::Client,
338        _overrides: &'a BTreeMap<String, String>,
339    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
340        Box::pin(async move { fetch_openrouter_catalog_models(client).await })
341    }
342}
343
344impl ModelCatalogProvider for GroqCatalogProvider {
345    fn provider_key(&self) -> &'static str { "groq" }
346
347    fn fetch<'a>(
348        &'a self,
349        client: &'a reqwest::Client,
350        overrides: &'a BTreeMap<String, String>,
351    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
352        Box::pin(async move {
353            let spec = super::registry::providers()
354                .iter()
355                .find(|s| s.key == "groq")
356                .ok_or_else(|| "unknown provider: groq".to_string())?;
357            let api_key = super::registry::resolve_provider("groq", overrides)
358                .map(|(cfg, _)| cfg.api_key)
359                .ok_or_else(|| format!("{} is not configured", spec.name))?;
360            let url = format!("{}/models", spec.base_url.trim_end_matches('/'));
361            let resp = catalog_get(client, &url)
362                .bearer_auth(api_key)
363                .send()
364                .await
365                .map_err(|e| format!("request failed: {e}"))?;
366            let body = read_catalog_response(resp).await?;
367            parse_groq_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
368        })
369    }
370}
371
372impl ModelCatalogProvider for NvidiaCatalogProvider {
373    fn provider_key(&self) -> &'static str { "nvidia" }
374
375    fn fetch<'a>(
376        &'a self,
377        client: &'a reqwest::Client,
378        _overrides: &'a BTreeMap<String, String>,
379    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
380        Box::pin(async move {
381            let resp = catalog_get(client, "https://integrate.api.nvidia.com/v1/models")
382                .send()
383                .await
384                .map_err(|e| format!("request failed: {e}"))?;
385            let body = read_catalog_response(resp).await?;
386            parse_nvidia_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
387        })
388    }
389}
390
391impl ModelCatalogProvider for AnthropicCatalogProvider {
392    fn provider_key(&self) -> &'static str { "claude" }
393
394    fn fetch<'a>(
395        &'a self,
396        client: &'a reqwest::Client,
397        _overrides: &'a BTreeMap<String, String>,
398    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
399        Box::pin(async move { fetch_anthropic_catalog_models(client).await })
400    }
401}
402
403impl ModelCatalogProvider for CodexCatalogProvider {
404    fn provider_key(&self) -> &'static str { "openai-codex" }
405
406    fn fetch<'a>(
407        &'a self,
408        _client: &'a reqwest::Client,
409        _overrides: &'a BTreeMap<String, String>,
410    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
411        Box::pin(async move { Ok(codex_static_catalog_models()) })
412    }
413}
414
415impl ModelCatalogProvider for GenericCatalogProvider {
416    fn provider_key(&self) -> &'static str { "generic" }
417
418    fn fetch<'a>(
419        &'a self,
420        _client: &'a reqwest::Client,
421        _overrides: &'a BTreeMap<String, String>,
422    ) -> Pin<Box<dyn Future<Output = Result<Vec<CatalogModel>, String>> + Send + 'a>> {
423        Box::pin(async move {
424            Err("generic catalog fetch requires provider key; use fetch_generic_catalog_provider_models".to_string())
425        })
426    }
427}
428
429async fn fetch_generic_catalog_provider_models(
430    client: &reqwest::Client,
431    provider_key: &str,
432    overrides: &BTreeMap<String, String>,
433) -> Result<Vec<CatalogModel>, String> {
434    let specs = super::registry::providers();
435    let spec = specs
436        .iter()
437        .find(|s| s.key == provider_key)
438        .ok_or_else(|| format!("unknown provider: {provider_key}"))?;
439
440    let api_key = super::registry::resolve_provider(provider_key, overrides)
441        .map(|(cfg, _)| cfg.api_key)
442        .ok_or_else(|| format!("{} is not configured", spec.name))?;
443
444    fetch_generic_catalog_models(
445        client,
446        provider_key,
447        spec.name,
448        spec.base_url,
449        &api_key,
450    ).await
451}
452
453/// Fetch the OpenRouter live model list. Auth not required.
454pub async fn fetch_openrouter_catalog_models(
455    client: &reqwest::Client,
456) -> Result<Vec<CatalogModel>, String> {
457    let resp = catalog_get(client, "https://openrouter.ai/api/v1/models")
458        .send()
459        .await
460        .map_err(|e| format!("request failed: {e}"))?;
461    let body = read_catalog_response(resp).await?;
462    parse_openrouter_catalog_models(&body).map_err(|e| format!("parse failed: {e}"))
463}
464
465/// Fetch a generic provider's `/models` endpoint.
466pub async fn fetch_generic_catalog_models(
467    client: &reqwest::Client,
468    provider_key: &str,
469    provider_name: &str,
470    base_url: &str,
471    api_key: &str,
472) -> Result<Vec<CatalogModel>, String> {
473    let url = format!("{}/models", base_url.trim_end_matches('/'));
474    let resp = catalog_get(client, &url)
475        .bearer_auth(api_key)
476        .send()
477        .await
478        .map_err(|e| format!("request failed: {e}"))?;
479    let body = read_catalog_response(resp).await?;
480    parse_generic_catalog_models(&body, provider_key, provider_name)
481        .map_err(|e| format!("parse failed: {e}"))
482}
483
484/// Fetch catalog models for any registered provider.
485/// OpenRouter uses its rich parser; all others use the generic parser.
486/// Compatible shim: callers that previously used `registry::fetch_provider_models`
487/// and then mapped to `ExpandedModelEntry` can switch to this.
488pub async fn fetch_catalog_models(
489    client: &reqwest::Client,
490    provider_key: &str,
491    overrides: &BTreeMap<String, String>,
492) -> Result<Vec<CatalogModel>, String> {
493    let provider = catalog_provider_for(provider_key);
494    if provider.provider_key() == "generic" {
495        return fetch_generic_catalog_provider_models(client, provider_key, overrides).await;
496    }
497    provider.fetch(client, overrides).await
498}
499
500// ─── Tests ────────────────────────────────────────────────────────────────────
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    // ── Task 1: Normalized catalog contract ──────────────────────────────────
507
508    #[test]
509    fn catalog_model_rejects_empty_ids() {
510        assert!(CatalogModel::new("openrouter", "OpenRouter", "").is_none());
511        assert!(CatalogModel::new("openrouter", "OpenRouter", "   ").is_none());
512    }
513
514    #[test]
515    fn static_seed_sets_fallback_source_and_runtime_id() {
516        let m = from_static_seed("groq", "Groq", "llama-3.3-70b-versatile", "Llama 3.3 70B")
517            .expect("valid seed");
518        assert_eq!(m.runtime_id(), "groq/llama-3.3-70b-versatile");
519        assert_eq!(m.display_label(), "Llama 3.3 70B");
520        assert_eq!(m.source, CatalogSource::StaticFallback);
521    }
522
523    #[test]
524    fn static_seed_empty_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_seed_whitespace_label_stores_none() {
531        let m = from_static_seed("groq", "Groq", "model-x", "   ").expect("valid id");
532        assert_eq!(m.label, None);
533    }
534
535    #[test]
536    fn static_seeds_from_spec_converts_all_groq_models() {
537        let spec = super::super::registry::providers()
538            .iter()
539            .find(|s| s.key == "groq")
540            .expect("groq spec");
541        let seeds = static_seeds_from_spec(spec);
542        assert_eq!(seeds.len(), spec.models.len());
543        assert!(seeds.iter().all(|m| m.source == CatalogSource::StaticFallback));
544        assert!(seeds.iter().all(|m| !m.id.is_empty()));
545        assert!(seeds.iter().all(|m| m.runtime_id().starts_with("groq/")));
546    }
547
548    #[test]
549    fn anthropic_runtime_id_is_bare() {
550        let mut m = CatalogModel::new("anthropic", "Anthropic", "claude-opus-4-7").unwrap();
551        m.provider_kind = CatalogProviderKind::Anthropic;
552        assert_eq!(m.runtime_id(), "claude-opus-4-7");
553    }
554
555    #[test]
556    fn pricing_summary_has_internal_reasoning_cost_zero_is_false() {
557        let p = PricingSummary {
558            prompt: None, completion: None,
559            internal_reasoning: Some("0".to_string()),
560        };
561        assert!(!p.has_internal_reasoning_cost());
562    }
563
564    #[test]
565    fn pricing_summary_has_internal_reasoning_cost_nonzero_is_true() {
566        let p = PricingSummary {
567            prompt: None, completion: None,
568            internal_reasoning: Some("0.0000035".to_string()),
569        };
570        assert!(p.has_internal_reasoning_cost());
571    }
572
573    #[test]
574    fn catalog_provider_trait_dispatch_selects_specialized_handlers() {
575        assert_eq!(catalog_provider_for("openrouter").provider_key(), "openrouter");
576        assert_eq!(catalog_provider_for("groq").provider_key(), "groq");
577        assert_eq!(catalog_provider_for("nvidia").provider_key(), "nvidia");
578        assert_eq!(catalog_provider_for("claude").provider_key(), "claude");
579        assert_eq!(catalog_provider_for("openai-codex").provider_key(), "openai-codex");
580        assert_eq!(catalog_provider_for("cerebras").provider_key(), "generic");
581    }
582
583    #[test]
584    fn catalog_request_timeout_is_bounded() {
585        assert_eq!(CATALOG_REQUEST_TIMEOUT, Duration::from_secs(15));
586    }
587
588    #[test]
589    fn anthropic_page_metadata_is_exposed_for_pagination() {
590        let page = parse_anthropic_catalog_page(r#"{
591            "data":[{"id":"claude-opus-4-7"}],
592            "has_more": true,
593            "last_id": "claude-opus-4-7"
594        }"#).expect("parse page");
595        assert!(page.has_more);
596        assert_eq!(page.last_id.as_deref(), Some("claude-opus-4-7"));
597        assert_eq!(page.models.len(), 1);
598    }
599
600    #[test]
601    fn anthropic_pagination_url_adds_after_id_cursor() {
602        assert_eq!(
603            anthropic_models_url(None),
604            "https://api.anthropic.com/v1/models?limit=100"
605        );
606        assert_eq!(
607            anthropic_models_url(Some("claude-opus-4-7")),
608            "https://api.anthropic.com/v1/models?limit=100&after_id=claude-opus-4-7"
609        );
610    }
611
612    #[test]
613    fn merge_catalog_pages_dedupes_by_id() {
614        let first = parse_anthropic_catalog_models(r#"{"data":[{"id":"claude-opus-4-7"}]}"#).unwrap();
615        let second = parse_anthropic_catalog_models(r#"{"data":[{"id":"claude-opus-4-7"},{"id":"claude-sonnet-4-6"}]}"#).unwrap();
616        let merged = merge_catalog_pages(vec![first, second]);
617        assert_eq!(merged.len(), 2);
618        assert_eq!(merged[0].id, "claude-opus-4-7");
619        assert_eq!(merged[1].id, "claude-sonnet-4-6");
620    }
621
622    // ── Task 2: OpenRouter rich catalog handler ───────────────────────────────
623
624    mod openrouter {
625        use super::super::*;
626
627        const RICH_FIXTURE: &str = r#"{
628          "data": [
629            {
630              "id": "qwen/qwen3-coder",
631              "name": "Qwen: Qwen3 Coder",
632              "context_length": 131072,
633              "top_provider": { "max_completion_tokens": 32768 },
634              "supported_parameters": ["temperature", "top_p", "max_tokens"],
635              "pricing": { "prompt": "0.0000001", "completion": "0.0000004", "internal_reasoning": "0" },
636              "architecture": { "input_modalities": ["text"] }
637            },
638            {
639              "id": "anthropic/claude-opus-4-7",
640              "name": "Anthropic: Claude Opus 4.7",
641              "context_length": 200000,
642              "supported_parameters": ["temperature", "verbosity", "max_tokens"],
643              "pricing": { "prompt": "0.000015", "completion": "0.000075" }
644            },
645            {
646              "id": "openai/o4-mini",
647              "name": "OpenAI: o4-mini",
648              "context_length": 128000,
649              "supported_parameters": ["reasoning_effort", "max_tokens"],
650              "pricing": { "prompt": "0.0000011", "completion": "0.0000044" }
651            },
652            {
653              "id": "google/gemini-2.5-flash",
654              "name": "Google: Gemini 2.5 Flash",
655              "context_length": 1048576,
656              "supported_parameters": ["reasoning", "include_reasoning", "max_tokens"],
657              "pricing": { "prompt": "0.00000015", "completion": "0.0000035", "internal_reasoning": "0.0000035" },
658              "architecture": { "input_modalities": ["text", "image", "audio", "video"] }
659            },
660            {
661              "id": "",
662              "name": "Empty — must be filtered"
663            }
664          ]
665        }"#;
666
667        #[test]
668        fn parses_minimal_model() {
669            let json = r#"{"data":[{"id":"test/model","name":"Test Model"}]}"#;
670            let models = parse_openrouter_catalog_models(json).expect("parse ok");
671            assert_eq!(models.len(), 1);
672            assert_eq!(models[0].id, "test/model");
673            assert_eq!(models[0].label.as_deref(), Some("Test Model"));
674            assert_eq!(models[0].runtime_id(), "openrouter/test/model");
675            assert_eq!(models[0].provider_key, "openrouter");
676            assert_eq!(models[0].source, CatalogSource::Live);
677        }
678
679        #[test]
680        fn parses_context_length_and_max_output() {
681            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
682            let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
683            assert_eq!(qwen.context_tokens, Some(131_072));
684            assert_eq!(qwen.max_output_tokens, Some(32_768));
685        }
686
687        #[test]
688        fn filters_empty_ids() {
689            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
690            assert!(!models.iter().any(|m| m.id.is_empty()));
691            assert_eq!(models.len(), 4);
692        }
693
694        #[test]
695        fn parses_pricing_fields() {
696            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
697            let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
698            assert_eq!(qwen.pricing.prompt.as_deref(), Some("0.0000001"));
699            assert_eq!(qwen.pricing.completion.as_deref(), Some("0.0000004"));
700            assert!(!qwen.pricing.has_internal_reasoning_cost());
701        }
702
703        #[test]
704        fn parses_internal_reasoning_cost_flag() {
705            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
706            let gemini = models.iter().find(|m| m.id == "google/gemini-2.5-flash").unwrap();
707            assert!(gemini.pricing.has_internal_reasoning_cost());
708        }
709
710        #[test]
711        fn no_reasoning_params_maps_to_none() {
712            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
713            let qwen = models.iter().find(|m| m.id == "qwen/qwen3-coder").unwrap();
714            assert_eq!(qwen.reasoning, ReasoningSupport::None);
715        }
716
717        #[test]
718        fn verbosity_param_maps_to_anthropic_adaptive() {
719            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
720            let claude = models.iter().find(|m| m.id == "anthropic/claude-opus-4-7").unwrap();
721            assert_eq!(claude.reasoning, ReasoningSupport::AnthropicAdaptive { adaptive: true });
722        }
723
724        #[test]
725        fn reasoning_effort_maps_to_openrouter_reasoning() {
726            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
727            let o4 = models.iter().find(|m| m.id == "openai/o4-mini").unwrap();
728            assert_eq!(o4.reasoning, ReasoningSupport::OpenRouter {
729                include_reasoning: false,
730                effort: true,
731                verbosity: false,
732                internal_reasoning_priced: false,
733            });
734        }
735
736        #[test]
737        fn reasoning_include_reasoning_maps_correctly() {
738            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
739            let gemini = models.iter().find(|m| m.id == "google/gemini-2.5-flash").unwrap();
740            assert_eq!(gemini.reasoning, ReasoningSupport::OpenRouter {
741                include_reasoning: true,
742                effort: false,
743                verbosity: false,
744                internal_reasoning_priced: true,
745            });
746        }
747
748        #[test]
749        fn parses_multimodal_input() {
750            let models = parse_openrouter_catalog_models(RICH_FIXTURE).expect("parse ok");
751            let gemini = models.iter().find(|m| m.id == "google/gemini-2.5-flash").unwrap();
752            assert!(gemini.input_modalities.contains(&Modality::Text));
753            assert!(gemini.input_modalities.contains(&Modality::Image));
754            assert!(gemini.input_modalities.contains(&Modality::Audio));
755            assert!(gemini.input_modalities.contains(&Modality::Video));
756        }
757
758        #[test]
759        fn missing_modalities_defaults_to_text() {
760            let json = r#"{"data":[{"id":"test/model"}]}"#;
761            let models = parse_openrouter_catalog_models(json).expect("parse ok");
762            assert_eq!(models[0].input_modalities, vec![Modality::Text]);
763        }
764
765        #[test]
766        fn parses_openrouter_rich_metadata_and_reasoning_flags() {
767            // Backward-compatible name used by existing test
768            let json = r#"{
769              "data": [{
770                "id": "anthropic/claude-sonnet-4.6",
771                "name": "Anthropic: Claude Sonnet 4.6",
772                "context_length": 1000000,
773                "architecture": { "input_modalities": ["text", "image"] },
774                "pricing": { "prompt": "0.000003", "completion": "0.000015", "internal_reasoning": "0.000012" },
775                "top_provider": { "max_completion_tokens": 128000 },
776                "supported_parameters": ["reasoning", "include_reasoning", "verbosity", "tools"]
777              }]
778            }"#;
779            let models = parse_openrouter_catalog_models(json).expect("parse ok");
780            assert_eq!(models.len(), 1);
781            let m = &models[0];
782            assert_eq!(m.runtime_id(), "openrouter/anthropic/claude-sonnet-4.6");
783            assert_eq!(m.context_tokens, Some(1_000_000));
784            assert_eq!(m.max_output_tokens, Some(128_000));
785            assert!(m.input_modalities.contains(&Modality::Image));
786            // verbosity wins → AnthropicAdaptive
787            assert_eq!(m.reasoning, ReasoningSupport::AnthropicAdaptive { adaptive: true });
788        }
789
790        #[test]
791        fn parses_openrouter_non_reasoning_model_as_none() {
792            let json = r#"{
793              "data": [{
794                "id": "meta-llama/llama-3.3-70b-instruct",
795                "name": "Meta: Llama 3.3 70B",
796                "supported_parameters": ["temperature", "tools"]
797              }]
798            }"#;
799            let models = parse_openrouter_catalog_models(json).expect("parse ok");
800            assert_eq!(models[0].reasoning, ReasoningSupport::None);
801        }
802
803        #[test]
804        fn invalid_json_returns_error() {
805            assert!(parse_openrouter_catalog_models("{not json}").is_err());
806        }
807
808        #[test]
809        fn missing_data_key_returns_error() {
810            assert!(parse_openrouter_catalog_models(r#"{"models":[]}"#).is_err());
811        }
812    }
813
814    // ── Task 3: Generic handler / compat with registry ────────────────────────
815
816
817
818
819    // ── Task 5: Anthropic parser and Codex static catalog ───────────────────
820
821    mod anthropic {
822        use super::super::*;
823
824        #[test]
825        fn parser_reads_optional_capabilities_and_token_limits() {
826            let json = r#"{
827                "data": [{
828                    "id": "claude-opus-4-7",
829                    "display_name": "Claude Opus 4.7",
830                    "max_input_tokens": 200000,
831                    "max_tokens": 32000,
832                    "capabilities": {
833                        "thinking": { "supported": true },
834                        "effort": { "supported": true }
835                    }
836                }],
837                "has_more": false
838            }"#;
839            let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
840            assert_eq!(models.len(), 1);
841            let model = &models[0];
842            assert_eq!(model.runtime_id(), "claude-opus-4-7");
843            assert_eq!(model.label.as_deref(), Some("Claude Opus 4.7"));
844            assert_eq!(model.context_tokens, Some(200_000));
845            assert_eq!(model.max_output_tokens, Some(32_000));
846            assert_eq!(model.reasoning, ReasoningSupport::AnthropicAdaptive { adaptive: true });
847        }
848
849        #[test]
850        fn parser_tolerates_missing_capabilities_as_unknown() {
851            let json = r#"{"data":[{"id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku"}]}"#;
852            let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
853            assert_eq!(models[0].reasoning, ReasoningSupport::Unknown);
854        }
855
856        #[test]
857        fn parser_filters_empty_ids() {
858            let json = r#"{"data":[{"id":""},{"id":"claude-sonnet-4-6"}]}"#;
859            let models = parse_anthropic_catalog_models(json).expect("parse anthropic");
860            assert_eq!(models.len(), 1);
861            assert_eq!(models[0].id, "claude-sonnet-4-6");
862        }
863    }
864
865    mod codex {
866        use super::super::*;
867
868        #[test]
869        fn static_catalog_uses_fallback_source_and_prefixed_runtime_ids() {
870            let models = codex_static_catalog_models();
871            assert!(models.iter().any(|m| m.id == "gpt-5.5"));
872            assert!(models.iter().any(|m| m.id == "gpt-5.4"));
873            assert!(models.iter().any(|m| m.id == "gpt-5.4-mini"));
874            assert!(!models.iter().any(|m| m.id == "gpt-5.5-pro"));
875            assert!(!models.iter().any(|m| m.id == "gpt-5.4-nano"));
876            assert!(!models.iter().any(|m| m.id == "gpt-5.1-codex-mini"));
877            assert!(models.iter().all(|m| m.source == CatalogSource::StaticFallback));
878            assert!(models.iter().all(|m| m.runtime_id().starts_with("openai-codex/")));
879        }
880    }
881
882    // ── Task 4: Groq and NVIDIA enrichment ──────────────────────────────────
883
884    mod groq {
885        use super::super::*;
886
887        #[test]
888        fn parser_extracts_context_window_and_filters_inactive() {
889            let json = r#"{"data":[
890                {"id":"llama-3.3-70b-versatile","active":true,"context_window":131072,"owned_by":"Meta"},
891                {"id":"old-model-v1","active":false,"context_window":8192,"owned_by":"Meta"}
892            ]}"#;
893            let models = parse_groq_catalog_models(json).expect("parse groq");
894            assert_eq!(models.len(), 1);
895            assert_eq!(models[0].id, "llama-3.3-70b-versatile");
896            assert_eq!(models[0].context_tokens, Some(131_072));
897            assert_eq!(models[0].reasoning, ReasoningSupport::None);
898        }
899
900        #[test]
901        fn inference_maps_reasoning_families() {
902            assert_eq!(infer_groq_reasoning("openai/gpt-oss-120b"), ReasoningSupport::GroqReasoning);
903            assert_eq!(infer_groq_reasoning("qwen/qwen3-32b"), ReasoningSupport::GroqReasoning);
904            assert_eq!(infer_groq_reasoning("groq/compound-mini"), ReasoningSupport::GroqReasoning);
905            assert_eq!(infer_groq_reasoning("llama-3.3-70b-versatile"), ReasoningSupport::None);
906        }
907
908        #[test]
909        fn parser_filters_empty_ids() {
910            let json = r#"{"data":[{"id":"","active":true},{"id":"openai/gpt-oss-20b","active":true,"context_window":131072}]}"#;
911            let models = parse_groq_catalog_models(json).expect("parse groq");
912            assert_eq!(models.len(), 1);
913            assert_eq!(models[0].id, "openai/gpt-oss-20b");
914        }
915    }
916
917    mod nvidia {
918        use super::super::*;
919
920        #[test]
921        fn parser_dedupes_and_enriches_known_context() {
922            let json = r#"{"data":[
923                {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
924                {"id":"nvidia/llama-3.1-nemotron-ultra-253b-v1","owned_by":"nvidia"},
925                {"id":"moonshotai/kimi-k2-thinking","owned_by":"moonshotai"}
926            ]}"#;
927            let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
928            assert_eq!(models.len(), 2);
929            let ultra = models.iter().find(|m| m.id.contains("ultra")).unwrap();
930            assert_eq!(ultra.context_tokens, Some(128_000));
931            assert_eq!(ultra.reasoning, ReasoningSupport::NvidiaInlineThinking);
932            let kimi = models.iter().find(|m| m.id.contains("kimi")).unwrap();
933            assert_eq!(kimi.context_tokens, Some(256_000));
934        }
935
936        #[test]
937        fn inference_detects_thinking_and_standard_models() {
938            assert_eq!(infer_nvidia_reasoning("qwen/qwen3-next-80b-a3b-thinking"), ReasoningSupport::NvidiaInlineThinking);
939            assert_eq!(infer_nvidia_reasoning("nvidia/cosmos-reason2-8b"), ReasoningSupport::NvidiaInlineThinking);
940            assert_eq!(infer_nvidia_reasoning("meta/llama-3.3-70b-instruct"), ReasoningSupport::None);
941        }
942
943        #[test]
944        fn parser_filters_empty_ids() {
945            let json = r#"{"data":[{"id":""},{"id":"meta/llama-3.3-70b-instruct"}]}"#;
946            let models = parse_nvidia_catalog_models(json).expect("parse nvidia");
947            assert_eq!(models.len(), 1);
948            assert_eq!(models[0].id, "meta/llama-3.3-70b-instruct");
949        }
950    }
951
952
953    mod generic_compat {
954        use super::super::*;
955
956        #[test]
957        fn parses_generic_catalog_models_and_filters_empty_ids() {
958            let json = r#"{
959                "data": [
960                    { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
961                    { "id": "" },
962                    { "id": "openai/gpt-oss-120b" }
963                ]
964            }"#;
965            let models = parse_generic_catalog_models(json, "openrouter", "OpenRouter")
966                .expect("parse ok");
967            assert_eq!(models.len(), 2);
968            assert_eq!(models[0].runtime_id(), "openrouter/qwen/qwen3-coder");
969            assert_eq!(models[0].display_label(), "Qwen: Qwen3 Coder");
970            assert_eq!(models[1].display_label(), "openai/gpt-oss-120b");
971        }
972
973        #[test]
974        fn whitespace_only_id_is_filtered() {
975            let json = r#"{"data":[{"id":"   "},{"id":"valid"}]}"#;
976            let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
977            assert_eq!(models.len(), 1);
978            assert_eq!(models[0].id, "valid");
979        }
980
981        #[test]
982        fn whitespace_label_stored_as_none() {
983            let json = r#"{"data":[{"id":"m1","name":"   "}]}"#;
984            let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
985            assert_eq!(models[0].label, None);
986        }
987
988        #[test]
989        fn generic_catalog_source_is_live() {
990            let json = r#"{"data":[{"id":"m1","name":"Model One"}]}"#;
991            let models = parse_generic_catalog_models(json, "testprovider", "Test")
992                .expect("parse ok");
993            assert_eq!(models[0].source, CatalogSource::Live);
994        }
995
996        #[test]
997        fn generic_reasoning_is_generic_open_ai() {
998            let json = r#"{"data":[{"id":"m1"}]}"#;
999            let models = parse_generic_catalog_models(json, "p", "P").expect("parse ok");
1000            assert_eq!(models[0].reasoning, ReasoningSupport::GenericOpenAi);
1001        }
1002
1003        #[test]
1004        fn generic_parse_matches_legacy_parse_behavior() {
1005            // parse_generic_catalog_models should produce same ids/labels
1006            // as the legacy registry::parse_provider_models_response
1007            let json = r#"{
1008                "data": [
1009                    { "id": "qwen/qwen3-coder", "name": "Qwen: Qwen3 Coder" },
1010                    { "id": "openai/gpt-oss-120b" }
1011                ]
1012            }"#;
1013            let legacy = super::super::super::registry::parse_provider_models_response(json)
1014                .expect("legacy parse ok");
1015            let catalog = parse_generic_catalog_models(json, "openrouter", "OpenRouter")
1016                .expect("catalog parse ok");
1017            assert_eq!(legacy.len(), catalog.len());
1018            for (l, c) in legacy.iter().zip(catalog.iter()) {
1019                assert_eq!(l.id, c.id, "ids must match");
1020                assert_eq!(l.name, c.label, "labels must match");
1021            }
1022        }
1023
1024        #[test]
1025        fn static_seeds_from_spec_all_providers() {
1026            // Every registered provider should produce at least one seed
1027            for spec in super::super::super::registry::providers() {
1028                if spec.models.is_empty() { continue; }
1029                let seeds = super::super::static_seeds_from_spec(spec);
1030                assert!(!seeds.is_empty(), "no seeds for {}", spec.key);
1031                assert!(
1032                    seeds.iter().all(|m| m.runtime_id().starts_with(&format!("{}/", spec.key))),
1033                    "runtime_id prefix wrong for {}",
1034                    spec.key
1035                );
1036            }
1037        }
1038    }
1039}