use crate::ModelChoice;
pub fn context_limit(provider: &str, model: &str) -> Option<u64> {
#[cfg(feature = "models-dev")]
{
imp::context_limit(provider, model)
}
#[cfg(not(feature = "models-dev"))]
{
let _ = (provider, model);
None
}
}
pub fn provider_models(provider: &str) -> Vec<ModelChoice> {
#[cfg(feature = "models-dev")]
{
imp::provider_models(provider)
}
#[cfg(not(feature = "models-dev"))]
{
let _ = provider;
Vec::new()
}
}
#[cfg(feature = "models-dev")]
mod imp {
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::Duration;
use serde::Deserialize;
use crate::ModelChoice;
const API_URL: &str = "https://models.dev/api.json";
#[derive(Deserialize)]
struct Catalog(HashMap<String, Provider>);
#[derive(Deserialize)]
struct Provider {
#[serde(default, deserialize_with = "models_skipping_unreadable")]
models: HashMap<String, Model>,
}
fn models_skipping_unreadable<'de, D>(de: D) -> Result<HashMap<String, Model>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = HashMap::<String, serde_json::Value>::deserialize(de)?;
Ok(raw
.into_iter()
.filter_map(|(key, value)| Some((key, serde_json::from_value(value).ok()?)))
.collect())
}
#[derive(Deserialize)]
struct Limit {
#[serde(default)]
context: Option<u64>,
}
#[derive(Deserialize)]
struct Model {
id: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
tool_call: bool,
#[serde(default)]
release_date: Option<String>,
#[serde(default)]
limit: Option<Limit>,
}
fn catalog() -> Option<&'static Catalog> {
static CACHE: OnceLock<Option<Catalog>> = OnceLock::new();
CACHE.get_or_init(|| load_or_fetch(fetch_remote)).as_ref()
}
fn load_or_fetch(fetch: impl FnOnce() -> Option<String>) -> Option<Catalog> {
if let Some(cached) = load_cached() {
if cache_is_stale() {
std::thread::spawn(refresh_cache);
}
return Some(cached);
}
let body = fetch()?;
let parsed = serde_json::from_str(&body).ok()?;
write_cache(&body);
Some(parsed)
}
fn cache_path() -> Option<PathBuf> {
let dir = std::env::var_os("AGENT_HARNESS_CACHE_DIR")?;
Some(PathBuf::from(dir).join("models_dev.json"))
}
fn load_cached() -> Option<Catalog> {
let body = std::fs::read_to_string(cache_path()?).ok()?;
serde_json::from_str(&body).ok()
}
fn write_cache(body: &str) {
let Some(path) = cache_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, body);
}
fn fetch_remote() -> Option<String> {
ureq::get(API_URL)
.timeout(Duration::from_secs(8))
.call()
.ok()?
.into_string()
.ok()
}
fn refresh_cache() {
refresh_from(fetch_remote);
}
fn refresh_from(fetch: impl FnOnce() -> Option<String>) {
if let Some(body) = fetch() {
write_cache(&body);
}
}
fn cache_is_stale() -> bool {
cache_path().is_some_and(|path| stale(&path))
}
fn stale(path: &Path) -> bool {
const MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
match std::fs::metadata(path).and_then(|meta| meta.modified()) {
Ok(modified) => modified.elapsed().map(|age| age >= MAX_AGE).unwrap_or(true),
Err(_) => true,
}
}
pub fn context_limit(provider: &str, model: &str) -> Option<u64> {
catalog()?
.0
.get(provider)?
.models
.values()
.find(|entry| entry.id == model)?
.limit
.as_ref()?
.context
}
pub fn provider_models(provider: &str) -> Vec<ModelChoice> {
catalog().map(|c| select(c, provider)).unwrap_or_default()
}
fn select(catalog: &Catalog, provider: &str) -> Vec<ModelChoice> {
let Some(p) = catalog.0.get(provider) else {
return Vec::new();
};
let mut models: Vec<&Model> = p.models.values().filter(|m| m.tool_call).collect();
models.sort_by(|a, b| {
b.release_date
.cmp(&a.release_date)
.then_with(|| a.id.cmp(&b.id))
});
models
.into_iter()
.map(|m| ModelChoice {
value: m.id.clone(),
label: m.name.clone().unwrap_or_else(|| m.id.clone()),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
static CACHE_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_cache_dir<T>(tag: &str, body: impl FnOnce(&Path) -> T) -> T {
let _guard = CACHE_ENV.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let restore = std::env::var_os("AGENT_HARNESS_CACHE_DIR");
let dir = std::env::temp_dir().join(format!("hl-cache-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::env::set_var("AGENT_HARNESS_CACHE_DIR", &dir);
let out = body(&dir);
match restore {
Some(value) => std::env::set_var("AGENT_HARNESS_CACHE_DIR", value),
None => std::env::remove_var("AGENT_HARNESS_CACHE_DIR"),
}
let _ = std::fs::remove_dir_all(&dir);
out
}
const SAMPLE: &str = r#"{"anthropic":{"models":{"claude-x":{"id":"claude-x","name":"Claude X","tool_call":true,"limit":{"context":200000}}}}}"#;
#[test]
fn one_unreadable_model_costs_that_model_and_nothing_else() {
let mixed = r#"{
"anthropic":{"models":{"good":{"id":"good","tool_call":true}}},
"openai":{"models":{
"bad":{"tool_call":true},
"fine":{"id":"fine","tool_call":true}
}}
}"#;
let catalog: Catalog =
serde_json::from_str(mixed).expect("one bad model must not fail the document");
assert_eq!(select(&catalog, "anthropic").len(), 1, "an unrelated provider is untouched");
let openai = select(&catalog, "openai");
assert_eq!(openai.len(), 1, "the readable sibling survives");
assert_eq!(openai[0].value, "fine");
}
#[test]
fn a_failed_refresh_leaves_the_working_cache_alone() {
with_cache_dir("refresh", |dir| {
write_cache(SAMPLE);
refresh_from(|| None);
assert_eq!(
std::fs::read_to_string(dir.join("models_dev.json")).unwrap(),
SAMPLE,
"a failed refresh is a no-op",
);
refresh_from(|| Some("{}".to_owned()));
assert_eq!(
std::fs::read_to_string(dir.join("models_dev.json")).unwrap(),
"{}",
"and a successful one replaces it",
);
});
}
#[test]
fn a_cold_start_fetches_once_and_keeps_what_it_got() {
with_cache_dir("cold", |dir| {
let catalog = load_or_fetch(|| Some(SAMPLE.to_owned()))
.expect("a cold start uses what it fetched");
assert_eq!(select(&catalog, "anthropic").len(), 1);
assert!(dir.join("models_dev.json").is_file(), "and writes it down");
});
}
#[test]
fn a_warm_start_does_not_reach_the_network_at_all() {
with_cache_dir("warm", |_| {
write_cache(SAMPLE);
let catalog = load_or_fetch(|| panic!("the disk cache must be preferred"))
.expect("the cached catalog");
assert_eq!(select(&catalog, "anthropic").len(), 1);
});
}
#[test]
fn a_body_we_cannot_read_is_not_cached() {
with_cache_dir("garbage", |dir| {
assert!(load_or_fetch(|| Some("<html>not json</html>".to_owned())).is_none());
assert!(!dir.join("models_dev.json").exists(), "nothing was kept");
});
}
#[test]
fn an_unreachable_catalog_is_absent_rather_than_empty() {
with_cache_dir("offline", |_| {
assert!(load_or_fetch(|| None).is_none());
});
}
#[test]
fn what_is_written_to_the_cache_is_what_comes_back() {
with_cache_dir("roundtrip", |dir| {
assert!(load_cached().is_none(), "nothing cached yet");
write_cache(SAMPLE);
assert!(dir.join("models_dev.json").is_file(), "the parent dir is created");
let loaded = load_cached().expect("what was just written must load");
let models = select(&loaded, "anthropic");
assert_eq!(models.len(), 1);
assert_eq!(models[0].value, "claude-x");
});
}
#[test]
fn a_damaged_cache_is_ignored_rather_than_believed() {
with_cache_dir("damaged", |_| {
write_cache(&SAMPLE[..SAMPLE.len() / 2]);
assert!(load_cached().is_none(), "a truncated cache is not a catalog");
write_cache("");
assert!(load_cached().is_none(), "nor is an empty one");
});
}
#[test]
fn a_stale_cache_on_disk_is_what_triggers_a_refresh() {
with_cache_dir("stale", |dir| {
assert!(cache_is_stale(), "no cache yet, so fetching is how we find out");
write_cache(SAMPLE);
assert!(!cache_is_stale(), "just written");
let path = dir.join("models_dev.json");
let long_ago = std::time::SystemTime::now() - Duration::from_secs(25 * 60 * 60);
let file = std::fs::File::options().write(true).open(&path).unwrap();
file.set_times(std::fs::FileTimes::new().set_modified(long_ago)).unwrap();
assert!(cache_is_stale(), "a day-old cache is refreshed");
});
}
#[test]
fn a_host_that_named_no_cache_dir_writes_nothing_anywhere() {
let _guard = CACHE_ENV.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let restore = std::env::var_os("AGENT_HARNESS_CACHE_DIR");
std::env::remove_var("AGENT_HARNESS_CACHE_DIR");
assert!(cache_path().is_none());
write_cache(SAMPLE); assert!(load_cached().is_none());
assert!(!cache_is_stale(), "nothing to refresh is not a stale cache");
if let Some(value) = restore {
std::env::set_var("AGENT_HARNESS_CACHE_DIR", value);
}
}
#[test]
fn a_cache_is_refetched_daily_and_a_missing_one_immediately() {
let dir = std::env::temp_dir().join(format!("hl-catalog-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("models_dev.json");
assert!(stale(&path), "nothing cached yet, so fetching is how we find out");
std::fs::write(&path, "{}").unwrap();
assert!(!stale(&path), "just written is not a day old");
let earlier = std::time::SystemTime::now() - Duration::from_secs(6 * 60 * 60);
let file = std::fs::File::options().write(true).open(&path).unwrap();
file.set_times(std::fs::FileTimes::new().set_modified(earlier)).unwrap();
assert!(!stale(&path), "six hours is not a day");
let file = std::fs::File::options().write(true).open(&path).unwrap();
let long_ago = std::time::SystemTime::now() - Duration::from_secs(25 * 60 * 60);
file.set_times(std::fs::FileTimes::new().set_modified(long_ago)).unwrap();
assert!(stale(&path), "a day-old catalog is refetched");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn no_cache_directory_means_nothing_to_refresh() {
assert!(cache_path().is_none() || std::env::var_os("AGENT_HARNESS_CACHE_DIR").is_some());
}
#[test]
fn select_keeps_only_tool_call_models_and_maps_name() {
let json = r#"{
"anthropic": { "models": {
"claude-x": { "id": "claude-x", "name": "Claude X", "tool_call": true },
"embed-x": { "id": "embed-x", "name": "Embed X", "tool_call": false }
}},
"openai": { "models": {
"o9": { "id": "o9", "tool_call": true }
}}
}"#;
let catalog: Catalog = serde_json::from_str(json).expect("parse catalog");
let a = select(&catalog, "anthropic");
assert_eq!(a, vec![ModelChoice { value: "claude-x".into(), label: "Claude X".into() }]);
let o = select(&catalog, "openai");
assert_eq!(o, vec![ModelChoice { value: "o9".into(), label: "o9".into() }]);
assert!(select(&catalog, "nope").is_empty());
}
#[test]
fn select_orders_newest_release_first() {
let json = r#"{
"anthropic": { "models": {
"old": { "id": "old", "tool_call": true, "release_date": "2023-03-01" },
"new": { "id": "new", "tool_call": true, "release_date": "2024-10-01" },
"mid": { "id": "mid", "tool_call": true, "release_date": "2024-02-01" },
"undated": { "id": "undated", "tool_call": true }
}}
}"#;
let catalog: Catalog = serde_json::from_str(json).expect("parse catalog");
let ids: Vec<String> =
select(&catalog, "anthropic").into_iter().map(|m| m.value).collect();
assert_eq!(ids, ["new", "mid", "old", "undated"], "newest first, undated last");
}
#[test]
#[ignore = "network: fetches https://models.dev/api.json"]
fn live_catalog_has_anthropic_and_openai_models() {
assert!(!provider_models("anthropic").is_empty(), "anthropic should list models");
assert!(!provider_models("openai").is_empty(), "openai should list models");
assert!(provider_models("totally-unknown-xyz").is_empty());
}
}
}
#[cfg(all(test, feature = "models-dev"))]
mod limit_tests {
#[test]
fn a_hosted_window_comes_from_the_catalog_or_is_absent() {
if let Some(window) = super::context_limit("openrouter", "openai/gpt-oss-120b") {
assert!(window >= 8_192, "a real model's window should be sane, got {window}");
}
assert_eq!(super::context_limit("openrouter", "no-such-model"), None);
assert_eq!(super::context_limit("no-such-provider", "openai/gpt-oss-120b"), None);
}
}