feat: refresh the model catalog from live API data at runtime
This commit is contained in:
7 files changed
+481
-30
No files matched your search
@@ -102,6 +102,8 @@ pub struct AppConfig {
|
||||
pub no_workspace_mcp: bool,
|
||||
pub no_workspace_macros: bool,
|
||||
pub sync_models_url: Option<String>,
|
||||
pub sync_models_auto: bool,
|
||||
pub sync_models_ttl_hours: u64,
|
||||
|
||||
pub clients: Vec<ClientConfig>,
|
||||
}
|
||||
@@ -188,6 +190,8 @@ impl Default for AppConfig {
|
||||
no_workspace_mcp: false,
|
||||
no_workspace_macros: false,
|
||||
sync_models_url: None,
|
||||
sync_models_auto: true,
|
||||
sync_models_ttl_hours: 24,
|
||||
|
||||
clients: vec![],
|
||||
}
|
||||
@@ -276,6 +280,8 @@ impl AppConfig {
|
||||
no_workspace_mcp: false,
|
||||
no_workspace_macros: false,
|
||||
sync_models_url: config.sync_models_url,
|
||||
sync_models_auto: config.sync_models_auto,
|
||||
sync_models_ttl_hours: config.sync_models_ttl_hours,
|
||||
|
||||
clients: config.clients,
|
||||
};
|
||||
@@ -388,10 +394,8 @@ impl AppConfig {
|
||||
.ok_or_else(|| anyhow!("Editor not found. Please add the `editor` configuration or set the $EDITOR or $VISUAL environment variable."))
|
||||
}
|
||||
|
||||
pub fn sync_models_url(&self) -> String {
|
||||
self.sync_models_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| super::SYNC_MODELS_URL.into())
|
||||
pub fn sync_models_url(&self) -> Option<String> {
|
||||
self.sync_models_url.clone()
|
||||
}
|
||||
|
||||
pub fn light_theme(&self) -> bool {
|
||||
@@ -665,6 +669,13 @@ impl AppConfig {
|
||||
if let Some(v) = super::read_env_value::<String>(&get_env_name("sync_models_url")) {
|
||||
self.sync_models_url = v;
|
||||
}
|
||||
if let Some(Some(v)) = super::read_env_bool(&get_env_name("sync_models_auto")) {
|
||||
self.sync_models_auto = v;
|
||||
}
|
||||
if let Some(Some(v)) = super::read_env_value::<u64>(&get_env_name("sync_models_ttl_hours"))
|
||||
{
|
||||
self.sync_models_ttl_hours = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -951,10 +962,70 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_models_url_has_default() {
|
||||
fn sync_models_url_defaults_to_none() {
|
||||
let app = AppConfig::default();
|
||||
let url = app.sync_models_url();
|
||||
assert!(!url.is_empty());
|
||||
assert!(app.sync_models_url().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_models_auto_and_ttl_have_defaults() {
|
||||
let app = AppConfig::default();
|
||||
assert!(app.sync_models_auto);
|
||||
assert_eq!(app.sync_models_ttl_hours, 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn load_envs_overrides_sync_models_auto() {
|
||||
let env_name = get_env_name("sync_models_auto");
|
||||
let prev = env::var_os(&env_name);
|
||||
|
||||
let mut app = AppConfig::default();
|
||||
|
||||
unsafe { env::set_var(&env_name, "false") };
|
||||
app.load_envs();
|
||||
assert!(!app.sync_models_auto);
|
||||
|
||||
unsafe { env::set_var(&env_name, "true") };
|
||||
app.load_envs();
|
||||
assert!(app.sync_models_auto);
|
||||
|
||||
unsafe { env::remove_var(&env_name) };
|
||||
app.sync_models_auto = false;
|
||||
app.load_envs();
|
||||
assert!(!app.sync_models_auto);
|
||||
|
||||
unsafe {
|
||||
match prev {
|
||||
Some(v) => env::set_var(&env_name, v),
|
||||
None => env::remove_var(&env_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn load_envs_overrides_sync_models_ttl_hours() {
|
||||
let env_name = get_env_name("sync_models_ttl_hours");
|
||||
let prev = env::var_os(&env_name);
|
||||
|
||||
let mut app = AppConfig::default();
|
||||
|
||||
unsafe { env::set_var(&env_name, "6") };
|
||||
app.load_envs();
|
||||
assert_eq!(app.sync_models_ttl_hours, 6);
|
||||
|
||||
unsafe { env::remove_var(&env_name) };
|
||||
app.sync_models_ttl_hours = 48;
|
||||
app.load_envs();
|
||||
assert_eq!(app.sync_models_ttl_hours, 48);
|
||||
|
||||
unsafe {
|
||||
match prev {
|
||||
Some(v) => env::set_var(&env_name, v),
|
||||
None => env::remove_var(&env_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+248
-17
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user