Skip to main content

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}