harness/models_dev.rs
1//! models.dev catalog lookup for [`Harness::list_models`].
2//!
3//! [models.dev](https://models.dev) is the open catalog of model specs (the same
4//! one opencode draws from). Its `api.json` is one GET, keyed by provider, so a
5//! CLI adapter tied to a provider (Claude → `anthropic`, Codex → `openai`) can
6//! offer a *live* model list instead of a hardcoded one — via [`provider_models`].
7//!
8//! The network call + HTTP client are gated behind the **`models-dev`** feature
9//! (off by default, keeping the neutral core HTTP-free). With the feature off,
10//! [`provider_models`] returns an empty list, so adapters fall back to their
11//! static models. With it on, the ~2 MB catalog is fetched once and cached **on
12//! disk** (under `AGENT_HARNESS_CACHE_DIR`, when the host app sets it): later
13//! launches load the cache instantly — so the picker works offline — and refresh
14//! it in the background. A provider's models are filtered to the agent-usable
15//! ones (`tool_call: true`, which drops embeddings / tts / image models), mapped
16//! to [`ModelChoice`], and ordered newest first.
17//!
18//! [`Harness::list_models`]: crate::Harness::list_models
19
20use crate::ModelChoice;
21
22/// The agent-usable models a provider serves per models.dev, mapped to
23/// [`ModelChoice`] and sorted by id for a stable picker order. Empty when the
24/// `models-dev` feature is off, the catalog can't be fetched, or the provider is
25/// unknown — so a caller can fall back to its own static list.
26/// A model's context window from the catalog, when it lists one.
27///
28/// The only cross-provider source: a hosted endpoint publishes its window in a
29/// shape of its own or not at all, so without this a hosted run has no window
30/// and profile selection has one less fact to work with.
31pub fn context_limit(provider: &str, model: &str) -> Option<u64> {
32 #[cfg(feature = "models-dev")]
33 {
34 imp::context_limit(provider, model)
35 }
36 #[cfg(not(feature = "models-dev"))]
37 {
38 let _ = (provider, model);
39 None
40 }
41}
42
43pub fn provider_models(provider: &str) -> Vec<ModelChoice> {
44 #[cfg(feature = "models-dev")]
45 {
46 imp::provider_models(provider)
47 }
48 #[cfg(not(feature = "models-dev"))]
49 {
50 let _ = provider;
51 Vec::new()
52 }
53}
54
55#[cfg(feature = "models-dev")]
56mod imp {
57 use std::collections::HashMap;
58 use std::path::{Path, PathBuf};
59 use std::sync::OnceLock;
60 use std::time::Duration;
61
62 use serde::Deserialize;
63
64 use crate::ModelChoice;
65
66 const API_URL: &str = "https://models.dev/api.json";
67
68 /// models.dev combined catalog: `{ <providerId>: { models: { <id>: Model } } }`.
69 #[derive(Deserialize)]
70 struct Catalog(HashMap<String, Provider>);
71
72 #[derive(Deserialize)]
73 struct Provider {
74 #[serde(default, deserialize_with = "models_skipping_unreadable")]
75 models: HashMap<String, Model>,
76 }
77
78 /// Deserialize the model map one entry at a time, dropping any that does
79 /// not parse.
80 ///
81 /// This is a ~4 MB file published by someone else, and `serde` fails a
82 /// whole document on one bad field. Derived normally, a single model
83 /// missing its `id` would cost **every provider** its entire list — and
84 /// refetching returns the same bytes, so it stays broken until upstream
85 /// fixes it. One unreadable model should cost that model.
86 fn models_skipping_unreadable<'de, D>(de: D) -> Result<HashMap<String, Model>, D::Error>
87 where
88 D: serde::Deserializer<'de>,
89 {
90 let raw = HashMap::<String, serde_json::Value>::deserialize(de)?;
91 Ok(raw
92 .into_iter()
93 .filter_map(|(key, value)| Some((key, serde_json::from_value(value).ok()?)))
94 .collect())
95 }
96
97 #[derive(Deserialize)]
98 struct Limit {
99 /// Context window in tokens.
100 #[serde(default)]
101 context: Option<u64>,
102 }
103
104 #[derive(Deserialize)]
105 struct Model {
106 /// Id passed to the CLI (`--model`).
107 id: String,
108 /// Human label; falls back to the id.
109 #[serde(default)]
110 name: Option<String>,
111 /// Supports tool calls — our proxy for "agent-usable" (text-only
112 /// embeddings / tts share the text modality but have `tool_call: false`).
113 #[serde(default)]
114 tool_call: bool,
115 /// ISO release date (`YYYY-MM-DD`) where models.dev has it — used to put
116 /// newer models first in the picker.
117 #[serde(default)]
118 release_date: Option<String>,
119 /// Context and output ceilings. The catalog is the only source of these
120 /// for a hosted provider: OpenRouter publishes a `context_length` on its
121 /// own models list, but nothing does across providers.
122 #[serde(default)]
123 limit: Option<Limit>,
124 }
125
126 /// The catalog for the process. Prefers the on-disk cache — instant and
127 /// works offline — and refreshes it in the background; on a cold first run
128 /// with no cache it fetches once and persists it. A miss caches `None`, so
129 /// callers fall back without retrying every call.
130 fn catalog() -> Option<&'static Catalog> {
131 static CACHE: OnceLock<Option<Catalog>> = OnceLock::new();
132 CACHE.get_or_init(|| load_or_fetch(fetch_remote)).as_ref()
133 }
134
135 /// Prefer the on-disk cache — instant, and works offline — refreshing it in
136 /// the background once it is a day old; on a cold run with no cache, fetch
137 /// once and persist.
138 ///
139 /// `fetch` is a parameter so this can be exercised without the network, and
140 /// without the process-wide `OnceLock` in [`catalog`] fixing the outcome
141 /// for every later test in the binary.
142 ///
143 /// A body is parsed before it is written: caching one we could not read
144 /// would spend the disk on something the next launch has to discard.
145 fn load_or_fetch(fetch: impl FnOnce() -> Option<String>) -> Option<Catalog> {
146 if let Some(cached) = load_cached() {
147 // The catalog changes slowly — refresh at most once a day.
148 if cache_is_stale() {
149 std::thread::spawn(refresh_cache);
150 }
151 return Some(cached);
152 }
153 let body = fetch()?;
154 let parsed = serde_json::from_str(&body).ok()?;
155 write_cache(&body);
156 Some(parsed)
157 }
158
159 /// Where the catalog is cached, when the host app names a cache dir via
160 /// `AGENT_HARNESS_CACHE_DIR`; `None` → no disk cache (fetch-only).
161 fn cache_path() -> Option<PathBuf> {
162 let dir = std::env::var_os("AGENT_HARNESS_CACHE_DIR")?;
163 Some(PathBuf::from(dir).join("models_dev.json"))
164 }
165
166 fn load_cached() -> Option<Catalog> {
167 let body = std::fs::read_to_string(cache_path()?).ok()?;
168 serde_json::from_str(&body).ok()
169 }
170
171 fn write_cache(body: &str) {
172 let Some(path) = cache_path() else {
173 return;
174 };
175 if let Some(parent) = path.parent() {
176 let _ = std::fs::create_dir_all(parent);
177 }
178 let _ = std::fs::write(path, body);
179 }
180
181 fn fetch_remote() -> Option<String> {
182 ureq::get(API_URL)
183 .timeout(Duration::from_secs(8))
184 .call()
185 .ok()?
186 .into_string()
187 .ok()
188 }
189
190 /// Refetch and rewrite the disk cache so the next launch is current.
191 fn refresh_cache() {
192 refresh_from(fetch_remote);
193 }
194
195 /// The rewrite itself, with the fetch as a parameter.
196 ///
197 /// A failed fetch leaves the existing cache untouched — this runs in the
198 /// background on a launch that already has a working catalog, so a network
199 /// blip must not trade it for nothing.
200 fn refresh_from(fetch: impl FnOnce() -> Option<String>) {
201 if let Some(body) = fetch() {
202 write_cache(&body);
203 }
204 }
205
206 /// Whether the cache file is at least a day old — the only time the
207 /// background refresh fires, so we re-fetch the ~2 MB catalog at most daily.
208 ///
209 /// A host that named no cache directory has nothing to refresh.
210 fn cache_is_stale() -> bool {
211 cache_path().is_some_and(|path| stale(&path))
212 }
213
214 /// How old is too old, given a path. Separate from [`cache_is_stale`] so the
215 /// rule can be checked against a real file without a process-wide
216 /// environment variable deciding where that file lives.
217 fn stale(path: &Path) -> bool {
218 const MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
219 match std::fs::metadata(path).and_then(|meta| meta.modified()) {
220 Ok(modified) => modified.elapsed().map(|age| age >= MAX_AGE).unwrap_or(true),
221 // Absent or unreadable: fetching is the way to find out, and the
222 // alternative is a picker that stays empty forever.
223 Err(_) => true,
224 }
225 }
226
227 pub fn context_limit(provider: &str, model: &str) -> Option<u64> {
228 catalog()?
229 .0
230 .get(provider)?
231 .models
232 .values()
233 .find(|entry| entry.id == model)?
234 .limit
235 .as_ref()?
236 .context
237 }
238
239 pub fn provider_models(provider: &str) -> Vec<ModelChoice> {
240 catalog().map(|c| select(c, provider)).unwrap_or_default()
241 }
242
243 /// Pure filter+map (no network), so the selection logic is unit-testable.
244 fn select(catalog: &Catalog, provider: &str) -> Vec<ModelChoice> {
245 let Some(p) = catalog.0.get(provider) else {
246 return Vec::new();
247 };
248 let mut models: Vec<&Model> = p.models.values().filter(|m| m.tool_call).collect();
249 // Newest first: models.dev `release_date` is ISO (`YYYY-MM-DD`), so a
250 // reverse string compare orders chronologically; undated models sort to
251 // the bottom, ties broken by id for a stable order.
252 models.sort_by(|a, b| {
253 b.release_date
254 .cmp(&a.release_date)
255 .then_with(|| a.id.cmp(&b.id))
256 });
257 models
258 .into_iter()
259 .map(|m| ModelChoice {
260 value: m.id.clone(),
261 label: m.name.clone().unwrap_or_else(|| m.id.clone()),
262 })
263 .collect()
264 }
265
266 #[cfg(test)]
267 mod tests {
268 use super::*;
269
270 /// `AGENT_HARNESS_CACHE_DIR` is process-global, so these cannot run
271 /// beside each other.
272 static CACHE_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
273
274 fn with_cache_dir<T>(tag: &str, body: impl FnOnce(&Path) -> T) -> T {
275 let _guard = CACHE_ENV.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
276 let restore = std::env::var_os("AGENT_HARNESS_CACHE_DIR");
277 let dir = std::env::temp_dir().join(format!("hl-cache-{tag}-{}", std::process::id()));
278 let _ = std::fs::remove_dir_all(&dir);
279 std::env::set_var("AGENT_HARNESS_CACHE_DIR", &dir);
280
281 let out = body(&dir);
282
283 match restore {
284 Some(value) => std::env::set_var("AGENT_HARNESS_CACHE_DIR", value),
285 None => std::env::remove_var("AGENT_HARNESS_CACHE_DIR"),
286 }
287 let _ = std::fs::remove_dir_all(&dir);
288 out
289 }
290
291 const SAMPLE: &str = r#"{"anthropic":{"models":{"claude-x":{"id":"claude-x","name":"Claude X","tool_call":true,"limit":{"context":200000}}}}}"#;
292
293 #[test]
294 fn one_unreadable_model_costs_that_model_and_nothing_else() {
295 // models.dev is a ~4 MB file published by someone else, and serde
296 // fails a whole document on one bad field. Derived normally, a
297 // single model missing its `id` dropped every provider's list —
298 // and a refetch returns the same bytes, so "no models anywhere"
299 // persisted until upstream fixed it.
300 let mixed = r#"{
301 "anthropic":{"models":{"good":{"id":"good","tool_call":true}}},
302 "openai":{"models":{
303 "bad":{"tool_call":true},
304 "fine":{"id":"fine","tool_call":true}
305 }}
306 }"#;
307 let catalog: Catalog =
308 serde_json::from_str(mixed).expect("one bad model must not fail the document");
309
310 assert_eq!(select(&catalog, "anthropic").len(), 1, "an unrelated provider is untouched");
311 let openai = select(&catalog, "openai");
312 assert_eq!(openai.len(), 1, "the readable sibling survives");
313 assert_eq!(openai[0].value, "fine");
314 }
315
316 #[test]
317 fn a_failed_refresh_leaves_the_working_cache_alone() {
318 // This runs in the background on a launch that already loaded a
319 // catalog. Rewriting unconditionally would trade a working cache
320 // for whatever a network blip returned, and the next launch would
321 // start from nothing.
322 with_cache_dir("refresh", |dir| {
323 write_cache(SAMPLE);
324 refresh_from(|| None);
325 assert_eq!(
326 std::fs::read_to_string(dir.join("models_dev.json")).unwrap(),
327 SAMPLE,
328 "a failed refresh is a no-op",
329 );
330
331 refresh_from(|| Some("{}".to_owned()));
332 assert_eq!(
333 std::fs::read_to_string(dir.join("models_dev.json")).unwrap(),
334 "{}",
335 "and a successful one replaces it",
336 );
337 });
338 }
339
340 #[test]
341 fn a_cold_start_fetches_once_and_keeps_what_it_got() {
342 // The whole point of the disk cache: pay ~4 MB once, not per
343 // launch. If the fetched body were not persisted, every start would
344 // pay it again and nothing would fail.
345 with_cache_dir("cold", |dir| {
346 let catalog = load_or_fetch(|| Some(SAMPLE.to_owned()))
347 .expect("a cold start uses what it fetched");
348 assert_eq!(select(&catalog, "anthropic").len(), 1);
349 assert!(dir.join("models_dev.json").is_file(), "and writes it down");
350 });
351 }
352
353 #[test]
354 fn a_warm_start_does_not_reach_the_network_at_all() {
355 // Reading the cache is what makes an offline launch work. A cold
356 // path that ran anyway would still *look* right — it returns a
357 // catalog either way — so the assertion has to be that the fetch
358 // was never called.
359 with_cache_dir("warm", |_| {
360 write_cache(SAMPLE);
361 let catalog = load_or_fetch(|| panic!("the disk cache must be preferred"))
362 .expect("the cached catalog");
363 assert_eq!(select(&catalog, "anthropic").len(), 1);
364 });
365 }
366
367 #[test]
368 fn a_body_we_cannot_read_is_not_cached() {
369 // Writing first and parsing second would spend the disk on
370 // something the next launch has to discard, and turn one bad
371 // response into a file someone has to delete by hand.
372 with_cache_dir("garbage", |dir| {
373 assert!(load_or_fetch(|| Some("<html>not json</html>".to_owned())).is_none());
374 assert!(!dir.join("models_dev.json").exists(), "nothing was kept");
375 });
376 }
377
378 #[test]
379 fn an_unreachable_catalog_is_absent_rather_than_empty() {
380 // No cache and no network is "we do not know", which lets a caller
381 // fall back. An empty catalog would instead read as "this provider
382 // has no models" — a wrong answer rather than a missing one.
383 with_cache_dir("offline", |_| {
384 assert!(load_or_fetch(|| None).is_none());
385 });
386 }
387
388 #[test]
389 fn what_is_written_to_the_cache_is_what_comes_back() {
390 // The catalog is ~4 MB over the network. A cache that writes but
391 // cannot read itself back is silent and costs that on every launch,
392 // so the round trip is the property, not either half alone.
393 with_cache_dir("roundtrip", |dir| {
394 assert!(load_cached().is_none(), "nothing cached yet");
395
396 write_cache(SAMPLE);
397 assert!(dir.join("models_dev.json").is_file(), "the parent dir is created");
398
399 let loaded = load_cached().expect("what was just written must load");
400 let models = select(&loaded, "anthropic");
401 assert_eq!(models.len(), 1);
402 assert_eq!(models[0].value, "claude-x");
403 });
404 }
405
406 #[test]
407 fn a_damaged_cache_is_ignored_rather_than_believed() {
408 // A half-written file (a crash mid-write, a full disk) must send us
409 // back to the network, not surface as an empty model list — an
410 // empty catalog reads to the caller as "this provider has no
411 // models", which is a wrong answer rather than a missing one.
412 with_cache_dir("damaged", |_| {
413 write_cache(&SAMPLE[..SAMPLE.len() / 2]);
414 assert!(load_cached().is_none(), "a truncated cache is not a catalog");
415
416 write_cache("");
417 assert!(load_cached().is_none(), "nor is an empty one");
418 });
419 }
420
421 #[test]
422 fn a_stale_cache_on_disk_is_what_triggers_a_refresh() {
423 // `cache_is_stale` is the wiring between the rule and the configured
424 // directory; with it stuck on false the catalog is fetched once and
425 // never updated again, which is a model list that is wrong until
426 // someone deletes a file by hand.
427 with_cache_dir("stale", |dir| {
428 assert!(cache_is_stale(), "no cache yet, so fetching is how we find out");
429
430 write_cache(SAMPLE);
431 assert!(!cache_is_stale(), "just written");
432
433 let path = dir.join("models_dev.json");
434 let long_ago = std::time::SystemTime::now() - Duration::from_secs(25 * 60 * 60);
435 let file = std::fs::File::options().write(true).open(&path).unwrap();
436 file.set_times(std::fs::FileTimes::new().set_modified(long_ago)).unwrap();
437 assert!(cache_is_stale(), "a day-old cache is refreshed");
438 });
439 }
440
441 #[test]
442 fn a_host_that_named_no_cache_dir_writes_nothing_anywhere() {
443 // `cache_path` returning None means fetch-only. Writing to some
444 // default location instead would put a 4 MB file somewhere the host
445 // never agreed to.
446 let _guard = CACHE_ENV.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
447 let restore = std::env::var_os("AGENT_HARNESS_CACHE_DIR");
448 std::env::remove_var("AGENT_HARNESS_CACHE_DIR");
449
450 assert!(cache_path().is_none());
451 write_cache(SAMPLE); // must not panic, must not write
452 assert!(load_cached().is_none());
453 assert!(!cache_is_stale(), "nothing to refresh is not a stale cache");
454
455 if let Some(value) = restore {
456 std::env::set_var("AGENT_HARNESS_CACHE_DIR", value);
457 }
458 }
459
460 #[test]
461 fn a_cache_is_refetched_daily_and_a_missing_one_immediately() {
462 // The catalog is ~2 MB, so refreshing it on every launch is the
463 // thing this rule exists to prevent — but never refreshing means a
464 // model list that is wrong until someone clears a file by hand.
465 let dir = std::env::temp_dir().join(format!("hl-catalog-{}", std::process::id()));
466 std::fs::create_dir_all(&dir).unwrap();
467 let path = dir.join("models_dev.json");
468
469 assert!(stale(&path), "nothing cached yet, so fetching is how we find out");
470
471 std::fs::write(&path, "{}").unwrap();
472 assert!(!stale(&path), "just written is not a day old");
473
474 // Six hours is the case that pins the interval to a *day*. A
475 // just-written file and a 25-hour-old one read the same either side
476 // of almost any threshold, so on their own they say only that some
477 // rule exists: `24 * 60 * 60` could become `24 + 60 + 60` (144
478 // seconds) and both would still pass. Six hours rather than one
479 // because `24 + 60 * 60` is 3,624 seconds — just over an hour, and
480 // an hour-old file cannot tell that from a day either.
481 let earlier = std::time::SystemTime::now() - Duration::from_secs(6 * 60 * 60);
482 let file = std::fs::File::options().write(true).open(&path).unwrap();
483 file.set_times(std::fs::FileTimes::new().set_modified(earlier)).unwrap();
484 assert!(!stale(&path), "six hours is not a day");
485
486 // Backdate it past the threshold.
487 let file = std::fs::File::options().write(true).open(&path).unwrap();
488 let long_ago = std::time::SystemTime::now() - Duration::from_secs(25 * 60 * 60);
489 file.set_times(std::fs::FileTimes::new().set_modified(long_ago)).unwrap();
490 assert!(stale(&path), "a day-old catalog is refetched");
491
492 let _ = std::fs::remove_dir_all(&dir);
493 }
494
495 #[test]
496 fn no_cache_directory_means_nothing_to_refresh() {
497 // The library does not pick a cache location: a host names one or
498 // there is no disk cache at all. Inventing a path under $HOME is
499 // exactly what this crate stopped doing for instruction files.
500 assert!(cache_path().is_none() || std::env::var_os("AGENT_HARNESS_CACHE_DIR").is_some());
501 }
502
503 #[test]
504 fn select_keeps_only_tool_call_models_and_maps_name() {
505 let json = r#"{
506 "anthropic": { "models": {
507 "claude-x": { "id": "claude-x", "name": "Claude X", "tool_call": true },
508 "embed-x": { "id": "embed-x", "name": "Embed X", "tool_call": false }
509 }},
510 "openai": { "models": {
511 "o9": { "id": "o9", "tool_call": true }
512 }}
513 }"#;
514 let catalog: Catalog = serde_json::from_str(json).expect("parse catalog");
515
516 // anthropic: only the tool_call model survives; `name` → label.
517 let a = select(&catalog, "anthropic");
518 assert_eq!(a, vec![ModelChoice { value: "claude-x".into(), label: "Claude X".into() }]);
519
520 // openai: no `name` → label falls back to the id.
521 let o = select(&catalog, "openai");
522 assert_eq!(o, vec![ModelChoice { value: "o9".into(), label: "o9".into() }]);
523
524 // unknown provider → empty (caller falls back to its static list).
525 assert!(select(&catalog, "nope").is_empty());
526 }
527
528 #[test]
529 fn select_orders_newest_release_first() {
530 let json = r#"{
531 "anthropic": { "models": {
532 "old": { "id": "old", "tool_call": true, "release_date": "2023-03-01" },
533 "new": { "id": "new", "tool_call": true, "release_date": "2024-10-01" },
534 "mid": { "id": "mid", "tool_call": true, "release_date": "2024-02-01" },
535 "undated": { "id": "undated", "tool_call": true }
536 }}
537 }"#;
538 let catalog: Catalog = serde_json::from_str(json).expect("parse catalog");
539 let ids: Vec<String> =
540 select(&catalog, "anthropic").into_iter().map(|m| m.value).collect();
541 assert_eq!(ids, ["new", "mid", "old", "undated"], "newest first, undated last");
542 }
543
544 // A network smoke test against the real catalog — ignored by default so
545 // CI / offline runs never flake. Run with
546 // `cargo test -p agent-harness --features models-dev -- --ignored`.
547 #[test]
548 #[ignore = "network: fetches https://models.dev/api.json"]
549 fn live_catalog_has_anthropic_and_openai_models() {
550 assert!(!provider_models("anthropic").is_empty(), "anthropic should list models");
551 assert!(!provider_models("openai").is_empty(), "openai should list models");
552 assert!(provider_models("totally-unknown-xyz").is_empty());
553 }
554 }
555}
556
557#[cfg(all(test, feature = "models-dev"))]
558mod limit_tests {
559 #[test]
560 fn a_hosted_window_comes_from_the_catalog_or_is_absent() {
561 // Network- and cache-dependent, so this asserts the shape rather than a
562 // number: a known provider/model either yields a plausible window or
563 // nothing (offline, no cache), and an unknown one always yields nothing.
564 if let Some(window) = super::context_limit("openrouter", "openai/gpt-oss-120b") {
565 assert!(window >= 8_192, "a real model's window should be sane, got {window}");
566 }
567 assert_eq!(super::context_limit("openrouter", "no-such-model"), None);
568 assert_eq!(super::context_limit("no-such-provider", "openai/gpt-oss-120b"), None);
569 }
570}