diff --git a/lib/crates/fabro-cli/src/doctor.rs b/lib/crates/fabro-cli/src/doctor.rs index 83fbb2c61..3deb71d0d 100644 --- a/lib/crates/fabro-cli/src/doctor.rs +++ b/lib/crates/fabro-cli/src/doctor.rs @@ -855,16 +855,9 @@ async fn probe_daytona() -> Option> { ) } -pub(crate) fn cheapest_model(provider: Provider) -> String { - let models = fabro_llm::catalog::list_models(Some(provider.as_str())); - models - .iter() - .min_by(|a, b| { - let cost_a = a.costs.input_cost_per_mtok.unwrap_or(f64::MAX); - let cost_b = b.costs.input_cost_per_mtok.unwrap_or(f64::MAX); - cost_a.total_cmp(&cost_b) - }) - .map(|m| m.id.clone()) +pub(crate) fn probe_model(provider: Provider) -> String { + fabro_llm::catalog::probe_model_for_provider(provider.as_str()) + .map(|m| m.id) .unwrap_or_else(|| format!("unknown-{}", provider.as_str())) } @@ -873,7 +866,7 @@ async fn probe_llm_provider( provider: Provider, ) -> (Provider, Result<(), String>) { let request = fabro_llm::types::Request { - model: cheapest_model(provider), + model: probe_model(provider), messages: vec![fabro_llm::types::Message::user("hi")], provider: Some(provider.as_str().to_string()), tools: None, diff --git a/lib/crates/fabro-cli/src/provider_auth.rs b/lib/crates/fabro-cli/src/provider_auth.rs index 1c4ddbe36..ba8b89f8a 100644 --- a/lib/crates/fabro-cli/src/provider_auth.rs +++ b/lib/crates/fabro-cli/src/provider_auth.rs @@ -162,7 +162,7 @@ pub(crate) async fn validate_api_key(provider: Provider, api_key: &str) -> Resul .await .map_err(|e| e.to_string())?; - let params = fabro_llm::generate::GenerateParams::new(doctor::cheapest_model(provider)) + let params = fabro_llm::generate::GenerateParams::new(doctor::probe_model(provider)) .provider(provider.as_str()) .prompt("Say OK") .max_tokens(16) diff --git a/lib/crates/fabro-llm/src/catalog.rs b/lib/crates/fabro-llm/src/catalog.rs index 68981179a..cd4bcbd45 100644 --- a/lib/crates/fabro-llm/src/catalog.rs +++ b/lib/crates/fabro-llm/src/catalog.rs @@ -56,6 +56,26 @@ pub fn default_model() -> ModelInfo { .expect("catalog.json must contain at least one default model") } +/// Get the probe model for a provider — the cheapest model known to work for +/// connectivity checks. Falls back to the default model when no explicit +/// override is configured. +#[must_use] +pub fn probe_model_for_provider(provider: &str) -> Option { + let provider = canonical_provider(provider); + // Provider-specific overrides where the cheapest model is unsuitable + // (e.g. gpt-5-mini is rejected by the ChatGPT/Codex backend). + let override_id: Option<&str> = match provider { + "openai" => Some("gpt-5.4-mini"), + _ => None, + }; + if let Some(id) = override_id { + if let Some(info) = get_model_info(id) { + return Some(info); + } + } + default_model_for_provider(provider) +} + /// List all known models, optionally filtered by provider (Section 2.9). #[must_use] pub fn list_models(provider: Option<&str>) -> Vec { @@ -608,6 +628,24 @@ mod tests { ); } + #[test] + fn probe_model_openai_returns_override() { + let m = probe_model_for_provider("openai").unwrap(); + assert_eq!(m.id, "gpt-5.4-mini"); + } + + #[test] + fn probe_model_anthropic_returns_default() { + let m = probe_model_for_provider("anthropic").unwrap(); + assert_eq!(m.id, "claude-opus-4-6"); + } + + #[test] + fn probe_model_gemini_returns_default() { + let m = probe_model_for_provider("gemini").unwrap(); + assert_eq!(m.id, "gemini-3.1-pro-preview"); + } + #[test] fn closest_model_opus_to_gemini() { let opus = get_model_info("claude-opus-4-6").unwrap();