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 [`HarnessModel`], and ordered newest first.
17//!
18//! [`Harness::list_models`]: crate::Harness::list_models
19
20use crate::HarnessModel;
21
22/// The agent-usable models a provider serves per models.dev, mapped to
23/// [`HarnessModel`] 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.
26pub 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    /// models.dev combined catalog: `{ <providerId>: { models: { <id>: Model } } }`.
52    #[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 passed to the CLI (`--model`).
64        id: String,
65        /// Human label; falls back to the id.
66        #[serde(default)]
67        name: Option<String>,
68        /// Supports tool calls — our proxy for "agent-usable" (text-only
69        /// embeddings / tts share the text modality but have `tool_call: false`).
70        #[serde(default)]
71        tool_call: bool,
72        /// ISO release date (`YYYY-MM-DD`) where models.dev has it — used to put
73        /// newer models first in the picker.
74        #[serde(default)]
75        release_date: Option<String>,
76    }
77
78    /// The catalog for the process. Prefers the on-disk cache — instant and
79    /// works offline — and refreshes it in the background; on a cold first run
80    /// with no cache it fetches once and persists it. A miss caches `None`, so
81    /// callers fall back without retrying every call.
82    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                    // The catalog changes slowly — refresh at most once a day.
88                    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    /// Where the catalog is cached, when the host app names a cache dir via
101    /// `AGENT_HARNESS_CACHE_DIR`; `None` → no disk cache (fetch-only).
102    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    /// Refetch and rewrite the disk cache so the next launch is current.
132    fn refresh_cache() {
133        if let Some(body) = fetch_remote() {
134            write_cache(&body);
135        }
136    }
137
138    /// Whether the cache file is at least a day old — the only time the
139    /// background refresh fires, so we re-fetch the ~2 MB catalog at most daily.
140    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    /// Pure filter+map (no network), so the selection logic is unit-testable.
156    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        // Newest first: models.dev `release_date` is ISO (`YYYY-MM-DD`), so a
162        // reverse string compare orders chronologically; undated models sort to
163        // the bottom, ties broken by id for a stable order.
164        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            // anthropic: only the tool_call model survives; `name` → label.
196            let a = select(&catalog, "anthropic");
197            assert_eq!(a, vec![HarnessModel { value: "claude-x".into(), label: "Claude X".into() }]);
198
199            // openai: no `name` → label falls back to the id.
200            let o = select(&catalog, "openai");
201            assert_eq!(o, vec![HarnessModel { value: "o9".into(), label: "o9".into() }]);
202
203            // unknown provider → empty (caller falls back to its static list).
204            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        // A network smoke test against the real catalog — ignored by default so
224        // CI / offline runs never flake. Run with
225        // `cargo test -p agent-harness --features models-dev -- --ignored`.
226        #[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}