-
Notifications
You must be signed in to change notification settings - Fork 1
Fix API key errors: propagate authentication failures as errors instead of warnings #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
90cd57b
4343fb7
a5fa349
d44b395
5c1c5fa
ed0e474
667cc4c
5ee0507
a3b3ace
88cc4b6
e0d1e79
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,207 @@ | ||||||
//! Error handling utilities for the git-ai CLI tool. | ||||||
//! | ||||||
//! This module provides helpers for detecting and handling specific error types, | ||||||
//! particularly authentication failures from the OpenAI API. | ||||||
//! | ||||||
//! # OpenAI Error Structure | ||||||
//! | ||||||
//! According to the official async-openai documentation: | ||||||
//! - `OpenAIError::ApiError(ApiError)` contains structured error information from OpenAI | ||||||
//! - `ApiError` has fields: `message`, `type`, `param`, and `code` | ||||||
//! - Authentication errors have `code` set to `"invalid_api_key"` | ||||||
//! - `OpenAIError::Reqwest(Error)` contains HTTP-level errors (connection issues, etc.) | ||||||
//! | ||||||
//! Reference: https://docs.rs/async-openai/latest/async_openai/error/ | ||||||
|
||||||
use anyhow::Error; | ||||||
use async_openai::error::OpenAIError; | ||||||
|
||||||
/// Checks if an error represents an OpenAI API authentication failure. | ||||||
/// | ||||||
/// This function detects authentication failures by checking for: | ||||||
/// 1. **Structured API errors** (preferred): Checks if the error contains an `OpenAIError::ApiError` | ||||||
/// with `code` field set to `"invalid_api_key"` - this is the official OpenAI error code | ||||||
/// for authentication failures. | ||||||
/// 2. **String-based fallback**: As a fallback, checks for authentication-related keywords in | ||||||
/// the error message for cases where the error has been wrapped or converted to a string. | ||||||
/// | ||||||
/// This approach is based on the official OpenAI API error codes documentation and the | ||||||
/// async-openai Rust library structure. | ||||||
/// | ||||||
/// # Arguments | ||||||
/// | ||||||
/// * `error` - The error to check | ||||||
/// | ||||||
/// # Returns | ||||||
/// | ||||||
/// `true` if the error appears to be an authentication failure, `false` otherwise | ||||||
/// | ||||||
/// # Examples | ||||||
/// | ||||||
/// ``` | ||||||
/// use anyhow::anyhow; | ||||||
/// use ai::error::is_openai_auth_error; | ||||||
/// | ||||||
/// let error = anyhow!("invalid_api_key: Incorrect API key provided"); | ||||||
/// assert!(is_openai_auth_error(&error)); | ||||||
/// ``` | ||||||
pub fn is_openai_auth_error(error: &Error) -> bool { | ||||||
// First, try to downcast to OpenAIError for accurate detection | ||||||
if let Some(openai_err) = error.downcast_ref::<OpenAIError>() { | ||||||
match openai_err { | ||||||
// Official OpenAI API error with structured error code | ||||||
OpenAIError::ApiError(api_err) => { | ||||||
// Check for the official invalid_api_key error code | ||||||
if api_err.code.as_deref() == Some("invalid_api_key") { | ||||||
return true; | ||||||
} | ||||||
// Also check for authentication-related types | ||||||
if let Some(err_type) = &api_err.r#type { | ||||||
if err_type.contains("authentication") || err_type.contains("invalid_request_error") { | ||||||
// For invalid_request_error, check if the message mentions API key | ||||||
if err_type == "invalid_request_error" && api_err.message.to_lowercase().contains("api key") { | ||||||
return true; | ||||||
} | ||||||
} | ||||||
} | ||||||
} | ||||||
// HTTP-level errors (connection failures, malformed requests, etc.) | ||||||
OpenAIError::Reqwest(_) => { | ||||||
// Reqwest errors for auth issues typically manifest as connection errors | ||||||
// when the API key format is completely invalid (e.g., "dl://BA7...") | ||||||
let msg = error.to_string().to_lowercase(); | ||||||
if msg.contains("error sending request") || msg.contains("connection") { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The condition
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||
return true; | ||||||
} | ||||||
} | ||||||
_ => {} | ||||||
} | ||||||
} | ||||||
|
||||||
// Fallback: String-based detection for wrapped errors | ||||||
let msg = error.to_string().to_lowercase(); | ||||||
|
||||||
// OpenAI-specific API key errors (from API responses) | ||||||
msg.contains("invalid_api_key") || | ||||||
msg.contains("incorrect api key") || | ||||||
msg.contains("openai api authentication failed") || | ||||||
|
||||||
// Generic auth failures (scoped to avoid false positives) | ||||||
(msg.contains("authentication") && msg.contains("openai")) || | ||||||
(msg.contains("unauthorized") && msg.contains("openai")) || | ||||||
|
||||||
// HTTP errors that typically indicate auth issues with OpenAI | ||||||
// This pattern catches connection issues when the API key is malformed | ||||||
(msg.contains("http error") && msg.contains("error sending request")) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This condition is too broad and may catch unrelated HTTP errors. Consider making it more specific by checking for OpenAI-related context or using a more precise pattern to avoid false positives with other services that might produce similar error messages.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||
} | ||||||
|
||||||
#[cfg(test)] | ||||||
mod tests { | ||||||
use anyhow::anyhow; | ||||||
use async_openai::error::{ApiError, OpenAIError}; | ||||||
|
||||||
use super::*; | ||||||
|
||||||
// Tests for structured OpenAIError detection (preferred method) | ||||||
|
||||||
#[test] | ||||||
fn test_detects_structured_invalid_api_key() { | ||||||
let api_error = ApiError { | ||||||
message: "Incorrect API key provided: dl://BA7...".to_string(), | ||||||
r#type: Some("invalid_request_error".to_string()), | ||||||
param: None, | ||||||
code: Some("invalid_api_key".to_string()) | ||||||
}; | ||||||
let openai_error = OpenAIError::ApiError(api_error); | ||||||
let error: anyhow::Error = openai_error.into(); | ||||||
assert!(is_openai_auth_error(&error)); | ||||||
} | ||||||
|
||||||
#[test] | ||||||
fn test_detects_invalid_request_with_api_key_message() { | ||||||
let api_error = ApiError { | ||||||
message: "You must provide a valid API key".to_string(), | ||||||
r#type: Some("invalid_request_error".to_string()), | ||||||
param: None, | ||||||
code: None | ||||||
}; | ||||||
let openai_error = OpenAIError::ApiError(api_error); | ||||||
let error: anyhow::Error = openai_error.into(); | ||||||
assert!(is_openai_auth_error(&error)); | ||||||
} | ||||||
|
||||||
#[test] | ||||||
fn test_detects_reqwest_error_sending_request() { | ||||||
// Simulate a wrapped reqwest error by using anyhow | ||||||
// In production, malformed API keys cause "error sending request" from reqwest | ||||||
let error = anyhow!("http error: error sending request"); | ||||||
assert!(is_openai_auth_error(&error)); | ||||||
} | ||||||
|
||||||
#[test] | ||||||
fn test_ignores_structured_non_auth_error() { | ||||||
let api_error = ApiError { | ||||||
message: "Model not found".to_string(), | ||||||
r#type: Some("invalid_request_error".to_string()), | ||||||
param: Some("model".to_string()), | ||||||
code: Some("model_not_found".to_string()) | ||||||
}; | ||||||
let openai_error = OpenAIError::ApiError(api_error); | ||||||
let error: anyhow::Error = openai_error.into(); | ||||||
assert!(!is_openai_auth_error(&error)); | ||||||
} | ||||||
|
||||||
// Tests for string-based fallback detection (for wrapped errors) | ||||||
|
||||||
#[test] | ||||||
fn test_detects_invalid_api_key_string() { | ||||||
let error = anyhow!("invalid_api_key: Incorrect API key provided"); | ||||||
assert!(is_openai_auth_error(&error)); | ||||||
} | ||||||
|
||||||
#[test] | ||||||
fn test_detects_incorrect_api_key_string() { | ||||||
let error = anyhow!("Incorrect API key provided: sk-xxxxx"); | ||||||
assert!(is_openai_auth_error(&error)); | ||||||
} | ||||||
|
||||||
#[test] | ||||||
fn test_detects_openai_auth_failed_string() { | ||||||
let error = anyhow!("OpenAI API authentication failed: http error"); | ||||||
assert!(is_openai_auth_error(&error)); | ||||||
} | ||||||
|
||||||
#[test] | ||||||
fn test_detects_http_error_sending_request() { | ||||||
let error = anyhow!("http error: error sending request"); | ||||||
assert!(is_openai_auth_error(&error)); | ||||||
} | ||||||
|
||||||
#[test] | ||||||
fn test_detects_openai_specific_auth() { | ||||||
let error = anyhow!("OpenAI authentication failed"); | ||||||
assert!(is_openai_auth_error(&error)); | ||||||
} | ||||||
|
||||||
#[test] | ||||||
fn test_ignores_generic_auth_errors() { | ||||||
// Should not match generic auth errors without OpenAI context | ||||||
let error = anyhow!("Database authentication timeout"); | ||||||
assert!(!is_openai_auth_error(&error)); | ||||||
|
||||||
let error = anyhow!("OAuth2 unauthorized redirect"); | ||||||
assert!(!is_openai_auth_error(&error)); | ||||||
} | ||||||
|
||||||
#[test] | ||||||
fn test_ignores_unrelated_errors() { | ||||||
let error = anyhow!("File not found"); | ||||||
assert!(!is_openai_auth_error(&error)); | ||||||
} | ||||||
|
||||||
#[test] | ||||||
fn test_ignores_non_auth_openai_errors() { | ||||||
let error = anyhow!("OpenAI rate limit exceeded"); | ||||||
assert!(!is_openai_auth_error(&error)); | ||||||
} | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,17 +1,18 @@ | ||
pub mod commit; | ||
pub mod config; | ||
pub mod hook; | ||
pub mod style; | ||
pub mod model; | ||
pub mod debug_output; | ||
pub mod error; | ||
pub mod filesystem; | ||
pub mod openai; | ||
pub mod profiling; | ||
pub mod function_calling; | ||
pub mod generation; | ||
pub mod hook; | ||
pub mod model; | ||
pub mod multi_step_analysis; | ||
pub mod multi_step_integration; | ||
pub mod openai; | ||
pub mod profiling; | ||
pub mod simple_multi_step; | ||
pub mod debug_output; | ||
pub mod generation; | ||
pub mod style; | ||
|
||
// Re-exports | ||
pub use profiling::Profile; |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,45 @@ | ||||||
use ai::commit; | ||||||
use ai::config::AppConfig; | ||||||
use ai::model::Model; | ||||||
|
||||||
#[tokio::test] | ||||||
async fn test_invalid_api_key_propagates_error() { | ||||||
// Initialize logging to capture warnings | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The comment states 'capture warnings' but the test is specifically designed to verify that authentication failures are propagated as errors, not warnings. The comment should be updated to reflect the actual purpose of logging initialization in this context.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||
let _ = env_logger::builder().is_test(true).try_init(); | ||||||
|
||||||
// Ensure no API key is available from environment to force early validation failure | ||||||
let original_key = std::env::var("OPENAI_API_KEY").ok(); | ||||||
std::env::remove_var("OPENAI_API_KEY"); | ||||||
|
||||||
// Create settings with an invalid API key that fails early validation (no network calls) | ||||||
let settings = AppConfig { | ||||||
openai_api_key: Some("".to_string()), // Empty string triggers early validation failure | ||||||
model: Some("gpt-4o-mini".to_string()), | ||||||
max_tokens: Some(1024), | ||||||
max_commit_length: Some(72), | ||||||
timeout: Some(30) | ||||||
}; | ||||||
|
||||||
let example_diff = "diff --git a/test.txt b/test.txt\n+Hello World".to_string(); | ||||||
|
||||||
// This should fail with an API key error, not log a warning and continue | ||||||
let result = commit::generate(example_diff, 1024, Model::GPT41Mini, Some(&settings)).await; | ||||||
|
||||||
// Restore original environment variable if it existed | ||||||
if let Some(key) = original_key { | ||||||
std::env::set_var("OPENAI_API_KEY", key); | ||||||
} | ||||||
|
||||||
// Verify the behavior - it should return an error, not continue with other files | ||||||
assert!(result.is_err(), "Expected API key error to be propagated as error, not warning"); | ||||||
|
||||||
let error_message = result.unwrap_err().to_string(); | ||||||
println!("Actual error message: '{}'", error_message); | ||||||
|
||||||
// The error should indicate that the API key is not configured (early validation without network calls) | ||||||
assert!( | ||||||
error_message.contains("OpenAI API key not configured") || error_message.contains("Invalid OpenAI API key"), | ||||||
"Expected error message to indicate API key configuration issue, got: {}", | ||||||
error_message | ||||||
); | ||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logic has an issue: if
err_type
contains 'authentication', it should returntrue
immediately, but the current structure only returnstrue
for theinvalid_request_error
case. Authentication errors that don't match the exact string 'invalid_request_error' will fall through without being detected.Copilot uses AI. Check for mistakes.