feat: refresh the model catalog from live API data at runtime
This commit is contained in:
7 files changed
+478
-27
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user