From 92f40e59fb819116f93ff8649d7732735fab9ad3 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 10 Mar 2026 17:12:08 -0400 Subject: [PATCH] Validate API keys immediately during arc install Instead of deferring key validation to the optional `arc doctor` step, each API key is now validated right after entry with a retry loop if invalid. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/arc-cli/src/doctor.rs | 2 +- lib/crates/arc-cli/src/install.rs | 85 +++++++++++++++++++++++++------ 2 files changed, 71 insertions(+), 16 deletions(-) diff --git a/lib/crates/arc-cli/src/doctor.rs b/lib/crates/arc-cli/src/doctor.rs index f5c47a822..fd36b70c6 100644 --- a/lib/crates/arc-cli/src/doctor.rs +++ b/lib/crates/arc-cli/src/doctor.rs @@ -926,7 +926,7 @@ async fn probe_docker() -> Option> { Some(docker.ping().await.map(|_| ()).map_err(|e| e.to_string())) } -fn cheapest_model(provider: Provider) -> String { +pub(crate) fn cheapest_model(provider: Provider) -> String { let models = arc_llm::catalog::list_models(Some(provider.as_str())); models .iter() diff --git a/lib/crates/arc-cli/src/install.rs b/lib/crates/arc-cli/src/install.rs index 04b6ce8b5..d78fa61b2 100644 --- a/lib/crates/arc-cli/src/install.rs +++ b/lib/crates/arc-cli/src/install.rs @@ -597,13 +597,8 @@ pub async fn run_install() -> Result<()> { let first_provider = primary_providers[primary_idx]; { - let env_var = first_provider.api_key_env_vars()[0]; - let url = provider_key_url(first_provider); - eprintln!(" Get your API key at: {url}"); - - let prompt = env_var.to_string(); - let key: String = tokio::task::spawn_blocking(move || prompt_password(&prompt)).await??; - env_pairs.push((env_var.to_string(), key)); + let (env_var, key) = prompt_and_validate_key(first_provider).await?; + env_pairs.push((env_var, key)); configured_providers.push(first_provider); } @@ -635,14 +630,8 @@ pub async fn run_install() -> Result<()> { for idx in selected_indices { let provider = remaining_providers[idx]; - let env_var = provider.api_key_env_vars()[0]; - let url = provider_key_url(provider); - eprintln!(" Get your API key at: {url}"); - - let prompt = env_var.to_string(); - let key: String = - tokio::task::spawn_blocking(move || prompt_password(&prompt)).await??; - env_pairs.push((env_var.to_string(), key)); + let (env_var, key) = prompt_and_validate_key(provider).await?; + env_pairs.push((env_var, key)); } } eprintln!(); @@ -799,6 +788,64 @@ mod hex { // Tests // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// API key validation +// --------------------------------------------------------------------------- + +async fn validate_api_key(provider: Provider, api_key: &str) -> Result<(), String> { + // Temporarily set the env var so Client::from_env() picks it up + let env_var = provider.api_key_env_vars()[0]; + std::env::set_var(env_var, api_key); + + let client = arc_llm::client::Client::from_env() + .await + .map_err(|e| e.to_string())?; + + let params = arc_llm::generate::GenerateParams::new(doctor::cheapest_model(provider)) + .provider(provider.as_str()) + .prompt("Say OK") + .max_tokens(16) + .client(std::sync::Arc::new(client)); + + tokio::time::timeout( + std::time::Duration::from_secs(30), + arc_llm::generate::generate(params), + ) + .await + .map_err(|_| "timeout (30s)".to_string())? + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +async fn prompt_and_validate_key(provider: Provider) -> Result<(String, String)> { + let env_var = provider.api_key_env_vars()[0]; + let url = provider_key_url(provider); + eprintln!(" Get your API key at: {url}"); + + loop { + let prompt = env_var.to_string(); + let key: String = tokio::task::spawn_blocking(move || prompt_password(&prompt)).await??; + + eprintln!(" Validating API key..."); + match validate_api_key(provider, &key).await { + Ok(()) => { + eprintln!(" [ok] API key is valid"); + return Ok((env_var.to_string(), key)); + } + Err(e) => { + eprintln!(" [error] API key validation failed: {e}"); + let retry = tokio::task::spawn_blocking(|| { + prompt_confirm("Try again with a different key?", true) + }) + .await??; + if !retry { + return Ok((env_var.to_string(), key)); + } + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1000,4 +1047,12 @@ mod tests { assert!(url.starts_with("https://"), "{provider:?} URL: {url}"); } } + + // -- API key validation -- + + #[tokio::test] + async fn validate_api_key_rejects_invalid_key() { + let result = validate_api_key(Provider::Anthropic, "sk-invalid-key-12345").await; + assert!(result.is_err(), "expected invalid key to be rejected"); + } }