From b19ac807834b566c9d9d746b17e0c9318f6a3ecd Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 2 Sep 2026 14:34:05 -0600 Subject: [PATCH] feat: refresh the model catalog from live API data at runtime --- assets/config-template.yaml | 7 +- assets/sbx-kit/spec.yaml | 1 + config.example.yaml | 9 +- src/client/catalog.rs | 138 ++++++++++++++++++- src/config/app_config.rs | 85 +++++++++++- src/config/mod.rs | 265 +++++++++++++++++++++++++++++++++--- src/main.rs | 6 +- 7 files changed, 481 insertions(+), 30 deletions(-) diff --git a/assets/config-template.yaml b/assets/config-template.yaml index 02ca30f..cd18c10 100644 --- a/assets/config-template.yaml +++ b/assets/config-template.yaml @@ -244,8 +244,13 @@ right_prompt: null # If null, uses the built-in default: # ---- Miscellaneous ---- user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/ save_shell_history: true # Whether to save shell execution command to the history file -sync_models_url: null # URL to sync model changes from. If null, uses the built-in default: +sync_models_url: null # Where `--sync-models` and the automatic refresh get model data from. + # If null, fetches fresh model data from models.dev + OpenRouter. + # Set a URL to mirror a full models.yaml instead, e.g.: # https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml +sync_models_auto: true # Whether to refresh the model catalog in the background on REPL startup + # once it goes stale +sync_models_ttl_hours: 24 # Hours before the synced model catalog is considered stale # ---- Clients ---- # See the [Clients documentation](https://github.com/Dark-Alex-17/coyote/wiki/Clients) for more details diff --git a/assets/sbx-kit/spec.yaml b/assets/sbx-kit/spec.yaml index 5c972d3..4b17b70 100644 --- a/assets/sbx-kit/spec.yaml +++ b/assets/sbx-kit/spec.yaml @@ -67,6 +67,7 @@ permissions: - 'api.cohere.ai' - 'api.groq.com' - 'openrouter.ai' + - 'models.dev' - 'api.ai21.com' - 'api.cloudflare.com' - 'api.deepinfra.com' diff --git a/config.example.yaml b/config.example.yaml index 2b2a8eb..ebb5d8c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -26,8 +26,13 @@ light_theme: false # Activates a light color theme when true. env: # ---- Miscellaneous ---- user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/ save_shell_history: true # Whether to save shell execution command to the history file -sync_models_url: > # URL to sync model changes from - https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml +sync_models_url: null # Where `--sync-models` and the automatic refresh get model data from. + # If null, fetches fresh model data from models.dev + OpenRouter. + # Set a URL to mirror a full models.yaml instead, e.g.: + # https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml +sync_models_auto: true # Whether to refresh the model catalog in the background on REPL startup + # once it goes stale +sync_models_ttl_hours: 24 # Hours before the synced model catalog is considered stale # ---- REPL Prompt ---- # Custom REPL left/right prompts; see the [REPL Prompt Documentation](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt) for more information diff --git a/src/client/catalog.rs b/src/client/catalog.rs index cec2d46..e1b3aa5 100644 --- a/src/client/catalog.rs +++ b/src/client/catalog.rs @@ -8,7 +8,7 @@ use fancy_regex::Regex; use log::warn; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; pub const QUIRKS_YAML: &str = include_str!("../../quirks.yaml"); @@ -491,6 +491,43 @@ pub fn build_catalog_strict( Ok(build.providers) } +/// Replaces or inserts the entry of every degraded/failed provider from the +/// embedded catalog, preserving the fixed provider order of +/// [`PROVIDER_SOURCES`]. A synced override must never carry fewer models for a +/// provider than the embedded catalog: an upstream outage would otherwise +/// shrink the model list until the next successful sync. +pub fn backfill_embedded_floor(build: &mut CatalogBuild, embedded: &[ProviderModels]) { + let needs_backfill: HashSet<&str> = build + .degraded + .iter() + .chain(build.failed.iter()) + .map(String::as_str) + .collect(); + if needs_backfill.is_empty() { + return; + } + let mut providers = Vec::with_capacity(PROVIDER_SOURCES.len()); + for source in &PROVIDER_SOURCES { + let built = build + .providers + .iter() + .position(|provider| provider.provider == source.provider) + .map(|position| build.providers.remove(position)); + if needs_backfill.contains(source.provider) + && let Some(entry) = embedded + .iter() + .find(|provider| provider.provider == source.provider) + { + providers.push(entry.clone()); + continue; + } + if let Some(built) = built { + providers.push(built); + } + } + build.providers = providers; +} + const GENERATED_HEADER: &str = "# GENERATED by 'coyote --generate-models' — DO NOT hand-edit model data.\n\ # Hand-owned behavior lives in quirks.yaml; this file is the embedded offline fallback.\n"; @@ -1473,6 +1510,105 @@ rules: } } + fn provider_entry(provider: &str, model_names: &[&str]) -> ProviderModels { + ProviderModels { + provider: provider.to_string(), + oauth: None, + models: model_names + .iter() + .map(|name| ModelData::new(name)) + .collect(), + } + } + + #[test] + fn backfill_replaces_degraded_and_inserts_failed_providers() { + let mut build = CatalogBuild { + providers: vec![ + provider_entry("openai", &["gpt-fresh"]), + provider_entry("gemini", &["quirks-only"]), + ], + degraded: vec!["gemini".to_string()], + failed: vec!["claude".to_string()], + }; + let embedded = vec![ + provider_entry("openai", &["gpt-old-1", "gpt-old-2"]), + provider_entry("gemini", &["gem-1", "gem-2", "gem-3"]), + provider_entry("claude", &["claude-1"]), + ]; + + backfill_embedded_floor(&mut build, &embedded); + + let order: Vec<&str> = build + .providers + .iter() + .map(|provider| provider.provider.as_str()) + .collect(); + assert_eq!(order, ["openai", "gemini", "claude"]); + assert_eq!(find_provider(&build, "openai").models.len(), 1); + assert_eq!(find_provider(&build, "gemini").models.len(), 3); + assert_eq!(find_provider(&build, "claude").models.len(), 1); + assert_eq!( + find_provider(&build, "openai").models[0].name, + "gpt-fresh", + "providers with live API data must not be touched" + ); + } + + #[test] + fn backfill_keeps_built_entry_when_embedded_lacks_the_provider() { + let mut build = CatalogBuild { + providers: vec![provider_entry("gemini", &["quirks-only"])], + degraded: vec!["gemini".to_string()], + failed: vec![], + }; + + backfill_embedded_floor(&mut build, &[]); + + assert_eq!(find_provider(&build, "gemini").models.len(), 1); + } + + #[test] + fn backfill_without_degraded_or_failed_is_a_no_op() { + let mut build = CatalogBuild { + providers: vec![provider_entry("openai", &["gpt-fresh"])], + degraded: vec![], + failed: vec![], + }; + + backfill_embedded_floor(&mut build, &[provider_entry("openai", &["a", "b"])]); + + assert_eq!(find_provider(&build, "openai").models.len(), 1); + } + + #[test] + fn backfill_restores_embedded_floor_for_unresolved_providers() { + let embedded: Vec = + serde_yaml::from_str(crate::client::MODELS_YAML).unwrap(); + let mut build = build_catalog(&make_sources(Some(MODELSDEV_FIXTURE), None), &ALL_QUIRKS); + let unresolved: Vec = build + .degraded + .iter() + .chain(build.failed.iter()) + .cloned() + .collect(); + assert!(!unresolved.is_empty()); + + backfill_embedded_floor(&mut build, &embedded); + + for provider in unresolved { + let built = find_provider(&build, &provider); + let floor = embedded + .iter() + .find(|embedded| embedded.provider == provider) + .unwrap(); + assert!( + built.models.len() >= floor.models.len(), + "provider '{provider}' shrank below the embedded floor" + ); + } + } + /// Guards against upstream id-shape drift by comparing live API model /// names against the embedded models.yaml. Run manually: /// `cargo test idshape_guard_live -- --ignored --nocapture` diff --git a/src/config/app_config.rs b/src/config/app_config.rs index 6235c1d..4cf4ef4 100644 --- a/src/config/app_config.rs +++ b/src/config/app_config.rs @@ -102,6 +102,8 @@ pub struct AppConfig { pub no_workspace_mcp: bool, pub no_workspace_macros: bool, pub sync_models_url: Option, + pub sync_models_auto: bool, + pub sync_models_ttl_hours: u64, pub clients: Vec, } @@ -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 { + 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::(&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::(&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] diff --git a/src/config/mod.rs b/src/config/mod.rs index 0649765..372c59b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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, pub save_shell_history: bool, pub sync_models_url: Option, + pub sync_models_auto: bool, + pub sync_models_ttl_hours: u64, pub clients: Vec, } @@ -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 { 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::>(&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> { + serde_yaml::from_str::>(content) + .with_context(|| "Failed to parse models.yaml") +} + +fn build_refreshed_catalog( + sources: &catalog::FetchedSources, +) -> Result<(Vec, Vec)> { + 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::>(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) -> Result { 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, 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); + } + } } diff --git a/src/main.rs b/src/main.rs index 605c689..7181aa3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,8 @@ use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME; use crate::config::{ Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope, RenderMode, RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, - install_builtins, list_agents, load_env_file, macro_execute, sync_models, + install_builtins, list_agents, load_env_file, macro_execute, maybe_spawn_models_refresh, + sync_models, }; use crate::config::{memory, paths}; use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail}; @@ -293,7 +294,7 @@ async fn run( ) -> Result<()> { if cli.sync_models { let url = ctx.app.config.sync_models_url(); - return sync_models(&url, abort_signal.clone()).await; + return sync_models(url.as_deref(), abort_signal.clone()).await; } if let Some(path) = &cli.generate_models { @@ -549,6 +550,7 @@ async fn run( if !*IS_STDOUT_TERMINAL { bail!("No TTY for REPL") } + maybe_spawn_models_refresh(&ctx.app.config); start_interactive(ctx).await } }