use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::process::Command;
use std::time::Duration;
const FEED_URL: &str = "https://llm-prices.agent-tools.org/v1/prices.json";
const CACHE_FILE: &str = "prices.json";
const CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const MAX_SERVER_AGE: i64 = 24 * 60 * 60;
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct FeedModel {
pub id: String,
#[serde(default)]
pub aliases: Vec<String>,
pub input_per_mtok: f64,
pub output_per_mtok: f64,
pub cached_input_per_mtok: Option<f64>,
pub context_length: Option<i64>,
pub source: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Feed {
pub built_at: String,
pub age_seconds: Option<i64>,
pub stale: Option<bool>,
pub count: Option<i64>,
pub models: Vec<FeedModel>,
}
impl Feed {
pub fn index(&self) -> HashMap<String, usize> {
let mut index = HashMap::with_capacity(self.models.len() * 2);
for (i, model) in self.models.iter().enumerate() {
index.insert(model.id.clone(), i);
for alias in &model.aliases {
index.insert(alias.clone(), i);
}
}
index
}
pub fn usable(&self) -> bool {
if self.stale.unwrap_or(true) {
return false;
}
self.age_seconds.unwrap_or(i64::MAX) <= MAX_SERVER_AGE
}
}
pub fn feed_lookup<'a>(feed: &'a Feed, index: &HashMap<String, usize>, model: &str) -> Option<&'a FeedModel> {
if let Some(&i) = index.get(model) {
return feed.models.get(i);
}
let basename = model.rsplit('/').next().unwrap_or(model);
if basename != model
&& let Some(&i) = index.get(basename)
{
return feed.models.get(i);
}
None
}
fn cache_path() -> std::path::PathBuf {
crate::paths::aid_dir().join(CACHE_FILE)
}
pub fn load_cache() -> Option<Feed> {
let bytes = fs::read(cache_path()).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn cache_fresh(feed: &Feed) -> bool {
let Ok(built) = DateTime::parse_from_rfc3339(&feed.built_at) else {
return false;
};
Utc::now().signed_duration_since(built.with_timezone(&Utc)) < chrono::Duration::from_std(CACHE_TTL).unwrap_or_default()
}
pub fn maybe_refresh() {
if load_cache().is_some_and(|feed| cache_fresh(&feed)) {
return;
}
let _ = std::thread::Builder::new()
.name("aid-price-feed".into())
.spawn(|| {
let Ok(output) = Command::new("curl").args(["-sfL", "--max-time", "15", FEED_URL]).output() else {
eprintln!("[price-feed] curl spawn failed");
return;
};
if !output.status.success() {
eprintln!("[price-feed] curl status {}", output.status);
return;
}
let Ok(body) = String::from_utf8(output.stdout) else {
eprintln!("[price-feed] utf8 failed");
return;
};
let Ok(feed) = serde_json::from_str::<Feed>(&body) else {
eprintln!("[price-feed] json parse failed, len {}", body.len());
return;
};
if !feed.usable() {
eprintln!("[price-feed] not usable: stale={:?} age={:?}", feed.stale, feed.age_seconds);
return;
}
let Ok(()) = std::fs::create_dir_all(crate::paths::aid_dir()) else {
eprintln!("[price-feed] create_dir_all failed");
return;
};
let Ok(encoded) = serde_json::to_vec(&feed) else {
eprintln!("[price-feed] serialize failed");
return;
};
let write_res = fs::write(cache_path(), encoded);
eprintln!("[price-feed] write result: {:?}", write_res);
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::AidHomeGuard;
fn sample_feed() -> Feed {
Feed {
built_at: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
age_seconds: Some(60),
stale: Some(false),
count: Some(2),
models: vec![
FeedModel {
id: "openai/gpt-5.3-codex".to_string(),
aliases: vec!["gpt-5.3-codex-high".to_string()],
input_per_mtok: 1.75,
output_per_mtok: 14.0,
cached_input_per_mtok: None,
context_length: None,
source: Some("openrouter".to_string()),
},
FeedModel {
id: "gpt-5.6-sol".to_string(),
aliases: vec![],
input_per_mtok: 5.0,
output_per_mtok: 30.0,
cached_input_per_mtok: None,
context_length: None,
source: Some("openrouter".to_string()),
},
],
}
}
#[test]
fn lookup_exact_id_and_alias() {
let feed = sample_feed();
let index = feed.index();
assert_eq!(feed_lookup(&feed, &index, "openai/gpt-5.3-codex").unwrap().id, "openai/gpt-5.3-codex");
assert_eq!(feed_lookup(&feed, &index, "gpt-5.3-codex-high").unwrap().id, "openai/gpt-5.3-codex");
assert_eq!(feed_lookup(&feed, &index, "gpt-5.6-sol").unwrap().id, "gpt-5.6-sol");
}
#[test]
fn lookup_strips_vendor_prefix() {
let feed = sample_feed();
let index = feed.index();
assert_eq!(feed_lookup(&feed, &index, "mimo/gpt-5.6-sol").unwrap().id, "gpt-5.6-sol");
}
#[test]
fn lookup_miss_is_none() {
let feed = sample_feed();
let index = feed.index();
assert!(feed_lookup(&feed, &index, "nobody/has-this-model").is_none());
assert!(feed_lookup(&feed, &index, "gpt-5.3-codex-high-extra").is_none());
}
#[test]
fn stale_feed_is_not_usable() {
let mut feed = sample_feed();
feed.stale = Some(true);
assert!(!feed.usable());
feed.stale = Some(false);
feed.age_seconds = Some(MAX_SERVER_AGE + 1);
assert!(!feed.usable());
}
#[test]
fn cache_round_trip_under_aid_home() {
let temp = tempfile::tempdir().unwrap();
let _guard = AidHomeGuard::set(temp.path());
std::fs::create_dir_all(crate::paths::aid_dir()).unwrap();
let feed = sample_feed();
fs::write(cache_path(), serde_json::to_vec(&feed).unwrap()).unwrap();
assert_eq!(load_cache().unwrap(), feed);
assert!(cache_fresh(&feed));
}
#[test]
fn missing_cache_loads_none() {
let temp = tempfile::tempdir().unwrap();
let _guard = AidHomeGuard::set(temp.path());
assert!(load_cache().is_none());
}
#[test]
#[ignore = "live network probe"]
fn live_refresh_writes_cache() {
let temp = tempfile::tempdir().unwrap();
let _guard = AidHomeGuard::set(temp.path());
maybe_refresh();
std::thread::sleep(std::time::Duration::from_secs(20));
let feed = load_cache().expect("cache should be written after live refresh");
assert!(feed.usable());
}
}