use crate::HarnessModel;
pub fn provider_models(provider: &str) -> Vec<HarnessModel> {
#[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::PathBuf;
use std::sync::OnceLock;
use std::time::Duration;
use serde::Deserialize;
use crate::HarnessModel;
const API_URL: &str = "https://models.dev/api.json";
#[derive(Deserialize)]
struct Catalog(HashMap<String, Provider>);
#[derive(Deserialize)]
struct Provider {
#[serde(default)]
models: HashMap<String, Model>,
}
#[derive(Deserialize)]
struct Model {
id: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
tool_call: bool,
#[serde(default)]
release_date: Option<String>,
}
fn catalog() -> Option<&'static Catalog> {
static CACHE: OnceLock<Option<Catalog>> = OnceLock::new();
CACHE
.get_or_init(|| {
if let Some(cached) = load_cached() {
if cache_is_stale() {
std::thread::spawn(refresh_cache);
}
return Some(cached);
}
let body = fetch_remote()?;
write_cache(&body);
serde_json::from_str(&body).ok()
})
.as_ref()
}
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() {
if let Some(body) = fetch_remote() {
write_cache(&body);
}
}
fn cache_is_stale() -> bool {
const MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
let Some(path) = cache_path() else {
return false;
};
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 provider_models(provider: &str) -> Vec<HarnessModel> {
catalog().map(|c| select(c, provider)).unwrap_or_default()
}
fn select(catalog: &Catalog, provider: &str) -> Vec<HarnessModel> {
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| HarnessModel {
value: m.id.clone(),
label: m.name.clone().unwrap_or_else(|| m.id.clone()),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[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![HarnessModel { value: "claude-x".into(), label: "Claude X".into() }]);
let o = select(&catalog, "openai");
assert_eq!(o, vec![HarnessModel { 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());
}
}
}