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:
Dark-Alex-17 committed 2026-09-03 11:17:58 -06:00
1 parent 2d7a759fc0
commit 539b57aded
8 files changed
+10276 -3058

No files matched your search

+120 -5
View File
@@ -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`