1use crate::HarnessModel;
21
22pub fn provider_models(provider: &str) -> Vec<HarnessModel> {
27 #[cfg(feature = "models-dev")]
28 {
29 imp::provider_models(provider)
30 }
31 #[cfg(not(feature = "models-dev"))]
32 {
33 let _ = provider;
34 Vec::new()
35 }
36}
37
38#[cfg(feature = "models-dev")]
39mod imp {
40 use std::collections::HashMap;
41 use std::path::PathBuf;
42 use std::sync::OnceLock;
43 use std::time::Duration;
44
45 use serde::Deserialize;
46
47 use crate::HarnessModel;
48
49 const API_URL: &str = "https://models.dev/api.json";
50
51 #[derive(Deserialize)]
53 struct Catalog(HashMap<String, Provider>);
54
55 #[derive(Deserialize)]
56 struct Provider {
57 #[serde(default)]
58 models: HashMap<String, Model>,
59 }
60
61 #[derive(Deserialize)]
62 struct Model {
63 id: String,
65 #[serde(default)]
67 name: Option<String>,
68 #[serde(default)]
71 tool_call: bool,
72 #[serde(default)]
75 release_date: Option<String>,
76 }
77
78 fn catalog() -> Option<&'static Catalog> {
83 static CACHE: OnceLock<Option<Catalog>> = OnceLock::new();
84 CACHE
85 .get_or_init(|| {
86 if let Some(cached) = load_cached() {
87 if cache_is_stale() {
89 std::thread::spawn(refresh_cache);
90 }
91 return Some(cached);
92 }
93 let body = fetch_remote()?;
94 write_cache(&body);
95 serde_json::from_str(&body).ok()
96 })
97 .as_ref()
98 }
99
100 fn cache_path() -> Option<PathBuf> {
103 let dir = std::env::var_os("AGENT_HARNESS_CACHE_DIR")?;
104 Some(PathBuf::from(dir).join("models_dev.json"))
105 }
106
107 fn load_cached() -> Option<Catalog> {
108 let body = std::fs::read_to_string(cache_path()?).ok()?;
109 serde_json::from_str(&body).ok()
110 }
111
112 fn write_cache(body: &str) {
113 let Some(path) = cache_path() else {
114 return;
115 };
116 if let Some(parent) = path.parent() {
117 let _ = std::fs::create_dir_all(parent);
118 }
119 let _ = std::fs::write(path, body);
120 }
121
122 fn fetch_remote() -> Option<String> {
123 ureq::get(API_URL)
124 .timeout(Duration::from_secs(8))
125 .call()
126 .ok()?
127 .into_string()
128 .ok()
129 }
130
131 fn refresh_cache() {
133 if let Some(body) = fetch_remote() {
134 write_cache(&body);
135 }
136 }
137
138 fn cache_is_stale() -> bool {
141 const MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
142 let Some(path) = cache_path() else {
143 return false;
144 };
145 match std::fs::metadata(&path).and_then(|meta| meta.modified()) {
146 Ok(modified) => modified.elapsed().map(|age| age >= MAX_AGE).unwrap_or(true),
147 Err(_) => true,
148 }
149 }
150
151 pub fn provider_models(provider: &str) -> Vec<HarnessModel> {
152 catalog().map(|c| select(c, provider)).unwrap_or_default()
153 }
154
155 fn select(catalog: &Catalog, provider: &str) -> Vec<HarnessModel> {
157 let Some(p) = catalog.0.get(provider) else {
158 return Vec::new();
159 };
160 let mut models: Vec<&Model> = p.models.values().filter(|m| m.tool_call).collect();
161 models.sort_by(|a, b| {
165 b.release_date
166 .cmp(&a.release_date)
167 .then_with(|| a.id.cmp(&b.id))
168 });
169 models
170 .into_iter()
171 .map(|m| HarnessModel {
172 value: m.id.clone(),
173 label: m.name.clone().unwrap_or_else(|| m.id.clone()),
174 })
175 .collect()
176 }
177
178 #[cfg(test)]
179 mod tests {
180 use super::*;
181
182 #[test]
183 fn select_keeps_only_tool_call_models_and_maps_name() {
184 let json = r#"{
185 "anthropic": { "models": {
186 "claude-x": { "id": "claude-x", "name": "Claude X", "tool_call": true },
187 "embed-x": { "id": "embed-x", "name": "Embed X", "tool_call": false }
188 }},
189 "openai": { "models": {
190 "o9": { "id": "o9", "tool_call": true }
191 }}
192 }"#;
193 let catalog: Catalog = serde_json::from_str(json).expect("parse catalog");
194
195 let a = select(&catalog, "anthropic");
197 assert_eq!(a, vec![HarnessModel { value: "claude-x".into(), label: "Claude X".into() }]);
198
199 let o = select(&catalog, "openai");
201 assert_eq!(o, vec![HarnessModel { value: "o9".into(), label: "o9".into() }]);
202
203 assert!(select(&catalog, "nope").is_empty());
205 }
206
207 #[test]
208 fn select_orders_newest_release_first() {
209 let json = r#"{
210 "anthropic": { "models": {
211 "old": { "id": "old", "tool_call": true, "release_date": "2023-03-01" },
212 "new": { "id": "new", "tool_call": true, "release_date": "2024-10-01" },
213 "mid": { "id": "mid", "tool_call": true, "release_date": "2024-02-01" },
214 "undated": { "id": "undated", "tool_call": true }
215 }}
216 }"#;
217 let catalog: Catalog = serde_json::from_str(json).expect("parse catalog");
218 let ids: Vec<String> =
219 select(&catalog, "anthropic").into_iter().map(|m| m.value).collect();
220 assert_eq!(ids, ["new", "mid", "old", "undated"], "newest first, undated last");
221 }
222
223 #[test]
227 #[ignore = "network: fetches https://models.dev/api.json"]
228 fn live_catalog_has_anthropic_and_openai_models() {
229 assert!(!provider_models("anthropic").is_empty(), "anthropic should list models");
230 assert!(!provider_models("openai").is_empty(), "openai should list models");
231 assert!(provider_models("totally-unknown-xyz").is_empty());
232 }
233 }
234}