feat: generate the model catalog from live API data
Add a hidden --generate-models flag that rebuilds models.yaml from models.dev and OpenRouter merged with the hand-owned quirks overlay, regenerate the committed catalog with it (393 -> 1090 models), and retire the Python updater. GitHub becomes fully quirks-owned since upstream only tracks Copilot ids, legacy ids upstream dropped are preserved as hand-owned quirks entries, and non-chat model families (image/tts/audio/realtime) are excluded from generation.
This commit is contained in:
8 files changed
+10276
-3058
No files matched your search
+24
-1
@@ -12,6 +12,7 @@ use clap_complete::ArgValueCompleter;
|
||||
use is_terminal::IsTerminal;
|
||||
use std::collections::HashSet;
|
||||
use std::io::{Read, stdin};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum McpTransportArg {
|
||||
@@ -53,7 +54,7 @@ pub enum McpScopeArg {
|
||||
"install_builtins", "sync_models", "list_models", "list_roles",
|
||||
"list_sessions", "list_agents", "list_rags", "list_macros",
|
||||
"list_skills", "list_bundles", "skill", "tail_logs", "completions",
|
||||
"update", "update_bundle", "uninstall",
|
||||
"update", "update_bundle", "uninstall", "generate_models",
|
||||
])
|
||||
),
|
||||
group(
|
||||
@@ -253,6 +254,15 @@ pub struct Cli {
|
||||
/// Sync models updates
|
||||
#[arg(long, help_heading = "Installation & Updates")]
|
||||
pub sync_models: bool,
|
||||
/// Regenerate the models catalog file from live API data
|
||||
#[arg(
|
||||
long,
|
||||
hide = true,
|
||||
value_name = "PATH",
|
||||
num_args = 0..=1,
|
||||
default_missing_value = "models.yaml"
|
||||
)]
|
||||
pub generate_models: Option<PathBuf>,
|
||||
/// Update Coyote to the latest release, or to a specific version
|
||||
#[arg(long, value_name = "VERSION", help_heading = "Installation & Updates")]
|
||||
pub update: Option<Option<String>>,
|
||||
@@ -810,6 +820,19 @@ mod tests {
|
||||
assert!(cli.sync_models);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_generate_models_flag() {
|
||||
assert_eq!(parse(&[]).generate_models, None);
|
||||
assert_eq!(
|
||||
parse(&["--generate-models"]).generate_models,
|
||||
Some(PathBuf::from("models.yaml"))
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--generate-models", "out.yaml"]).generate_models,
|
||||
Some(PathBuf::from("out.yaml"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_model_with_role() {
|
||||
let cli = parse(&["-m", "gpt-4o", "-r", "coder"]);
|
||||
|
||||
+120
-5
@@ -13,7 +13,6 @@ use std::sync::LazyLock;
|
||||
|
||||
pub const QUIRKS_YAML: &str = include_str!("../../quirks.yaml");
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub static ALL_QUIRKS: LazyLock<Vec<ProviderQuirks>> =
|
||||
LazyLock::new(|| serde_yaml::from_str(QUIRKS_YAML).expect("quirks.yaml must be valid"));
|
||||
|
||||
@@ -133,8 +132,8 @@ const PROVIDER_SOURCES: [SourceIds; 24] = [
|
||||
},
|
||||
SourceIds {
|
||||
provider: "github",
|
||||
modelsdev: Some("github-copilot"),
|
||||
openrouter: Some("github"),
|
||||
modelsdev: None,
|
||||
openrouter: None,
|
||||
},
|
||||
SourceIds {
|
||||
provider: "deepinfra",
|
||||
@@ -162,7 +161,6 @@ pub struct FetchedSources {
|
||||
|
||||
/// Fetches both upstream catalogs concurrently. A failed fetch is logged and
|
||||
/// surfaces as `None` rather than an error.
|
||||
#[allow(dead_code)]
|
||||
pub async fn fetch_sources() -> FetchedSources {
|
||||
let (modelsdev, openrouter) = tokio::join!(fetch(MODELSDEV_API_URL), fetch(OPENROUTER_API_URL));
|
||||
FetchedSources {
|
||||
@@ -478,7 +476,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).
|
||||
#[allow(dead_code)]
|
||||
pub fn build_catalog_strict(
|
||||
sources: &FetchedSources,
|
||||
quirks: &[ProviderQuirks],
|
||||
@@ -494,6 +491,32 @@ pub fn build_catalog_strict(
|
||||
Ok(build.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";
|
||||
|
||||
/// 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<()> {
|
||||
let sources = fetch_sources().await;
|
||||
if sources.modelsdev.is_none() || sources.openrouter.is_none() {
|
||||
bail!(
|
||||
"generating the models file requires both catalog sources; see the log for the failed fetch"
|
||||
);
|
||||
}
|
||||
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}"))?;
|
||||
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.
|
||||
@@ -1333,6 +1356,8 @@ rules:
|
||||
models: [{name: e}]
|
||||
- provider: hunyuan
|
||||
models: [{name: h}]
|
||||
- provider: github
|
||||
models: [{name: g}]
|
||||
- provider: jina
|
||||
models: [{name: j}]
|
||||
- provider: voyageai
|
||||
@@ -1358,6 +1383,96 @@ rules:
|
||||
assert_eq!(name_overlap(&old, &[]), 0.0);
|
||||
}
|
||||
|
||||
fn embedded_catalog() -> Vec<ProviderModels> {
|
||||
serde_yaml::from_str(crate::client::MODELS_YAML).unwrap()
|
||||
}
|
||||
|
||||
fn embedded_provider<'a>(providers: &'a [ProviderModels], name: &str) -> &'a ProviderModels {
|
||||
providers
|
||||
.iter()
|
||||
.find(|provider| provider.provider == name)
|
||||
.unwrap_or_else(|| panic!("provider '{name}' missing from models.yaml"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_catalog_invariants() {
|
||||
let providers = embedded_catalog();
|
||||
assert!(providers.len() >= 24);
|
||||
let order: Vec<&str> = providers
|
||||
.iter()
|
||||
.map(|provider| provider.provider.as_str())
|
||||
.collect();
|
||||
assert_eq!(order, KNOWN_PROVIDERS);
|
||||
|
||||
assert!(embedded_provider(&providers, "xai").oauth.is_some());
|
||||
for name in ["jina", "voyageai"] {
|
||||
assert!(
|
||||
embedded_provider(&providers, name)
|
||||
.models
|
||||
.iter()
|
||||
.any(|model| model.model_type == "embedding"),
|
||||
"{name} lost its embedding models"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
embedded_provider(&providers, "cohere")
|
||||
.models
|
||||
.iter()
|
||||
.any(|model| model.model_type == "reranker"),
|
||||
"cohere lost its reranker models"
|
||||
);
|
||||
|
||||
for provider in &providers {
|
||||
for model in &provider.models {
|
||||
// A :thinking model without a patch must at least alias a real
|
||||
// wire name (qianwen twins invert a base-model patch instead).
|
||||
if model.name.ends_with(":thinking") {
|
||||
assert!(
|
||||
model.patch.is_some() || model.real_name.is_some(),
|
||||
"{}:{} has neither patch nor real_name",
|
||||
provider.provider,
|
||||
model.name
|
||||
);
|
||||
}
|
||||
if model.model_type == "chat" {
|
||||
let serialized = serde_yaml::to_value(model).unwrap();
|
||||
assert!(
|
||||
serialized
|
||||
.as_mapping()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.any(|key| !matches!(key.as_str(), Some("name" | "type"))),
|
||||
"{}:{} is an all-default chat entry",
|
||||
provider.provider,
|
||||
model.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for model in &embedded_provider(&providers, "claude").models {
|
||||
if model.model_type == "chat" {
|
||||
assert!(
|
||||
model.require_max_tokens,
|
||||
"claude:{} is missing require_max_tokens",
|
||||
model.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_catalog_roundtrip() {
|
||||
let providers = embedded_catalog();
|
||||
let yaml = serde_yaml::to_string(&providers).unwrap();
|
||||
let reparsed: Vec<ProviderModels> = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(reparsed.len(), providers.len());
|
||||
for (reparsed, original) in reparsed.iter().zip(&providers) {
|
||||
assert_eq!(reparsed.provider, original.provider);
|
||||
assert_eq!(reparsed.models.len(), original.models.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`
|
||||
|
||||
+1
-1
@@ -358,7 +358,7 @@ impl ModelData {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderModels {
|
||||
pub provider: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oauth: Option<OAuthConfig>,
|
||||
pub models: Vec<ModelData>,
|
||||
}
|
||||
|
||||
+6
-1
@@ -20,7 +20,7 @@ extern crate log;
|
||||
|
||||
use crate::cli::Cli;
|
||||
use crate::client::{
|
||||
ModelType, call_chat_completions, call_chat_completions_streaming, list_models, oauth,
|
||||
ModelType, call_chat_completions, call_chat_completions_streaming, catalog, list_models, oauth,
|
||||
};
|
||||
use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
|
||||
use crate::config::{
|
||||
@@ -101,6 +101,7 @@ async fn main() -> Result<()> {
|
||||
|
||||
let info_flag = cli.info
|
||||
|| cli.sync_models
|
||||
|| cli.generate_models.is_some()
|
||||
|| cli.list_models
|
||||
|| cli.list_roles
|
||||
|| cli.list_agents
|
||||
@@ -295,6 +296,10 @@ async fn run(
|
||||
return sync_models(&url, abort_signal.clone()).await;
|
||||
}
|
||||
|
||||
if let Some(path) = &cli.generate_models {
|
||||
return catalog::generate_models_file(path).await;
|
||||
}
|
||||
|
||||
if cli.list_models {
|
||||
for model in list_models(ctx.app.config.as_ref(), ModelType::Chat) {
|
||||
println!("{}", model.id());
|
||||
|
||||
Reference in new issue
Block a user