feat: refresh the model catalog from live API data at runtime

This commit is contained in:
Dark-Alex-17 committed 2026-09-03 11:17:58 -06:00
1 parent 539b57aded
commit f1cb6589bc
7 files changed
+481 -30

No files matched your search

+248 -17
View File
@@ -69,8 +69,9 @@ pub use self::tool_scope::{
};
pub use self::update::run_self_update;
use crate::client::{
self, ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS,
ProviderModels, create_client_config, list_client_types, oauth, set_client_models_config,
self, ClientConfig, MODELS_YAML, MessageContentToolCalls, Model, ModelType,
OPENAI_COMPATIBLE_PROVIDERS, ProviderModels, catalog, create_client_config, list_client_types,
oauth, set_client_models_config,
};
use crate::function::{FunctionDeclaration, Functions};
use crate::rag::Rag;
@@ -99,6 +100,7 @@ use std::{
path::{Path, PathBuf},
process,
sync::{Arc, OnceLock},
time::SystemTime,
};
pub const TEMP_ROLE_NAME: &str = "temp";
@@ -187,9 +189,6 @@ const GITIGNORE_FILE_NAME: &str = ".gitignore";
const CLIENTS_FIELD: &str = "clients";
const SYNC_MODELS_URL: &str =
"https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml";
const SUMMARIZATION_PROMPT: &str =
"Summarize the discussion briefly in 200 words or less to use as a prompt for future context.";
const SUMMARY_CONTEXT_PROMPT: &str = "This is a summary of the chat history as a recap: ";
@@ -287,6 +286,8 @@ pub struct Config {
pub user_agent: Option<String>,
pub save_shell_history: bool,
pub sync_models_url: Option<String>,
pub sync_models_auto: bool,
pub sync_models_ttl_hours: u64,
pub clients: Vec<ClientConfig>,
}
@@ -370,6 +371,8 @@ impl Default for Config {
user_agent: None,
save_shell_history: true,
sync_models_url: None,
sync_models_auto: true,
sync_models_ttl_hours: 24,
clients: vec![],
}
@@ -513,26 +516,158 @@ pub fn list_sessions() -> Vec<String> {
list_file_names(default_sessions_dir(), ".yaml")
}
pub async fn sync_models(url: &str, abort_signal: AbortSignal) -> Result<()> {
let content = abortable_run_with_spinner(fetch(url), "Fetching models.yaml", abort_signal)
.await
.with_context(|| format!("Failed to fetch '{url}'"))?;
println!("✓ Fetched '{url}'");
let list = serde_yaml::from_str::<Vec<ProviderModels>>(&content)
.with_context(|| "Failed to parse models.yaml")?;
/// Refreshes the models override. With a URL, mirrors a full models.yaml from
/// it; without one, rebuilds the catalog from the live models.dev + OpenRouter
/// APIs merged with the embedded quirks.
pub async fn sync_models(url: Option<&str>, abort_signal: AbortSignal) -> Result<()> {
let list = match url {
Some(url) => {
let content =
abortable_run_with_spinner(fetch(url), "Fetching models.yaml", abort_signal)
.await
.with_context(|| format!("Failed to fetch '{url}'"))?;
println!("✓ Fetched '{url}'");
parse_mirrored_models(&content)?
}
None => {
let sources = abortable_run_with_spinner(
async { Ok(catalog::fetch_sources().await) },
"Fetching model data",
abort_signal,
)
.await?;
let (list, warnings) = build_refreshed_catalog(&sources)?;
for warning in &warnings {
println!("{} {warning}", warning_text("WARNING:"));
}
list
}
};
let model_override_path = write_models_override(list)?;
println!("✓ Updated '{}'", model_override_path.display());
Ok(())
}
/// [`sync_models`] without a spinner or terminal output, for background use;
/// failures are returned for the caller to log.
pub async fn sync_models_quiet(url: Option<&str>) -> Result<()> {
let list = match url {
Some(url) => {
let content = fetch(url)
.await
.with_context(|| format!("Failed to fetch '{url}'"))?;
parse_mirrored_models(&content)?
}
None => {
let sources = catalog::fetch_sources().await;
let (list, warnings) = build_refreshed_catalog(&sources)?;
for warning in warnings {
debug!("{warning}");
}
list
}
};
write_models_override(list)?;
Ok(())
}
fn parse_mirrored_models(content: &str) -> Result<Vec<ProviderModels>> {
serde_yaml::from_str::<Vec<ProviderModels>>(content)
.with_context(|| "Failed to parse models.yaml")
}
fn build_refreshed_catalog(
sources: &catalog::FetchedSources,
) -> Result<(Vec<ProviderModels>, Vec<String>)> {
if sources.modelsdev.is_none() && sources.openrouter.is_none() {
bail!("Failed to fetch model data from both models.dev and OpenRouter");
}
let mut warnings = Vec::new();
if sources.modelsdev.is_none() {
warnings.push("Failed to fetch models.dev; continuing with OpenRouter data only".into());
}
if sources.openrouter.is_none() {
warnings.push("Failed to fetch OpenRouter; continuing with models.dev data only".into());
}
let mut build = catalog::build_catalog(sources, &catalog::ALL_QUIRKS);
if !build.degraded.is_empty() {
warnings.push(format!(
"No live API data for: {}; keeping their embedded models",
build.degraded.join(", ")
));
}
if !build.failed.is_empty() {
warnings.push(format!(
"No models resolved for: {}; keeping their embedded models",
build.failed.join(", ")
));
}
let embedded = serde_yaml::from_str::<Vec<ProviderModels>>(MODELS_YAML)
.with_context(|| "Failed to parse the embedded models.yaml")?;
catalog::backfill_embedded_floor(&mut build, &embedded);
if build.providers.is_empty() {
bail!("Model sync produced no providers");
}
Ok((build.providers, warnings))
}
fn write_models_override(list: Vec<ProviderModels>) -> Result<PathBuf> {
let models_override = ModelsOverride {
version: env!("CARGO_PKG_VERSION").to_string(),
list,
};
let models_override_data =
serde_yaml::to_string(&models_override).with_context(|| "Failed to serde {}")?;
let models_override_data = serde_yaml::to_string(&models_override)
.with_context(|| "Failed to serialize the models override")?;
let model_override_path = paths::models_override_file();
ensure_parent_exists(&model_override_path)?;
std::fs::write(&model_override_path, models_override_data)
// Write through a sibling .tmp file and rename it over the target so an
// interrupted write can never leave a truncated override behind.
let mut tmp_name = model_override_path.as_os_str().to_os_string();
tmp_name.push(".tmp");
let tmp_path = PathBuf::from(tmp_name);
std::fs::write(&tmp_path, models_override_data)
.with_context(|| format!("Failed to write to '{}'", tmp_path.display()))?;
std::fs::rename(&tmp_path, &model_override_path)
.with_context(|| format!("Failed to write to '{}'", model_override_path.display()))?;
println!("✓ Updated '{}'", model_override_path.display());
Ok(())
Ok(model_override_path)
}
/// A missing, unreadable, corrupt, or version-mismatched override is always
/// stale; a loadable one is stale once its mtime is older than the TTL.
fn models_override_is_stale(loadable: bool, mtime: Option<SystemTime>, ttl_hours: u64) -> bool {
if !loadable {
return true;
}
let Some(mtime) = mtime else {
return true;
};
match SystemTime::now().duration_since(mtime) {
Ok(age) => age.as_secs() > ttl_hours.saturating_mul(3600),
Err(_) => false,
}
}
/// Fire-and-forget refresh of the models override on REPL startup. The
/// running session keeps its already-loaded catalog; the next start picks up
/// the refreshed file.
pub fn maybe_spawn_models_refresh(app: &AppConfig) {
if !app.sync_models_auto || app.dry_run {
return;
}
let loadable = paths::local_models_override().is_ok();
let mtime = std::fs::metadata(paths::models_override_file())
.and_then(|metadata| metadata.modified())
.ok();
if !models_override_is_stale(loadable, mtime, app.sync_models_ttl_hours) {
return;
}
let url = app.sync_models_url.clone();
tokio::spawn(async move {
if let Err(err) = sync_models_quiet(url.as_deref()).await {
debug!("Background models refresh failed: {err}");
}
});
}
impl Config {
@@ -1381,4 +1516,100 @@ clients:
"placeholder should be preserved as a literal string in sandbox mode"
);
}
#[test]
fn unloadable_override_is_stale() {
assert!(models_override_is_stale(false, Some(SystemTime::now()), 24));
assert!(models_override_is_stale(false, None, 24));
}
#[test]
fn unreadable_mtime_is_stale() {
assert!(models_override_is_stale(true, None, 24));
}
#[test]
fn fresh_loadable_override_is_not_stale() {
assert!(!models_override_is_stale(true, Some(SystemTime::now()), 24));
}
#[test]
fn ttl_boundary_controls_staleness() {
let ttl_hours = 24;
let ttl = std::time::Duration::from_secs(ttl_hours * 3600);
let margin = std::time::Duration::from_secs(300);
let just_fresh = SystemTime::now() - (ttl - margin);
let just_stale = SystemTime::now() - (ttl + margin);
assert!(!models_override_is_stale(true, Some(just_fresh), ttl_hours));
assert!(models_override_is_stale(true, Some(just_stale), ttl_hours));
}
#[test]
fn future_mtime_is_not_stale() {
let future = SystemTime::now() + std::time::Duration::from_secs(3600);
assert!(!models_override_is_stale(true, Some(future), 24));
}
#[test]
#[serial_test::serial]
fn models_override_loading_and_atomic_write() {
use std::fs;
let unique = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp_dir = std::env::temp_dir().join(format!("coyote-models-override-{unique}"));
fs::create_dir_all(&tmp_dir).unwrap();
let env_name = get_env_name("config_dir");
let prev = env::var_os(&env_name);
unsafe { env::set_var(&env_name, &tmp_dir) };
let result = std::panic::catch_unwind(|| {
let path = paths::models_override_file();
let entry = || ProviderModels {
provider: "openai".into(),
oauth: None,
models: vec![client::ModelData::new("gpt-test")],
};
// Missing override is unloadable.
assert!(paths::local_models_override().is_err());
// Corrupt override is unloadable.
fs::write(&path, "not: [valid").unwrap();
assert!(paths::local_models_override().is_err());
// Version-mismatched override is unloadable.
let mismatched = ModelsOverride {
version: "0.0.0".into(),
list: vec![entry()],
};
fs::write(&path, serde_yaml::to_string(&mismatched).unwrap()).unwrap();
assert!(paths::local_models_override().is_err());
// write_models_override goes through the sibling .tmp file and
// leaves a loadable override behind.
let written = write_models_override(vec![entry()]).unwrap();
assert_eq!(written, path);
let mut tmp_name = path.as_os_str().to_os_string();
tmp_name.push(".tmp");
assert!(!PathBuf::from(tmp_name).exists());
let loaded = paths::local_models_override().unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].provider, "openai");
});
unsafe {
match prev {
Some(v) => env::set_var(&env_name, v),
None => env::remove_var(&env_name),
}
}
let _ = fs::remove_dir_all(&tmp_dir);
if let Err(panic) = result {
std::panic::resume_unwind(panic);
}
}
}