use crate::provider::{ModelCapabilities, ModelCost, ModelDescriptor, ModelLimit, ModelModalities};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
const MODELS_DEV_URL: &str = "https://models.dev/api.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelLimits {
pub context: Option<u64>,
pub input: Option<u64>,
pub output: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ModelEntry {
pub id: String,
pub name: String,
pub provider_id: String,
#[serde(default)]
pub family: Option<String>,
#[serde(default)]
pub release_date: Option<String>,
#[serde(default)]
pub knowledge: Option<String>,
#[serde(default)]
pub limits: Option<ModelLimits>,
#[serde(default)]
pub cost: Option<ModelCost>,
#[serde(default)]
pub modalities: Option<ModelModalities>,
#[serde(default)]
pub capabilities: Option<ModelCapabilities>,
}
impl From<ModelEntry> for ModelDescriptor {
fn from(entry: ModelEntry) -> Self {
let limit = entry.limits.and_then(|l| match (l.context, l.output) {
(Some(context), Some(output)) => Some(ModelLimit { context, output }),
_ => None,
});
ModelDescriptor {
id: entry.id,
name: entry.name,
family: entry.family,
release_date: entry.release_date,
cost: entry.cost,
limit,
modalities: entry.modalities,
capabilities: entry.capabilities,
knowledge: entry.knowledge,
}
}
}
#[derive(Debug, Clone, Deserialize)]
struct RawModel {
id: String,
name: String,
#[serde(default)]
family: Option<String>,
#[serde(default)]
release_date: Option<String>,
#[serde(default)]
knowledge: Option<String>,
#[serde(default)]
attachment: Option<bool>,
#[serde(default)]
reasoning: Option<bool>,
#[serde(default)]
temperature: Option<bool>,
#[serde(default)]
tool_call: Option<bool>,
#[serde(default)]
limit: Option<RawLimit>,
#[serde(default)]
cost: Option<ModelCost>,
#[serde(default)]
modalities: Option<ModelModalities>,
}
#[derive(Debug, Clone, Deserialize)]
struct RawLimit {
#[serde(default)]
context: Option<u64>,
#[serde(default)]
output: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
struct RawProvider {
#[serde(default)]
#[allow(dead_code)]
env: Vec<String>,
#[serde(default)]
npm: Option<String>,
#[serde(default)]
api: Option<String>,
#[serde(default)]
doc: Option<String>,
#[serde(default)]
#[allow(dead_code)]
name: Option<String>,
#[serde(default)]
models: HashMap<String, RawModel>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ModelsDevPayload(HashMap<String, RawProvider>);
impl ModelsDevPayload {
#[must_use]
pub fn entries(&self) -> Vec<ModelEntry> {
let mut provider_ids: Vec<&String> = self.0.keys().collect();
provider_ids.sort();
provider_ids
.into_iter()
.flat_map(|pid| provider_to_entries(pid, &self.0[pid]))
.collect()
}
#[must_use]
pub fn provider_models(&self, provider_key: &str) -> Vec<ModelDescriptor> {
self.0
.get(provider_key)
.map(|p| provider_to_entries(provider_key, p))
.unwrap_or_default()
.into_iter()
.map(ModelDescriptor::from)
.collect()
}
#[must_use]
pub fn provider_api_base(&self, provider_key: &str) -> Option<&str> {
self.0.get(provider_key).and_then(|p| p.api.as_deref())
}
#[must_use]
pub fn provider_npm(&self, provider_key: &str) -> Option<&str> {
self.0.get(provider_key).and_then(|p| p.npm.as_deref())
}
#[must_use]
pub fn provider_doc(&self, provider_key: &str) -> Option<&str> {
self.0.get(provider_key).and_then(|p| p.doc.as_deref())
}
}
fn provider_to_entries(provider_id: &str, provider: &RawProvider) -> Vec<ModelEntry> {
let mut model_ids: Vec<&String> = provider.models.keys().collect();
model_ids.sort();
model_ids
.into_iter()
.filter_map(|mid| {
provider
.models
.get(mid)
.map(|m| raw_to_entry(provider_id, m))
})
.collect()
}
fn raw_to_entry(provider_id: &str, m: &RawModel) -> ModelEntry {
let capabilities = Some(ModelCapabilities {
attachment: m.attachment.unwrap_or(false),
reasoning: m.reasoning.unwrap_or(false),
temperature: m.temperature.unwrap_or(false),
tool_call: m.tool_call.unwrap_or(false),
streaming: true,
});
let limits = m.limit.as_ref().map(|l| ModelLimits {
context: l.context,
input: None,
output: l.output,
});
ModelEntry {
id: m.id.clone(),
name: m.name.clone(),
provider_id: provider_id.to_string(),
family: m.family.clone(),
release_date: m.release_date.clone(),
knowledge: m.knowledge.clone(),
limits,
cost: m.cost.clone(),
modalities: m.modalities.clone(),
capabilities,
}
}
fn http_get(url: &str) -> anyhow::Result<String> {
let config = ureq::config::Config::builder()
.timeout_global(Some(std::time::Duration::from_secs(10)))
.build();
let agent = ureq::Agent::new_with_config(config);
let mut resp = agent.get(url).call()?;
Ok(resp.body_mut().read_to_string()?)
}
pub fn fetch_from(url: &str) -> anyhow::Result<ModelsDevPayload> {
let body = http_get(url)?;
serde_json::from_str(&body).map_err(Into::into)
}
pub fn fetch() -> anyhow::Result<ModelsDevPayload> {
fetch_from(MODELS_DEV_URL)
}
pub fn fetch_and_cache(cache_path: &str) -> anyhow::Result<ModelsDevPayload> {
let body = http_get(MODELS_DEV_URL)?;
if let Some(parent) = Path::new(cache_path).parent() {
fs::create_dir_all(parent)?;
}
fs::write(cache_path, &body)?;
serde_json::from_str(&body).map_err(Into::into)
}
pub fn load_cache(cache_path: &str) -> anyhow::Result<Option<ModelsDevPayload>> {
if !Path::new(cache_path).exists() {
return Ok(None);
}
let data = fs::read_to_string(cache_path)?;
let payload: ModelsDevPayload = serde_json::from_str(&data)?;
Ok(Some(payload))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_payload() -> &'static str {
r#"{
"anthropic": {
"id": "anthropic",
"env": ["ANTHROPIC_API_KEY"],
"npm": "@ai-sdk/anthropic",
"name": "Anthropic",
"doc": "https://docs.anthropic.com",
"models": {
"claude-opus-4-5": {
"id": "claude-opus-4-5",
"name": "Claude Opus 4.5",
"family": "claude-opus",
"attachment": true,
"reasoning": true,
"reasoning_options": [{"type": "effort"}],
"tool_call": true,
"temperature": true,
"interleaved": {"field": "reasoning_content"},
"knowledge": "2025-03-31",
"release_date": "2025-11-24",
"last_updated": "2025-11-24",
"modalities": {"input": ["text", "image", "pdf"], "output": ["text"]},
"open_weights": false,
"limit": {"context": 200000, "output": 64000},
"cost": {"input": 5, "output": 25, "cache_read": 0.5, "cache_write": 6.25}
}
}
},
"openai": {
"id": "openai",
"env": ["OPENAI_API_KEY"],
"models": {
"gpt-4o-mini": {
"id": "gpt-4o-mini",
"name": "GPT-4o mini",
"tool_call": true,
"temperature": true,
"limit": {"context": 128000, "output": 16384},
"cost": {"input": 0.15, "output": 0.6, "tiers": [{"min_tokens": 0}]}
}
}
}
}"#
}
#[test]
fn test_parse_real_schema() {
let payload: ModelsDevPayload = serde_json::from_str(sample_payload()).unwrap();
let entries = payload.entries();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].id, "claude-opus-4-5");
assert_eq!(entries[0].provider_id, "anthropic");
assert_eq!(entries[1].id, "gpt-4o-mini");
assert_eq!(entries[1].provider_id, "openai");
}
#[test]
fn test_unknown_fields_are_ignored() {
let payload: ModelsDevPayload = serde_json::from_str(sample_payload()).unwrap();
let opus = payload
.provider_models("anthropic")
.into_iter()
.find(|m| m.id == "claude-opus-4-5")
.unwrap();
assert!(opus.capabilities.as_ref().unwrap().attachment);
assert!(opus.capabilities.as_ref().unwrap().reasoning);
assert!(opus.capabilities.as_ref().unwrap().streaming); assert_eq!(opus.cost.as_ref().unwrap().cache_read, Some(0.5));
}
#[test]
fn test_partial_cost_and_optional_cache() {
let payload: ModelsDevPayload = serde_json::from_str(sample_payload()).unwrap();
let mini = payload
.provider_models("openai")
.into_iter()
.find(|m| m.id == "gpt-4o-mini")
.unwrap();
let cost = mini.cost.as_ref().unwrap();
assert_eq!(cost.input, 0.15);
assert_eq!(cost.output, 0.6);
assert_eq!(cost.cache_read, None);
assert_eq!(cost.cache_write, None);
}
#[test]
fn test_limit_has_no_input_after_transform() {
let payload: ModelsDevPayload = serde_json::from_str(sample_payload()).unwrap();
let entry = payload
.entries()
.into_iter()
.find(|e| e.id == "claude-opus-4-5")
.unwrap();
let limits = entry.limits.unwrap();
assert_eq!(limits.context, Some(200_000));
assert_eq!(limits.output, Some(64_000));
assert_eq!(limits.input, None); }
#[test]
fn test_from_entry_to_descriptor_requires_both_limits() {
let full = ModelEntry {
id: "m".to_string(),
name: "M".to_string(),
provider_id: "p".to_string(),
family: None,
release_date: None,
knowledge: None,
limits: Some(ModelLimits {
context: Some(1000),
input: None,
output: Some(500),
}),
cost: None,
modalities: None,
capabilities: None,
};
let d: ModelDescriptor = full.into();
assert_eq!(d.limit.as_ref().unwrap().context, 1000);
assert_eq!(d.limit.as_ref().unwrap().output, 500);
let partial = ModelEntry {
limits: Some(ModelLimits {
context: Some(1000),
input: None,
output: None,
}),
..ModelEntry {
id: "m".to_string(),
name: "M".to_string(),
provider_id: "p".to_string(),
family: None,
release_date: None,
knowledge: None,
limits: None,
cost: None,
modalities: None,
capabilities: None,
}
};
let d2: ModelDescriptor = partial.into();
assert!(d2.limit.is_none());
}
#[test]
fn test_provider_models_sorted_and_absent_key() {
let payload: ModelsDevPayload = serde_json::from_str(sample_payload()).unwrap();
let anthropic = payload.provider_models("anthropic");
assert_eq!(anthropic.len(), 1);
assert!(payload.provider_models("nonexistent").is_empty());
}
#[test]
fn test_provider_api_base() {
let payload: ModelsDevPayload = serde_json::from_str(sample_payload()).unwrap();
assert_eq!(payload.provider_api_base("anthropic"), None);
}
}