feat: refresh the model catalog from live API data at runtime
This commit is contained in:
7 files changed
+481
-30
No files matched your search
+137
-1
@@ -8,7 +8,7 @@ use fancy_regex::Regex;
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub const QUIRKS_YAML: &str = include_str!("../../quirks.yaml");
|
||||
@@ -491,6 +491,43 @@ pub fn build_catalog_strict(
|
||||
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\
|
||||
# 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
|
||||
/// names against the embedded models.yaml. Run manually:
|
||||
/// `cargo test idshape_guard_live -- --ignored --nocapture`
|
||||
|
||||
Reference in new issue
Block a user