Skip to main content

llm_kernel/discovery/
models_dev.rs

1//! Client for the [models.dev](https://github.com/anomalyco/models.dev) model catalog API.
2//!
3//! Fetches the live catalog from `https://models.dev/api.json`, whose top-level
4//! shape is a provider-keyed map:
5//!
6//! ```json
7//! { "<provider_id>": { "id": ..., "env": [...], "api": ...,
8//!                      "models": { "<model_id>": { "cost": {...}, "limit": {...}, ... } } } }
9//! ```
10//!
11//! Results are flattened into [`ModelEntry`] records carrying the full
12//! models.dev metadata (pricing, limits, modalities, capabilities) so they can
13//! feed [`crate::provider::ProviderIndex::with_discovered`].
14
15use crate::error::{KernelError, Result};
16use crate::provider::{ModelCapabilities, ModelCost, ModelDescriptor, ModelLimit, ModelModalities};
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use std::fs;
20use std::path::Path;
21
22const MODELS_DEV_URL: &str = "https://models.dev/api.json";
23
24// ---------------------------------------------------------------------------
25// Discovery result types
26// ---------------------------------------------------------------------------
27
28/// Token limits reported by a discovery source for a single model.
29///
30/// All fields are optional so heterogeneous sources (models.dev, Ollama,
31/// OpenAI-compatible) can report only what they know.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ModelLimits {
34    /// Maximum context window in tokens.
35    pub context: Option<u64>,
36    /// Maximum input tokens per request.
37    pub input: Option<u64>,
38    /// Maximum output tokens per request.
39    pub output: Option<u64>,
40}
41
42/// A single model entry discovered from a remote catalog.
43///
44/// `id`, `name`, and `provider_id` are always present; the remaining fields are
45/// optional so sparse sources can populate only what they know. The richer
46/// fields mirror [`ModelDescriptor`] so a discovered entry can feed
47/// [`crate::provider::ProviderIndex::estimate_cost`] via
48/// [`crate::provider::ProviderIndex::with_discovered`].
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct ModelEntry {
51    /// Unique model identifier (e.g. `"anthropic/claude-3-5-sonnet"`).
52    pub id: String,
53    /// Human-readable model name.
54    pub name: String,
55    /// Provider that hosts this model (e.g. `"anthropic"`).
56    pub provider_id: String,
57    /// Model family grouping.
58    #[serde(default)]
59    pub family: Option<String>,
60    /// ISO 8601 release date.
61    #[serde(default)]
62    pub release_date: Option<String>,
63    /// Knowledge cutoff date (ISO 8601).
64    #[serde(default)]
65    pub knowledge: Option<String>,
66    /// Token limits for context and output.
67    #[serde(default)]
68    pub limits: Option<ModelLimits>,
69    /// Per-million-token pricing.
70    #[serde(default)]
71    pub cost: Option<ModelCost>,
72    /// Input and output modalities.
73    #[serde(default)]
74    pub modalities: Option<ModelModalities>,
75    /// Capability flags (tool calling, streaming, etc.).
76    #[serde(default)]
77    pub capabilities: Option<ModelCapabilities>,
78}
79
80impl From<ModelEntry> for ModelDescriptor {
81    /// Project a discovery entry onto a catalog model descriptor.
82    ///
83    /// `limits` is carried over only when both `context` and `output` are known
84    /// (the catalog representation requires both). Pricing, modalities, and
85    /// capabilities pass through unchanged.
86    fn from(entry: ModelEntry) -> Self {
87        let limit = entry.limits.and_then(|l| match (l.context, l.output) {
88            (Some(context), Some(output)) => Some(ModelLimit { context, output }),
89            _ => None,
90        });
91        ModelDescriptor {
92            id: entry.id,
93            name: entry.name,
94            family: entry.family,
95            release_date: entry.release_date,
96            cost: entry.cost,
97            limit,
98            modalities: entry.modalities,
99            capabilities: entry.capabilities,
100            knowledge: entry.knowledge,
101        }
102    }
103}
104
105// ---------------------------------------------------------------------------
106// models.dev raw schema (private — deserialized, then transformed)
107// ---------------------------------------------------------------------------
108
109/// A raw models.dev model object. Unknown fields (`reasoning_options`,
110/// `interleaved`, `structured_output`, `open_weights`, `last_updated`,
111/// `cost.tiers`, `cost.context_over_200k`) are silently ignored by serde.
112#[derive(Debug, Clone, Deserialize)]
113struct RawModel {
114    id: String,
115    name: String,
116    #[serde(default)]
117    family: Option<String>,
118    #[serde(default)]
119    release_date: Option<String>,
120    #[serde(default)]
121    knowledge: Option<String>,
122    // Flat capability bools — models.dev puts these at the model top level, not
123    // nested under a `capabilities` object.
124    #[serde(default)]
125    attachment: Option<bool>,
126    #[serde(default)]
127    reasoning: Option<bool>,
128    #[serde(default)]
129    temperature: Option<bool>,
130    #[serde(default)]
131    tool_call: Option<bool>,
132    #[serde(default)]
133    limit: Option<RawLimit>,
134    #[serde(default)]
135    cost: Option<ModelCost>,
136    #[serde(default)]
137    modalities: Option<ModelModalities>,
138}
139
140#[derive(Debug, Clone, Deserialize)]
141struct RawLimit {
142    #[serde(default)]
143    context: Option<u64>,
144    #[serde(default)]
145    output: Option<u64>,
146}
147
148/// A raw models.dev provider object.
149#[derive(Debug, Clone, Deserialize)]
150struct RawProvider {
151    #[serde(default)]
152    #[allow(dead_code)]
153    env: Vec<String>,
154    #[serde(default)]
155    npm: Option<String>,
156    #[serde(default)]
157    api: Option<String>,
158    #[serde(default)]
159    doc: Option<String>,
160    #[serde(default)]
161    #[allow(dead_code)]
162    name: Option<String>,
163    #[serde(default)]
164    models: HashMap<String, RawModel>,
165}
166
167/// Top-level response payload from the models.dev API: a provider-keyed map.
168///
169/// **Breaking change:** this was previously `{ models: Vec<ModelEntry> }`, a
170/// shape that never matched the live API. The real response is a map of
171/// provider id → provider object; this type now mirrors it. The on-disk cache
172/// written by [`fetch_and_cache`] is byte-identical to the upstream payload.
173#[derive(Debug, Clone, Deserialize)]
174pub struct ModelsDevPayload(HashMap<String, RawProvider>);
175
176impl ModelsDevPayload {
177    /// Flatten every provider into discovery [`ModelEntry`] records, in
178    /// deterministic (sorted provider id, sorted model id) order.
179    #[must_use]
180    pub fn entries(&self) -> Vec<ModelEntry> {
181        let mut provider_ids: Vec<&String> = self.0.keys().collect();
182        provider_ids.sort();
183        provider_ids
184            .into_iter()
185            .flat_map(|pid| provider_to_entries(pid, &self.0[pid]))
186            .collect()
187    }
188
189    /// Return the catalog model descriptors for one upstream provider key, in
190    /// sorted model-id order. Returns an empty vec if the key is absent.
191    #[must_use]
192    pub fn provider_models(&self, provider_key: &str) -> Vec<ModelDescriptor> {
193        self.0
194            .get(provider_key)
195            .map(|p| provider_to_entries(provider_key, p))
196            .unwrap_or_default()
197            .into_iter()
198            .map(ModelDescriptor::from)
199            .collect()
200    }
201
202    /// Return the upstream provider's `api` base URL, if advertised.
203    #[must_use]
204    pub fn provider_api_base(&self, provider_key: &str) -> Option<&str> {
205        self.0.get(provider_key).and_then(|p| p.api.as_deref())
206    }
207
208    /// Return the upstream provider's npm package name, if any.
209    #[must_use]
210    pub fn provider_npm(&self, provider_key: &str) -> Option<&str> {
211        self.0.get(provider_key).and_then(|p| p.npm.as_deref())
212    }
213
214    /// Return the upstream provider's documentation URL, if any.
215    #[must_use]
216    pub fn provider_doc(&self, provider_key: &str) -> Option<&str> {
217        self.0.get(provider_key).and_then(|p| p.doc.as_deref())
218    }
219}
220
221/// Flatten one models.dev provider into discovery entries (sorted model order).
222fn provider_to_entries(provider_id: &str, provider: &RawProvider) -> Vec<ModelEntry> {
223    let mut model_ids: Vec<&String> = provider.models.keys().collect();
224    model_ids.sort();
225    model_ids
226        .into_iter()
227        .filter_map(|mid| {
228            provider
229                .models
230                .get(mid)
231                .map(|m| raw_to_entry(provider_id, m))
232        })
233        .collect()
234}
235
236/// Transform one raw models.dev model into a discovery entry.
237fn raw_to_entry(provider_id: &str, m: &RawModel) -> ModelEntry {
238    let capabilities = Some(ModelCapabilities {
239        attachment: m.attachment.unwrap_or(false),
240        reasoning: m.reasoning.unwrap_or(false),
241        temperature: m.temperature.unwrap_or(false),
242        tool_call: m.tool_call.unwrap_or(false),
243        // models.dev has no per-model streaming flag; default true (matches
244        // the catalog convention and `ModelCapabilities`'s serde default).
245        streaming: true,
246    });
247    let limits = m.limit.as_ref().map(|l| ModelLimits {
248        context: l.context,
249        // models.dev `limit` has no `input` field.
250        input: None,
251        output: l.output,
252    });
253    ModelEntry {
254        id: m.id.clone(),
255        name: m.name.clone(),
256        provider_id: provider_id.to_string(),
257        family: m.family.clone(),
258        release_date: m.release_date.clone(),
259        knowledge: m.knowledge.clone(),
260        limits,
261        cost: m.cost.clone(),
262        modalities: m.modalities.clone(),
263        capabilities,
264    }
265}
266
267// ---------------------------------------------------------------------------
268// Fetch + cache
269// ---------------------------------------------------------------------------
270
271/// Fetch the raw response body from `url` with a bounded timeout.
272fn http_get(url: &str) -> Result<String> {
273    let config = ureq::config::Config::builder()
274        .timeout_global(Some(std::time::Duration::from_secs(10)))
275        .build();
276    let agent = ureq::Agent::new_with_config(config);
277    let mut resp = agent.get(url).call().map_err(KernelError::discovery)?;
278    resp.body_mut()
279        .read_to_string()
280        .map_err(KernelError::discovery)
281}
282
283/// Fetch and parse the models.dev catalog from `url` (no disk cache).
284///
285/// **Trust boundary:** the URL is used verbatim with no host allowlist; pass
286/// only admin-configured values.
287pub fn fetch_from(url: &str) -> Result<ModelsDevPayload> {
288    let body = http_get(url)?;
289    serde_json::from_str(&body).map_err(Into::into)
290}
291
292/// Fetch and parse the catalog from the default models.dev endpoint (no cache).
293pub fn fetch() -> Result<ModelsDevPayload> {
294    fetch_from(MODELS_DEV_URL)
295}
296
297/// Fetch models from the models.dev API and cache the raw payload to disk.
298///
299/// The cache file is written byte-identical to the upstream response, so it
300/// diffs cleanly against `https://models.dev/api.json` and round-trips through
301/// [`load_cache`].
302pub fn fetch_and_cache(cache_path: &str) -> Result<ModelsDevPayload> {
303    let body = http_get(MODELS_DEV_URL)?;
304
305    if let Some(parent) = Path::new(cache_path).parent() {
306        fs::create_dir_all(parent)?;
307    }
308    fs::write(cache_path, &body)?;
309
310    serde_json::from_str(&body).map_err(Into::into)
311}
312
313/// Load previously cached models.dev data.
314pub fn load_cache(cache_path: &str) -> Result<Option<ModelsDevPayload>> {
315    if !Path::new(cache_path).exists() {
316        return Ok(None);
317    }
318    let data = fs::read_to_string(cache_path)?;
319    let payload: ModelsDevPayload = serde_json::from_str(&data)?;
320    Ok(Some(payload))
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    fn sample_payload() -> &'static str {
328        r#"{
329            "anthropic": {
330                "id": "anthropic",
331                "env": ["ANTHROPIC_API_KEY"],
332                "npm": "@ai-sdk/anthropic",
333                "name": "Anthropic",
334                "doc": "https://docs.anthropic.com",
335                "models": {
336                    "claude-opus-4-5": {
337                        "id": "claude-opus-4-5",
338                        "name": "Claude Opus 4.5",
339                        "family": "claude-opus",
340                        "attachment": true,
341                        "reasoning": true,
342                        "reasoning_options": [{"type": "effort"}],
343                        "tool_call": true,
344                        "temperature": true,
345                        "interleaved": {"field": "reasoning_content"},
346                        "knowledge": "2025-03-31",
347                        "release_date": "2025-11-24",
348                        "last_updated": "2025-11-24",
349                        "modalities": {"input": ["text", "image", "pdf"], "output": ["text"]},
350                        "open_weights": false,
351                        "limit": {"context": 200000, "output": 64000},
352                        "cost": {"input": 5, "output": 25, "cache_read": 0.5, "cache_write": 6.25}
353                    }
354                }
355            },
356            "openai": {
357                "id": "openai",
358                "env": ["OPENAI_API_KEY"],
359                "models": {
360                    "gpt-4o-mini": {
361                        "id": "gpt-4o-mini",
362                        "name": "GPT-4o mini",
363                        "tool_call": true,
364                        "temperature": true,
365                        "limit": {"context": 128000, "output": 16384},
366                        "cost": {"input": 0.15, "output": 0.6, "tiers": [{"min_tokens": 0}]}
367                    }
368                }
369            }
370        }"#
371    }
372
373    #[test]
374    fn test_parse_real_schema() {
375        let payload: ModelsDevPayload = serde_json::from_str(sample_payload()).unwrap();
376        let entries = payload.entries();
377        assert_eq!(entries.len(), 2);
378        // Sorted: anthropic/claude-opus-4-5 before openai/gpt-4o-mini.
379        assert_eq!(entries[0].id, "claude-opus-4-5");
380        assert_eq!(entries[0].provider_id, "anthropic");
381        assert_eq!(entries[1].id, "gpt-4o-mini");
382        assert_eq!(entries[1].provider_id, "openai");
383    }
384
385    #[test]
386    fn test_unknown_fields_are_ignored() {
387        // reasoning_options, interleaved, last_updated, open_weights,
388        // cost.tiers must be tolerated (serde skips unknown fields).
389        let payload: ModelsDevPayload = serde_json::from_str(sample_payload()).unwrap();
390        let opus = payload
391            .provider_models("anthropic")
392            .into_iter()
393            .find(|m| m.id == "claude-opus-4-5")
394            .unwrap();
395        assert!(opus.capabilities.as_ref().unwrap().attachment);
396        assert!(opus.capabilities.as_ref().unwrap().reasoning);
397        assert!(opus.capabilities.as_ref().unwrap().streaming); // defaulted
398        assert_eq!(opus.cost.as_ref().unwrap().cache_read, Some(0.5));
399    }
400
401    #[test]
402    fn test_partial_cost_and_optional_cache() {
403        let payload: ModelsDevPayload = serde_json::from_str(sample_payload()).unwrap();
404        let mini = payload
405            .provider_models("openai")
406            .into_iter()
407            .find(|m| m.id == "gpt-4o-mini")
408            .unwrap();
409        let cost = mini.cost.as_ref().unwrap();
410        assert_eq!(cost.input, 0.15);
411        assert_eq!(cost.output, 0.6);
412        // No cache fields upstream → None.
413        assert_eq!(cost.cache_read, None);
414        assert_eq!(cost.cache_write, None);
415    }
416
417    #[test]
418    fn test_limit_has_no_input_after_transform() {
419        let payload: ModelsDevPayload = serde_json::from_str(sample_payload()).unwrap();
420        let entry = payload
421            .entries()
422            .into_iter()
423            .find(|e| e.id == "claude-opus-4-5")
424            .unwrap();
425        let limits = entry.limits.unwrap();
426        assert_eq!(limits.context, Some(200_000));
427        assert_eq!(limits.output, Some(64_000));
428        assert_eq!(limits.input, None); // models.dev limit has no input
429    }
430
431    #[test]
432    fn test_from_entry_to_descriptor_requires_both_limits() {
433        // Both context + output present → descriptor.limit is Some.
434        let full = ModelEntry {
435            id: "m".to_string(),
436            name: "M".to_string(),
437            provider_id: "p".to_string(),
438            family: None,
439            release_date: None,
440            knowledge: None,
441            limits: Some(ModelLimits {
442                context: Some(1000),
443                input: None,
444                output: Some(500),
445            }),
446            cost: None,
447            modalities: None,
448            capabilities: None,
449        };
450        let d: ModelDescriptor = full.into();
451        assert_eq!(d.limit.as_ref().unwrap().context, 1000);
452        assert_eq!(d.limit.as_ref().unwrap().output, 500);
453
454        // Missing output → descriptor.limit is None.
455        let partial = ModelEntry {
456            limits: Some(ModelLimits {
457                context: Some(1000),
458                input: None,
459                output: None,
460            }),
461            ..ModelEntry {
462                id: "m".to_string(),
463                name: "M".to_string(),
464                provider_id: "p".to_string(),
465                family: None,
466                release_date: None,
467                knowledge: None,
468                limits: None,
469                cost: None,
470                modalities: None,
471                capabilities: None,
472            }
473        };
474        let d2: ModelDescriptor = partial.into();
475        assert!(d2.limit.is_none());
476    }
477
478    #[test]
479    fn test_provider_models_sorted_and_absent_key() {
480        let payload: ModelsDevPayload = serde_json::from_str(sample_payload()).unwrap();
481        let anthropic = payload.provider_models("anthropic");
482        assert_eq!(anthropic.len(), 1);
483        assert!(payload.provider_models("nonexistent").is_empty());
484    }
485
486    #[test]
487    fn test_provider_api_base() {
488        let payload: ModelsDevPayload = serde_json::from_str(sample_payload()).unwrap();
489        // openai has no `api` in the fixture; anthropic fixture omits it too.
490        assert_eq!(payload.provider_api_base("anthropic"), None);
491    }
492}