1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ModelLimits {
34 pub context: Option<u64>,
36 pub input: Option<u64>,
38 pub output: Option<u64>,
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct ModelEntry {
51 pub id: String,
53 pub name: String,
55 pub provider_id: String,
57 #[serde(default)]
59 pub family: Option<String>,
60 #[serde(default)]
62 pub release_date: Option<String>,
63 #[serde(default)]
65 pub knowledge: Option<String>,
66 #[serde(default)]
68 pub limits: Option<ModelLimits>,
69 #[serde(default)]
71 pub cost: Option<ModelCost>,
72 #[serde(default)]
74 pub modalities: Option<ModelModalities>,
75 #[serde(default)]
77 pub capabilities: Option<ModelCapabilities>,
78}
79
80impl From<ModelEntry> for ModelDescriptor {
81 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#[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 #[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#[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#[derive(Debug, Clone, Deserialize)]
174pub struct ModelsDevPayload(HashMap<String, RawProvider>);
175
176impl ModelsDevPayload {
177 #[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 #[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 #[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 #[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 #[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
221fn 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
236fn 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 streaming: true,
246 });
247 let limits = m.limit.as_ref().map(|l| ModelLimits {
248 context: l.context,
249 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
267fn 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
283pub 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
292pub fn fetch() -> Result<ModelsDevPayload> {
294 fetch_from(MODELS_DEV_URL)
295}
296
297pub 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
313pub 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 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 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); 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 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); }
430
431 #[test]
432 fn test_from_entry_to_descriptor_requires_both_limits() {
433 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 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 assert_eq!(payload.provider_api_base("anthropic"), None);
491 }
492}