fix(providers): harden tool fallback and refresh model catalogs

This commit is contained in:
Chummy 2026-02-18 22:36:39 +08:00
parent 43494f8331
commit b4b379e3e7
9 changed files with 1111 additions and 367 deletions

View file

@ -99,6 +99,132 @@ pub fn run(config: &Config) -> Result<()> {
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModelProbeOutcome {
Ok,
Skipped,
AuthOrAccess,
Error,
}
fn classify_model_probe_error(err_message: &str) -> ModelProbeOutcome {
let lower = err_message.to_lowercase();
if lower.contains("does not support live model discovery") {
return ModelProbeOutcome::Skipped;
}
if [
"401",
"403",
"429",
"unauthorized",
"forbidden",
"api key",
"token",
"insufficient balance",
"insufficient quota",
"plan does not include",
"rate limit",
]
.iter()
.any(|hint| lower.contains(hint))
{
return ModelProbeOutcome::AuthOrAccess;
}
ModelProbeOutcome::Error
}
fn doctor_model_targets(provider_override: Option<&str>) -> Vec<String> {
if let Some(provider) = provider_override.map(str::trim).filter(|p| !p.is_empty()) {
return vec![provider.to_string()];
}
crate::providers::list_providers()
.into_iter()
.map(|provider| provider.name.to_string())
.collect()
}
pub fn run_models(config: &Config, provider_override: Option<&str>, use_cache: bool) -> Result<()> {
let targets = doctor_model_targets(provider_override);
if targets.is_empty() {
anyhow::bail!("No providers available for model probing");
}
println!("🩺 ZeroClaw Doctor — Model Catalog Probe");
println!(" Providers to probe: {}", targets.len());
println!(
" Mode: {}",
if use_cache {
"cache-first"
} else {
"force live refresh"
}
);
println!();
let mut ok_count = 0usize;
let mut skipped_count = 0usize;
let mut auth_count = 0usize;
let mut error_count = 0usize;
for provider_name in &targets {
println!(" [{}]", provider_name);
match crate::onboard::run_models_refresh(config, Some(provider_name), !use_cache) {
Ok(()) => {
ok_count += 1;
println!(" ✅ model catalog check passed");
}
Err(error) => {
let error_text = format_error_chain(&error);
match classify_model_probe_error(&error_text) {
ModelProbeOutcome::Skipped => {
skipped_count += 1;
println!(" ⚪ skipped: {}", truncate_for_display(&error_text, 160));
}
ModelProbeOutcome::AuthOrAccess => {
auth_count += 1;
println!(
" ⚠️ auth/access: {}",
truncate_for_display(&error_text, 160)
);
}
ModelProbeOutcome::Error => {
error_count += 1;
println!(" ❌ error: {}", truncate_for_display(&error_text, 160));
}
ModelProbeOutcome::Ok => {
ok_count += 1;
}
}
}
}
println!();
}
println!(
" Summary: {} ok, {} skipped, {} auth/access, {} errors",
ok_count, skipped_count, auth_count, error_count
);
if auth_count > 0 {
println!(
" 💡 Some providers need valid API keys/plan access before `/models` can be fetched."
);
}
if provider_override.is_some() && ok_count == 0 {
anyhow::bail!("Model probe failed for target provider")
}
Ok(())
}
// ── Config semantic validation ───────────────────────────────────
fn check_config_semantics(config: &Config, items: &mut Vec<DiagItem>) {
@ -572,6 +698,22 @@ fn check_command_available(cmd: &str, args: &[&str], cat: &'static str, items: &
}
}
fn format_error_chain(error: &anyhow::Error) -> String {
let mut parts = Vec::new();
for cause in error.chain() {
let message = cause.to_string();
if !message.is_empty() {
parts.push(message);
}
}
if parts.is_empty() {
return String::new();
}
parts.join(": ")
}
fn truncate_for_display(input: &str, max_chars: usize) -> String {
let mut chars = input.chars();
let preview: String = chars.by_ref().take(max_chars).collect();
@ -615,6 +757,25 @@ mod tests {
assert_eq!(DiagItem::error("t", "m").icon(), "");
}
#[test]
fn classify_model_probe_error_marks_unsupported_as_skipped() {
let outcome = classify_model_probe_error(
"Provider 'copilot' does not support live model discovery yet",
);
assert_eq!(outcome, ModelProbeOutcome::Skipped);
}
#[test]
fn classify_model_probe_error_marks_auth_and_plan_issues() {
let auth_outcome = classify_model_probe_error("OpenAI API error (401): unauthorized");
assert_eq!(auth_outcome, ModelProbeOutcome::AuthOrAccess);
let plan_outcome = classify_model_probe_error(
"Z.AI API error (429): plan does not include requested model",
);
assert_eq!(plan_outcome, ModelProbeOutcome::AuthOrAccess);
}
#[test]
fn config_validation_catches_bad_temperature() {
let mut config = Config::default();