use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};
use std::time::SystemTime;
use arc_swap::ArcSwapOption;
use serde::{Deserialize, Serialize};
use super::{ModelPricing, registry_from_catalog_str};
pub const DEFAULT_CATALOG_URL: &str =
"https://github.com/xberg-io/liter-llm/releases/download/model-catalog/catalog.json";
const CACHE_DIR_NAME: &str = "liter-llm";
const CACHE_FILE_NAME: &str = "catalog.json";
#[cfg(feature = "native-http")]
const FETCH_TIMEOUT_SECS: u64 = 30;
static OVERLAY: LazyLock<ArcSwapOption<HashMap<String, ModelPricing>>> = LazyLock::new(|| ArcSwapOption::from(None));
pub(crate) fn overlay_registry() -> Option<Arc<HashMap<String, ModelPricing>>> {
OVERLAY.load_full()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CatalogRefreshConfig {
pub enabled: bool,
pub source_url: String,
pub ttl_seconds: u64,
pub cache_path: Option<String>,
}
impl Default for CatalogRefreshConfig {
fn default() -> Self {
CatalogRefreshConfig {
enabled: false,
source_url: DEFAULT_CATALOG_URL.to_string(),
ttl_seconds: 86_400,
cache_path: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RefreshOutcome {
Disabled,
FromCache,
Fetched,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CatalogRefreshError {
#[error("catalog refresh is disabled")]
Disabled,
#[error("insecure catalog source URL {url:?}: only https is allowed")]
InsecureUrl {
url: String,
},
#[error("failed to fetch catalog from {url}: {message}")]
Fetch {
url: String,
message: String,
},
#[error("failed to parse catalog JSON: {message}")]
Parse {
message: String,
},
#[error("catalog cache I/O error at {path}: {message}")]
Cache {
path: String,
message: String,
},
}
pub fn install_catalog_overlay_from_str(catalog_json: &str) -> Result<(), CatalogRefreshError> {
let registry = registry_from_catalog_str(catalog_json).map_err(|message| CatalogRefreshError::Parse { message })?;
OVERLAY.store(Some(Arc::new(registry)));
Ok(())
}
pub fn clear_catalog_overlay() {
OVERLAY.store(None);
}
fn default_cache_path() -> PathBuf {
std::env::temp_dir().join(CACHE_DIR_NAME).join(CACHE_FILE_NAME)
}
fn cache_path_for(config: &CatalogRefreshConfig) -> PathBuf {
config
.cache_path
.as_ref()
.map_or_else(default_cache_path, PathBuf::from)
}
fn cache_is_fresh(path: &Path, ttl_seconds: u64) -> bool {
let Ok(metadata) = std::fs::metadata(path) else {
return false;
};
let Ok(modified) = metadata.modified() else {
return false;
};
let Ok(age) = SystemTime::now().duration_since(modified) else {
return false;
};
age.as_secs() < ttl_seconds
}
pub async fn refresh_catalog(config: &CatalogRefreshConfig) -> Result<RefreshOutcome, CatalogRefreshError> {
if !config.enabled {
return Ok(RefreshOutcome::Disabled);
}
let cache_path = cache_path_for(config);
if cache_is_fresh(&cache_path, config.ttl_seconds) {
return refresh_from_cache(&cache_path);
}
refresh_from_network(config, &cache_path).await
}
fn refresh_from_cache(cache_path: &Path) -> Result<RefreshOutcome, CatalogRefreshError> {
let raw = std::fs::read_to_string(cache_path).map_err(|source| CatalogRefreshError::Cache {
path: cache_path.display().to_string(),
message: source.to_string(),
})?;
let registry = registry_from_catalog_str(&raw).map_err(|message| CatalogRefreshError::Parse { message })?;
OVERLAY.store(Some(Arc::new(registry)));
Ok(RefreshOutcome::FromCache)
}
#[cfg(feature = "native-http")]
async fn refresh_from_network(
config: &CatalogRefreshConfig,
cache_path: &Path,
) -> Result<RefreshOutcome, CatalogRefreshError> {
let url = reqwest::Url::parse(&config.source_url).map_err(|_| CatalogRefreshError::InsecureUrl {
url: config.source_url.clone(),
})?;
if url.scheme() != "https" {
return Err(CatalogRefreshError::InsecureUrl {
url: config.source_url.clone(),
});
}
crate::ensure_crypto_provider();
let fetch_err = |message: String| CatalogRefreshError::Fetch {
url: config.source_url.clone(),
message,
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
.build()
.map_err(|e| fetch_err(e.to_string()))?;
let response = client.get(url).send().await.map_err(|e| fetch_err(e.to_string()))?;
let response = response.error_for_status().map_err(|e| fetch_err(e.to_string()))?;
let raw = response.text().await.map_err(|e| fetch_err(e.to_string()))?;
let registry = registry_from_catalog_str(&raw).map_err(|message| CatalogRefreshError::Parse { message })?;
if let Some(parent) = cache_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(cache_path, &raw);
OVERLAY.store(Some(Arc::new(registry)));
Ok(RefreshOutcome::Fetched)
}
#[cfg(not(feature = "native-http"))]
async fn refresh_from_network(
config: &CatalogRefreshConfig,
_cache_path: &Path,
) -> Result<RefreshOutcome, CatalogRefreshError> {
Err(CatalogRefreshError::Fetch {
url: config.source_url.clone(),
message: "network catalog refresh requires the `native-http` feature".to_string(),
})
}