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

This commit is contained in:
Dark-Alex-17 committed 2026-09-02 14:34:05 -06:00
1 parent 46c8561173
commit b19ac80783
7 files changed
+478 -27

No files matched your search

+6 -1
View File
@@ -244,8 +244,13 @@ right_prompt: null # If null, uses the built-in default:
# ---- Miscellaneous ---- # ---- Miscellaneous ----
user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version> user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version>
save_shell_history: true # Whether to save shell execution command to the history file 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 # 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 ---- # ---- Clients ----
# See the [Clients documentation](https://github.com/Dark-Alex-17/coyote/wiki/Clients) for more details # See the [Clients documentation](https://github.com/Dark-Alex-17/coyote/wiki/Clients) for more details
+1
View File
@@ -67,6 +67,7 @@ permissions:
- 'api.cohere.ai' - 'api.cohere.ai'
- 'api.groq.com' - 'api.groq.com'
- 'openrouter.ai' - 'openrouter.ai'
- 'models.dev'
- 'api.ai21.com' - 'api.ai21.com'
- 'api.cloudflare.com' - 'api.cloudflare.com'
- 'api.deepinfra.com' - 'api.deepinfra.com'
+7 -2
View File
@@ -26,8 +26,13 @@ light_theme: false # Activates a light color theme when true. env:
# ---- Miscellaneous ---- # ---- Miscellaneous ----
user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version> user_agent: null # Set User-Agent HTTP header, use `auto` for coyote/<current-version>
save_shell_history: true # Whether to save shell execution command to the history file save_shell_history: true # Whether to save shell execution command to the history file
sync_models_url: > # URL to sync model changes from sync_models_url: null # Where `--sync-models` and the automatic refresh get model data from.
https://raw.githubusercontent.com/Dark-Alex-17/coyote/refs/heads/main/models.yaml # 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 ---- # ---- 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 # Custom REPL left/right prompts; see the [REPL Prompt Documentation](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt) for more information
+137 -1
View File
@@ -8,7 +8,7 @@ use fancy_regex::Regex;
use log::warn; use log::warn;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::sync::LazyLock; use std::sync::LazyLock;
pub const QUIRKS_YAML: &str = include_str!("../../quirks.yaml"); pub const QUIRKS_YAML: &str = include_str!("../../quirks.yaml");
@@ -491,6 +491,43 @@ pub fn build_catalog_strict(
Ok(build.providers) 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\ 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"; # 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<ProviderModels> =
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<String> = 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 /// Guards against upstream id-shape drift by comparing live API model
/// names against the embedded models.yaml. Run manually: /// names against the embedded models.yaml. Run manually:
/// `cargo test idshape_guard_live -- --ignored --nocapture` /// `cargo test idshape_guard_live -- --ignored --nocapture`
+78 -7
View File
@@ -102,6 +102,8 @@ pub struct AppConfig {
pub no_workspace_mcp: bool, pub no_workspace_mcp: bool,
pub no_workspace_macros: bool, pub no_workspace_macros: bool,
pub sync_models_url: Option<String>, pub sync_models_url: Option<String>,
pub sync_models_auto: bool,
pub sync_models_ttl_hours: u64,
pub clients: Vec<ClientConfig>, pub clients: Vec<ClientConfig>,
} }
@@ -188,6 +190,8 @@ impl Default for AppConfig {
no_workspace_mcp: false, no_workspace_mcp: false,
no_workspace_macros: false, no_workspace_macros: false,
sync_models_url: None, sync_models_url: None,
sync_models_auto: true,
sync_models_ttl_hours: 24,
clients: vec![], clients: vec![],
} }
@@ -276,6 +280,8 @@ impl AppConfig {
no_workspace_mcp: false, no_workspace_mcp: false,
no_workspace_macros: false, no_workspace_macros: false,
sync_models_url: config.sync_models_url, 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, 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.")) .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 { pub fn sync_models_url(&self) -> Option<String> {
self.sync_models_url self.sync_models_url.clone()
.clone()
.unwrap_or_else(|| super::SYNC_MODELS_URL.into())
} }
pub fn light_theme(&self) -> bool { 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")) { if let Some(v) = super::read_env_value::<String>(&get_env_name("sync_models_url")) {
self.sync_models_url = v; 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] #[test]
fn sync_models_url_has_default() { fn sync_models_url_defaults_to_none() {
let app = AppConfig::default(); let app = AppConfig::default();
let url = app.sync_models_url(); assert!(app.sync_models_url().is_none());
assert!(!url.is_empty()); }
#[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] #[test]
+245 -14
View File
@@ -69,8 +69,9 @@ pub use self::tool_scope::{
}; };
pub use self::update::run_self_update; pub use self::update::run_self_update;
use crate::client::{ use crate::client::{
self, ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS, self, ClientConfig, MODELS_YAML, MessageContentToolCalls, Model, ModelType,
ProviderModels, create_client_config, list_client_types, oauth, set_client_models_config, OPENAI_COMPATIBLE_PROVIDERS, ProviderModels, catalog, create_client_config, list_client_types,
oauth, set_client_models_config,
}; };
use crate::function::{FunctionDeclaration, Functions}; use crate::function::{FunctionDeclaration, Functions};
use crate::rag::Rag; use crate::rag::Rag;
@@ -99,6 +100,7 @@ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
process, process,
sync::{Arc, OnceLock}, sync::{Arc, OnceLock},
time::SystemTime,
}; };
pub const TEMP_ROLE_NAME: &str = "temp"; pub const TEMP_ROLE_NAME: &str = "temp";
@@ -187,9 +189,6 @@ const GITIGNORE_FILE_NAME: &str = ".gitignore";
const CLIENTS_FIELD: &str = "clients"; 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 = const SUMMARIZATION_PROMPT: &str =
"Summarize the discussion briefly in 200 words or less to use as a prompt for future context."; "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: "; 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 user_agent: Option<String>,
pub save_shell_history: bool, pub save_shell_history: bool,
pub sync_models_url: Option<String>, pub sync_models_url: Option<String>,
pub sync_models_auto: bool,
pub sync_models_ttl_hours: u64,
pub clients: Vec<ClientConfig>, pub clients: Vec<ClientConfig>,
} }
@@ -370,6 +371,8 @@ impl Default for Config {
user_agent: None, user_agent: None,
save_shell_history: true, save_shell_history: true,
sync_models_url: None, sync_models_url: None,
sync_models_auto: true,
sync_models_ttl_hours: 24,
clients: vec![], clients: vec![],
} }
@@ -513,26 +516,158 @@ pub fn list_sessions() -> Vec<String> {
list_file_names(default_sessions_dir(), ".yaml") list_file_names(default_sessions_dir(), ".yaml")
} }
pub async fn sync_models(url: &str, abort_signal: AbortSignal) -> Result<()> { /// Refreshes the models override. With a URL, mirrors a full models.yaml from
let content = abortable_run_with_spinner(fetch(url), "Fetching models.yaml", abort_signal) /// 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 .await
.with_context(|| format!("Failed to fetch '{url}'"))?; .with_context(|| format!("Failed to fetch '{url}'"))?;
println!("✓ Fetched '{url}'"); println!("✓ Fetched '{url}'");
let list = serde_yaml::from_str::<Vec<ProviderModels>>(&content) parse_mirrored_models(&content)?
.with_context(|| "Failed to parse models.yaml")?; }
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 { let models_override = ModelsOverride {
version: env!("CARGO_PKG_VERSION").to_string(), version: env!("CARGO_PKG_VERSION").to_string(),
list, list,
}; };
let models_override_data = let models_override_data = serde_yaml::to_string(&models_override)
serde_yaml::to_string(&models_override).with_context(|| "Failed to serde {}")?; .with_context(|| "Failed to serialize the models override")?;
let model_override_path = paths::models_override_file(); let model_override_path = paths::models_override_file();
ensure_parent_exists(&model_override_path)?; 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()))?; .with_context(|| format!("Failed to write to '{}'", model_override_path.display()))?;
println!("✓ Updated '{}'", model_override_path.display()); Ok(model_override_path)
Ok(()) }
/// 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 { impl Config {
@@ -1381,4 +1516,100 @@ clients:
"placeholder should be preserved as a literal string in sandbox mode" "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);
}
}
} }
+4 -2
View File
@@ -26,7 +26,8 @@ use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
use crate::config::{ use crate::config::{
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope, Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope,
RenderMode, RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, 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::config::{memory, paths};
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail}; use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
@@ -293,7 +294,7 @@ async fn run(
) -> Result<()> { ) -> Result<()> {
if cli.sync_models { if cli.sync_models {
let url = ctx.app.config.sync_models_url(); 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 { if let Some(path) = &cli.generate_models {
@@ -549,6 +550,7 @@ async fn run(
if !*IS_STDOUT_TERMINAL { if !*IS_STDOUT_TERMINAL {
bail!("No TTY for REPL") bail!("No TTY for REPL")
} }
maybe_spawn_models_refresh(&ctx.app.config);
start_interactive(ctx).await start_interactive(ctx).await
} }
} }