From dc41e9d5df98e569f8c9ba8b85181b07dc677b5b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 3 Sep 2026 12:40:08 -0600 Subject: [PATCH] style: Cleaned up comments and organization of quirks.yaml file --- quirks.yaml | 34 +++++++-------- src/client/catalog.rs | 97 +++++++++++++------------------------------ src/config/mod.rs | 35 ++++++++-------- 3 files changed, 63 insertions(+), 103 deletions(-) diff --git a/quirks.yaml b/quirks.yaml index 75e68c0..3db303c 100644 --- a/quirks.yaml +++ b/quirks.yaml @@ -4,9 +4,9 @@ # model data: oauth blocks, request patches, wire-name aliases, behavioral # flags, and models that no public catalog provides. # -# `models:` entries are self-complete — `rules` apply ONLY to API-sourced +# `models:` entries are self-complete. `rules` apply ONLY to API-sourced # models, never to hand-owned `models:` entries. Legacy verbatim-preserved -# entries are grouped under a `# legacy — pruning candidates` comment. +# entries are grouped under a `# legacy -- pruning candidates` comment. - provider: openai exclude: @@ -78,7 +78,7 @@ max_tokens_per_chunk: 8191 default_chunk_size: 2000 max_batch_size: 100 - # legacy — pruning candidates + # legacy -- pruning candidates - name: o4-mini-high max_output_tokens: 100000 real_name: o4-mini @@ -179,7 +179,7 @@ max_tokens_per_chunk: 2048 default_chunk_size: 1500 max_batch_size: 100 - # legacy — pruning candidates + # legacy -- pruning candidates - name: gemini-3-pro-preview max_input_tokens: 1048576 supports_vision: true @@ -296,7 +296,7 @@ type: enabled budget_tokens: 16000 models: - # legacy — pruning candidates + # legacy -- pruning candidates - name: claude-opus-4-1-20250805 max_input_tokens: 200000 max_output_tokens: 8192 @@ -324,7 +324,7 @@ input_price: 0.1 max_tokens_per_chunk: 8092 default_chunk_size: 2000 - # legacy — pruning candidates + # legacy -- pruning candidates - name: magistral-small-latest max_input_tokens: 131072 input_price: 0.5 @@ -451,7 +451,7 @@ - provider: perplexity models: - # legacy — pruning candidates + # legacy -- pruning candidates - name: sonar-reasoning max_input_tokens: 128000 input_price: 1 @@ -461,7 +461,7 @@ exclude: - "whisper-*" models: - # legacy — pruning candidates + # legacy -- pruning candidates - name: meta-llama/llama-4-maverick-17b-128e-instruct max_input_tokens: 131072 input_price: 0 @@ -600,7 +600,7 @@ max_tokens_per_chunk: 2048 default_chunk_size: 1500 max_batch_size: 5 - # legacy — pruning candidates + # legacy -- pruning candidates - name: gemini-3-pro-preview max_input_tokens: 1048576 supports_vision: true @@ -813,7 +813,7 @@ max_tokens_per_chunk: 512 default_chunk_size: 1000 max_batch_size: 96 - # legacy — pruning candidates + # legacy -- pruning candidates - name: us.meta.llama3-3-70b-instruct-v1:0 max_input_tokens: 131072 max_output_tokens: 8192 @@ -855,7 +855,7 @@ max_tokens_per_chunk: 512 default_chunk_size: 1000 max_batch_size: 100 - # legacy — pruning candidates + # legacy -- pruning candidates - name: '@cf/google/gemma-3-12b-it' max_input_tokens: 131072 max_output_tokens: 2048 @@ -939,7 +939,7 @@ max_tokens_per_chunk: 8192 default_chunk_size: 2000 max_batch_size: 10 - # legacy — pruning candidates + # legacy -- pruning candidates - name: qwen3-vl-flash max_input_tokens: 262144 supports_vision: true @@ -1000,7 +1000,7 @@ - provider: deepseek models: - # legacy — pruning candidates + # legacy -- pruning candidates - name: deepseek-chat max_input_tokens: 64000 max_output_tokens: 8192 @@ -1040,7 +1040,7 @@ type: reranker max_input_tokens: 4096 input_price: 0.112 - # legacy — pruning candidates + # legacy -- pruning candidates - name: glm-4.6v-flash max_input_tokens: 65536 input_price: 0 @@ -1049,7 +1049,7 @@ - provider: minimax models: - # legacy — pruning candidates + # legacy -- pruning candidates - name: minimax-m3 max_input_tokens: 1000000 input_price: 4.2 @@ -1166,7 +1166,7 @@ body: include_reasoning: true models: - # legacy — pruning candidates + # legacy -- pruning candidates - name: google/gemini-3-pro-preview max_input_tokens: 1048576 supports_vision: true @@ -1352,7 +1352,7 @@ max_tokens_per_chunk: 512 default_chunk_size: 1000 max_batch_size: 100 - # legacy — pruning candidates + # legacy -- pruning candidates - name: Qwen/Qwen3-Max-Thinking max_input_tokens: 262144 input_price: 1.2 diff --git a/src/client/catalog.rs b/src/client/catalog.rs index 9742849..b6b8456 100644 --- a/src/client/catalog.rs +++ b/src/client/catalog.rs @@ -1,5 +1,6 @@ use super::model::{ModelData, ProviderModels}; use super::oauth::OAuthConfig; +use std::cmp::Ordering; use crate::utils::fetch; @@ -9,6 +10,8 @@ use log::warn; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::Path; use std::sync::{Arc, LazyLock, Mutex}; pub const QUIRKS_YAML: &str = include_str!("../../quirks.yaml"); @@ -19,10 +22,6 @@ pub static ALL_QUIRKS: LazyLock> = const MODELSDEV_API_URL: &str = "https://models.dev/api.json"; const OPENROUTER_API_URL: &str = "https://openrouter.ai/api/v1/models"; -/// Upstream ids for one coyote provider. The table order is the fixed output -/// order of the built catalog. Providers with neither upstream id are fully -/// quirks-owned; `openrouter` is special-cased in the merge: its models come -/// from the OpenRouter list natively instead of via a prefix join. struct SourceIds { provider: &'static str, modelsdev: Option<&'static str>, @@ -152,15 +151,11 @@ const PROVIDER_SOURCES: [SourceIds; 24] = [ }, ]; -/// Raw payloads from the two upstream catalogs. Each source is independently -/// fallible; `None` means that fetch failed and the merge degrades gracefully. pub struct FetchedSources { pub modelsdev: Option, pub openrouter: Option, } -/// Fetches both upstream catalogs concurrently. A failed fetch is logged and -/// surfaces as `None` rather than an error. pub async fn fetch_sources() -> FetchedSources { let (modelsdev, openrouter) = tokio::join!(fetch(MODELSDEV_API_URL), fetch(OPENROUTER_API_URL)); FetchedSources { @@ -173,8 +168,6 @@ pub async fn fetch_sources() -> FetchedSources { } } -/// An API-sourced model plus the release date used for ordering. The date is -/// deliberately kept out of [`ModelData`] and never serialized. struct SourcedModel { data: ModelData, release_date: Option, @@ -192,9 +185,6 @@ fn clamp_price(value: f64) -> Option { (0.0..=10_000.0).contains(&value).then_some(value) } -/// Extracts the models of one models.dev provider. Tolerant by design: -/// missing or malformed fields are skipped while the model is kept, and -/// out-of-range numbers are dropped field-wise. fn modelsdev_provider_models(api: &Value, provider_id: &str) -> Vec { let Some(models) = api[provider_id]["models"].as_object() else { return Vec::new(); @@ -236,15 +226,11 @@ fn modelsdev_provider_models(api: &Value, provider_id: &str) -> Vec Option { let per_token: f64 = value.as_str()?.parse().ok()?; clamp_price((per_token * 1_000_000.0 * 10_000.0).round() / 10_000.0) } -/// Maps one OpenRouter list entry onto [`ModelData`], with the same field-wise -/// tolerance and clamping as the models.dev adapter. fn openrouter_model_data(name: &str, entry: &Value) -> ModelData { let mut data = ModelData::new(name); data.max_input_tokens = entry["context_length"] @@ -276,9 +262,6 @@ fn openrouter_index(payload: &Value) -> HashMap<&str, &Value> { .unwrap_or_default() } -/// Every OpenRouter list entry as a model of the `openrouter` provider, named -/// by its full id. OpenRouter publishes no release date, so ordering falls -/// back to name-ascending. fn openrouter_native_models(payload: &Value) -> Vec { payload["data"] .as_array() @@ -297,8 +280,6 @@ fn openrouter_native_models(payload: &Value) -> Vec { .unwrap_or_default() } -/// Fills fields that models.dev left unset on the same model: OpenRouter data -/// never overrides a present models.dev value. fn fill_from_openrouter(data: &mut ModelData, entry: &Value) { let filler = openrouter_model_data(&data.name, entry); if data.max_input_tokens.is_none() { @@ -427,8 +408,8 @@ pub fn build_catalog(sources: &FetchedSources, quirks: &[ProviderQuirks]) -> Cat (Some(a_date), Some(b_date)) => b_date .cmp(a_date) .then_with(|| a.data.name.cmp(&b.data.name)), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, (None, None) => a.data.name.cmp(&b.data.name), }); @@ -474,8 +455,6 @@ pub fn build_catalog(sources: &FetchedSources, quirks: &[ProviderQuirks]) -> Cat } } -/// Like [`build_catalog`], but errors unless every provider was assembled -/// from live API data (or is quirks-owned by design). pub fn build_catalog_strict( sources: &FetchedSources, quirks: &[ProviderQuirks], @@ -488,6 +467,7 @@ pub fn build_catalog_strict( build.failed.join(", ") ); } + Ok(build.providers) } @@ -528,13 +508,10 @@ pub fn backfill_embedded_floor(build: &mut CatalogBuild, embedded: &[ProviderMod 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' \n#DO NOT hand-edit model data.\n\ # Hand-owned behavior lives in quirks.yaml; this file is the embedded offline fallback.\n"; -/// Builds the catalog from live API data and writes it to `path`. Both -/// upstream sources must respond and every provider must fully resolve; -/// identical source data always produces identical bytes. -pub async fn generate_models_file(path: &std::path::Path) -> Result<()> { +pub async fn generate_models_file(path: &Path) -> Result<()> { let sources = fetch_sources().await; if sources.modelsdev.is_none() || sources.openrouter.is_none() { bail!( @@ -544,25 +521,24 @@ pub async fn generate_models_file(path: &std::path::Path) -> Result<()> { let providers = build_catalog_strict(&sources, &ALL_QUIRKS)?; let model_count: usize = providers.iter().map(|provider| provider.models.len()).sum(); let yaml = serde_yaml::to_string(&providers)?; - std::fs::write(path, format!("{GENERATED_HEADER}{yaml}"))?; + + fs::write(path, format!("{GENERATED_HEADER}{yaml}"))?; println!( "Wrote {} models across {} providers to {}", model_count, providers.len(), path.display() ); + Ok(()) } -/// Fraction of `old` names that also appear in `new`; empty `old` yields 1.0. -/// Used to guard against upstream id-shape drift: a low overlap means the -/// upstream catalog renamed its models out from under us. -#[allow(dead_code)] +#[cfg(test)] pub(crate) fn name_overlap(old: &[String], new: &[String]) -> f64 { if old.is_empty() { return 1.0; } - let new_names: std::collections::HashSet<&str> = new.iter().map(String::as_str).collect(); + let new_names: HashSet<&str> = new.iter().map(String::as_str).collect(); let kept = old .iter() .filter(|name| new_names.contains(name.as_str())) @@ -570,10 +546,6 @@ pub(crate) fn name_overlap(old: &[String], new: &[String]) -> f64 { kept as f64 / old.len() as f64 } -/// Overlays a hand-owned quirks entry onto the API-sourced model of the same -/// name. Every field the hand-owned entry carries (Some, true, or non-empty) -/// wins; absent fields keep the API value. `model_type` is authoritative on -/// the hand-owned entry. fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData { ModelData { name: hand.name, @@ -604,9 +576,6 @@ fn overlay_hand_owned(hand: ModelData, api: ModelData) -> ModelData { } } -/// Hand-owned catalog overrides for a single provider: oauth configuration, -/// curation excludes, field overlays for API-sourced models, suffixed model -/// variants, and fully hand-owned model entries. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProviderQuirks { @@ -623,10 +592,6 @@ pub struct ProviderQuirks { pub models: Vec, } -/// A field overlay applied to every API-sourced model whose name matches the -/// glob in `match` (`*` = any run of characters, `?` = exactly one). Only -/// explicitly present fields are written; absent fields leave the model -/// untouched. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct QuirkRule { @@ -661,9 +626,6 @@ pub struct QuirkRule { pub system_prompt_prefix: Option, } -/// Clones every model whose name matches the glob in `match`, appends `suffix` -/// to the clone's name, and overlays any explicitly present fields onto the -/// clone. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct QuirkVariant { @@ -699,8 +661,6 @@ pub struct QuirkVariant { pub system_prompt_prefix: Option, } -/// Applies every rule whose glob matches `data.name`, in file order; later -/// rules overwrite earlier ones on the same field. pub fn apply_rules(provider_quirks: &ProviderQuirks, data: &mut ModelData) { for rule in &provider_quirks.rules { if !glob_matches(&rule.r#match, &data.name) { @@ -751,8 +711,6 @@ pub fn apply_rules(provider_quirks: &ProviderQuirks, data: &mut ModelData) { } } -/// Overlays a variant's explicitly present fields onto a cloned model; same -/// field semantics as [`apply_rules`]. fn apply_variant(variant: &QuirkVariant, data: &mut ModelData) { if let Some(real_name) = &variant.real_name { data.real_name = Some(real_name.clone()); @@ -798,9 +756,6 @@ fn apply_variant(variant: &QuirkVariant, data: &mut ModelData) { } } -/// Finds the quirks for a client: an exact provider match wins, otherwise a -/// client whose name starts with the provider name (openai-compatible clients -/// are conventionally named after the provider they wrap). pub fn quirks_for<'a>( quirks: &'a [ProviderQuirks], client_name: &str, @@ -815,9 +770,6 @@ pub fn quirks_for<'a>( }) } -/// Compiled patterns are memoized: catalog builds evaluate every rule, exclude, -/// and variant glob against every model, so recompiling per call would dominate -/// the merge as the catalog grows. static GLOB_CACHE: LazyLock>>> = LazyLock::new(|| Mutex::new(HashMap::new())); @@ -834,8 +786,6 @@ fn glob_matches(pattern: &str, name: &str) -> bool { regex.is_match(name).unwrap_or(false) } -/// Translates a glob pattern (`*` = any run of characters, `?` = exactly one) -/// into an anchored regex. Patterns that fail to compile match nothing. fn compile_glob(pattern: &str) -> Regex { let translated = format!( "^{}$", @@ -853,6 +803,7 @@ fn compile_glob(pattern: &str) -> Regex { mod tests { use super::*; + use crate::client::MODELS_YAML; use serde_json::json; const KNOWN_PROVIDERS: [&str; 24] = [ @@ -934,6 +885,7 @@ mod tests { .iter() .map(|source| source.provider) .collect(); + assert_eq!(order, KNOWN_PROVIDERS); } @@ -1122,7 +1074,9 @@ rules: .iter() .flat_map(|id| modelsdev_provider_models(&api, id)) .collect(); + all.extend(openrouter_native_models(&payload)); + assert!(!all.is_empty()); for model in &all { let data = &model.data; @@ -1154,7 +1108,9 @@ rules: token_url: https://example.com/token "#, ); + let build = build_catalog(&fixture_sources(), &quirks); + assert!(find_provider(&build, "xai").oauth.is_some()); assert!(find_provider(&build, "openai").oauth.is_none()); } @@ -1352,7 +1308,9 @@ rules: models: [{name: jamba}] "#, ); + let build = build_catalog(&make_sources(None, None), &quirks); + assert_eq!(build.degraded, ["gemini"]); assert!(build.failed.contains(&"openai".to_string())); assert!(build.failed.contains(&"openrouter".to_string())); @@ -1383,9 +1341,11 @@ rules: models: [{name: g-1}] "#, ); + let err = build_catalog_strict(&make_sources(None, None), &quirks) .unwrap_err() .to_string(); + assert!(err.contains("gemini"), "{err}"); assert!(err.contains("openai"), "{err}"); } @@ -1416,6 +1376,7 @@ rules: models: [{name: v}] "#, ); + let providers = build_catalog_strict(&make_sources(Some(&modelsdev), Some(&openrouter)), &quirks) .unwrap(); @@ -1423,6 +1384,7 @@ rules: .iter() .map(|provider| provider.provider.as_str()) .collect(); + assert_eq!(order, KNOWN_PROVIDERS); } @@ -1430,13 +1392,14 @@ rules: fn name_overlap_fraction_of_old_names_still_present() { let old: Vec = ["a", "b", "c", "d"].map(str::to_string).into(); let new: Vec = ["a", "c", "x"].map(str::to_string).into(); + assert_eq!(name_overlap(&old, &new), 0.5); assert_eq!(name_overlap(&[], &new), 1.0); assert_eq!(name_overlap(&old, &[]), 0.0); } fn embedded_catalog() -> Vec { - serde_yaml::from_str(crate::client::MODELS_YAML).unwrap() + serde_yaml::from_str(MODELS_YAML).unwrap() } fn embedded_provider<'a>(providers: &'a [ProviderModels], name: &str) -> &'a ProviderModels { @@ -1598,8 +1561,7 @@ rules: #[test] fn backfill_restores_embedded_floor_for_unresolved_providers() { - let embedded: Vec = - serde_yaml::from_str(crate::client::MODELS_YAML).unwrap(); + let embedded: Vec = serde_yaml::from_str(MODELS_YAML).unwrap(); let mut build = build_catalog(&make_sources(Some(MODELSDEV_FIXTURE), None), &ALL_QUIRKS); let unresolved: Vec = build .degraded @@ -1645,8 +1607,7 @@ rules: .expect("openrouter fetch failed"), ) .unwrap(); - let embedded: Vec = - serde_yaml::from_str(crate::client::MODELS_YAML).unwrap(); + let embedded: Vec = serde_yaml::from_str(MODELS_YAML).unwrap(); println!( "{:<12} {:>7} {:>5} {:>5}", diff --git a/src/config/mod.rs b/src/config/mod.rs index 2873817..96ac24a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -94,7 +94,7 @@ use serde_json::json; use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; use std::{ - env, + env, fs, fs::{File, create_dir_all, read_dir, read_to_string}, io::Write, path::{Path, PathBuf}, @@ -548,8 +548,6 @@ pub async fn sync_models(url: Option<&str>, abort_signal: AbortSignal) -> Result 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) => { @@ -589,6 +587,7 @@ fn build_refreshed_catalog( 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!( @@ -602,12 +601,15 @@ fn build_refreshed_catalog( 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)) } @@ -626,20 +628,18 @@ fn write_models_override(list: Vec) -> Result { // name embeds the pid so concurrent coyote processes (auto-refresh racing // a manual --sync-models) never interleave writes into the same file. let mut tmp_name = model_override_path.as_os_str().to_os_string(); - tmp_name.push(format!(".{}.tmp", std::process::id())); + tmp_name.push(format!(".{}.tmp", process::id())); let tmp_path = PathBuf::from(tmp_name); - std::fs::write(&tmp_path, models_override_data) + fs::write(&tmp_path, models_override_data) .with_context(|| format!("Failed to write to '{}'", tmp_path.display()))?; - if let Err(error) = std::fs::rename(&tmp_path, &model_override_path) { - let _ = std::fs::remove_file(&tmp_path); + if let Err(error) = fs::rename(&tmp_path, &model_override_path) { + let _ = fs::remove_file(&tmp_path); return Err(error) .with_context(|| format!("Failed to write to '{}'", model_override_path.display())); } 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; @@ -653,15 +653,12 @@ fn models_override_is_stale(loadable: bool, mtime: Option, ttl_hours } } -/// 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()) + let mtime = fs::metadata(paths::models_override_file()) .and_then(|metadata| metadata.modified()) .ok(); if !models_override_is_stale(loadable, mtime, app.sync_models_ttl_hours) { @@ -1030,13 +1027,13 @@ fn render_config_template_from( fn write_config_file(config_path: &Path, config_data: &str) -> Result<()> { ensure_parent_exists(config_path)?; - std::fs::write(config_path, config_data) + fs::write(config_path, config_data) .with_context(|| format!("Failed to write to '{}'", config_path.display()))?; #[cfg(unix)] { use std::os::unix::prelude::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - std::fs::set_permissions(config_path, perms)?; + let perms = fs::Permissions::from_mode(0o600); + fs::set_permissions(config_path, perms)?; } println!("✓ Saved the config file to '{}'.\n", config_path.display()); @@ -1545,6 +1542,7 @@ clients: 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)); } @@ -1552,6 +1550,7 @@ clients: #[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)); } @@ -1564,8 +1563,8 @@ clients: .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 tmp_dir = env::temp_dir().join(format!("coyote-models-override-{unique}")); + create_dir_all(&tmp_dir).unwrap(); let env_name = get_env_name("config_dir"); let prev = env::var_os(&env_name);