diff --git a/gpt_engineer/applications/cli/main.py b/gpt_engineer/applications/cli/main.py index 5a0c4135b7..390f5461fe 100644 --- a/gpt_engineer/applications/cli/main.py +++ b/gpt_engineer/applications/cli/main.py @@ -90,6 +90,148 @@ def load_env_if_needed(): load_dotenv(dotenv_path=os.path.join(os.getcwd(), ".env")) +def validate_api_key_format(key_name: str, key_value: str) -> tuple[bool, str]: + """ + Validate the format of an API key. + + Parameters + ---------- + key_name : str + The name of the API key environment variable. + key_value : str + The value of the API key to validate. + + Returns + ------- + tuple[bool, str] + A tuple of (is_valid, error_message). If valid, error_message is empty. + """ + if not key_value: + return False, f"{key_name} is empty" + + # Check minimum length (most API keys are at least 20 characters) + if len(key_value) < 20: + return False, f"{key_name} appears too short (length: {len(key_value)}). Please check your API key." + + # Check for common mistakes + if key_value.startswith("your-"): + return False, f"{key_name} appears to be a placeholder. Please use your actual API key." + + if key_value == "...": + return False, f"{key_name} is a placeholder. Please set your actual API key." + + # Validate specific formats for known providers + if key_name == "OPENAI_API_KEY": + # OpenAI keys typically start with 'sk-' or 'sk-proj-' + if not (key_value.startswith("sk-") or key_value.startswith("Bearer ")): + return False, f"{key_name} should start with 'sk-' (found: '{key_value[:10]}...')" + + elif key_name == "ANTHROPIC_API_KEY": + # Anthropic keys typically start with 'sk-ant-' + if not key_value.startswith("sk-ant-"): + return False, f"{key_name} should start with 'sk-ant-' (found: '{key_value[:10]}...')" + + return True, "" + + +def validate_api_configuration( + model: str, + azure_endpoint: str, + llm_via_clipboard: bool +) -> None: + """ + Validate API configuration before execution. + + This function checks if the required API keys are set and properly formatted + based on the selected model and configuration. It provides clear error messages + with actionable suggestions if validation fails. + + Parameters + ---------- + model : str + The model name to use (e.g., 'gpt-4', 'claude-3-opus-20240229'). + azure_endpoint : str + The Azure OpenAI endpoint URL (if using Azure). + llm_via_clipboard : bool + Whether using clipboard mode (skips API key validation). + + Raises + ------ + typer.Exit + If validation fails, exits with code 1 and displays error message. + """ + # Skip validation for clipboard mode + if llm_via_clipboard: + return + + # Skip validation for local models + if os.getenv("LOCAL_MODEL"): + return + + # Determine which API key is needed based on model and configuration + required_key = None + key_value = None + provider_name = "" + setup_instructions = "" + + if azure_endpoint: + # Azure OpenAI + required_key = "AZURE_OPENAI_API_KEY" + key_value = os.getenv(required_key) + provider_name = "Azure OpenAI" + setup_instructions = ( + "1. Get your API key from Azure Portal (https://portal.azure.com)\n" + "2. Set it using: export AZURE_OPENAI_API_KEY='your-key'\n" + "3. Or add it to a .env file in your project directory" + ) + elif "claude" in model.lower() or "anthropic" in model.lower(): + # Anthropic + required_key = "ANTHROPIC_API_KEY" + key_value = os.getenv(required_key) + provider_name = "Anthropic" + setup_instructions = ( + "1. Get your API key from https://console.anthropic.com/\n" + "2. Set it using: export ANTHROPIC_API_KEY='your-key'\n" + "3. Or add it to a .env file in your project directory" + ) + else: + # OpenAI (default) + required_key = "OPENAI_API_KEY" + key_value = os.getenv(required_key) + provider_name = "OpenAI" + setup_instructions = ( + "1. Get your API key from https://platform.openai.com/api-keys\n" + "2. Set it using: export OPENAI_API_KEY='your-key'\n" + "3. Or add it to a .env file in your project directory" + ) + + # Check if API key is set + if not key_value: + error_msg = ( + f"\n{colored('Error:', 'red')} {required_key} is not set.\n\n" + f"To use {provider_name} with model '{model}', you need to configure your API key:\n\n" + f"{setup_instructions}\n\n" + f"For more information, see: {colored('README.md', 'cyan')}" + ) + print(error_msg) + raise typer.Exit(code=1) + + # Validate API key format + is_valid, error_message = validate_api_key_format(required_key, key_value) + if not is_valid: + error_msg = ( + f"\n{colored('Error:', 'red')} {error_message}\n\n" + f"Please check your {required_key} and ensure it's set correctly.\n\n" + f"To update your API key:\n" + f"{setup_instructions}\n" + ) + print(error_msg) + raise typer.Exit(code=1) + + # Success - API key is set and appears valid + logging.debug(f"API key validation passed for {provider_name}") + + def concatenate_paths(base_path, sub_path): # Compute the relative path from base_path to sub_path relative_path = os.path.relpath(sub_path, base_path) @@ -455,6 +597,9 @@ def main( load_env_if_needed() + # Validate API configuration before creating AI + validate_api_configuration(model, azure_endpoint, llm_via_clipboard) + if llm_via_clipboard: ai = ClipboardAI() else: diff --git a/tests/applications/cli/test_api_validation.py b/tests/applications/cli/test_api_validation.py new file mode 100644 index 0000000000..f21763076c --- /dev/null +++ b/tests/applications/cli/test_api_validation.py @@ -0,0 +1,255 @@ +""" +Tests for API key validation functionality in the CLI. +""" + +import os +import unittest +from unittest.mock import patch + +import typer + +from gpt_engineer.applications.cli.main import ( + validate_api_configuration, + validate_api_key_format, +) + + +class TestValidateApiKeyFormat(unittest.TestCase): + """Test the validate_api_key_format function.""" + + def test_valid_openai_key(self): + """Test validation of a valid OpenAI API key.""" + is_valid, error_msg = validate_api_key_format( + "OPENAI_API_KEY", "sk-proj-1234567890abcdefghijklmnop" + ) + self.assertTrue(is_valid) + self.assertEqual(error_msg, "") + + def test_valid_openai_key_sk_format(self): + """Test validation of OpenAI key with sk- prefix.""" + is_valid, error_msg = validate_api_key_format( + "OPENAI_API_KEY", "sk-1234567890abcdefghijklmnop" + ) + self.assertTrue(is_valid) + self.assertEqual(error_msg, "") + + def test_valid_anthropic_key(self): + """Test validation of a valid Anthropic API key.""" + is_valid, error_msg = validate_api_key_format( + "ANTHROPIC_API_KEY", "sk-ant-1234567890abcdefghijklmnop" + ) + self.assertTrue(is_valid) + self.assertEqual(error_msg, "") + + def test_empty_key(self): + """Test validation of an empty API key.""" + is_valid, error_msg = validate_api_key_format("OPENAI_API_KEY", "") + self.assertFalse(is_valid) + self.assertIn("empty", error_msg) + + def test_too_short_key(self): + """Test validation of a too short API key.""" + is_valid, error_msg = validate_api_key_format("OPENAI_API_KEY", "sk-123") + self.assertFalse(is_valid) + self.assertIn("too short", error_msg) + + def test_placeholder_key_your(self): + """Test detection of placeholder API key starting with 'your-'.""" + is_valid, error_msg = validate_api_key_format( + "OPENAI_API_KEY", "your-api-key-here" + ) + self.assertFalse(is_valid) + self.assertIn("placeholder", error_msg.lower()) + + def test_placeholder_key_dots(self): + """Test detection of placeholder API key '...'.""" + is_valid, error_msg = validate_api_key_format("OPENAI_API_KEY", "...") + self.assertFalse(is_valid) + self.assertIn("placeholder", error_msg.lower()) + + def test_invalid_openai_key_prefix(self): + """Test validation fails for OpenAI key without sk- prefix.""" + is_valid, error_msg = validate_api_key_format( + "OPENAI_API_KEY", "invalid-1234567890abcdefghijklmnop" + ) + self.assertFalse(is_valid) + self.assertIn("should start with", error_msg) + + def test_invalid_anthropic_key_prefix(self): + """Test validation fails for Anthropic key without sk-ant- prefix.""" + is_valid, error_msg = validate_api_key_format( + "ANTHROPIC_API_KEY", "sk-1234567890abcdefghijklmnop" + ) + self.assertFalse(is_valid) + self.assertIn("sk-ant-", error_msg) + + def test_azure_key_no_specific_format(self): + """Test that Azure keys don't have specific format requirements.""" + is_valid, error_msg = validate_api_key_format( + "AZURE_OPENAI_API_KEY", "any-valid-length-key-12345" + ) + self.assertTrue(is_valid) + self.assertEqual(error_msg, "") + + +class TestValidateApiConfiguration(unittest.TestCase): + """Test the validate_api_configuration function.""" + + def test_clipboard_mode_skips_validation(self): + """Test that clipboard mode skips API key validation.""" + with patch.dict(os.environ, {}, clear=True): + # Should not raise an exception even without API keys + try: + validate_api_configuration( + model="gpt-4", + azure_endpoint="", + llm_via_clipboard=True + ) + except typer.Exit: + self.fail("Clipboard mode should skip validation") + + def test_local_model_skips_validation(self): + """Test that local model mode skips API key validation.""" + with patch.dict(os.environ, {"LOCAL_MODEL": "true"}, clear=True): + # Should not raise an exception even without API keys + try: + validate_api_configuration( + model="gpt-4", + azure_endpoint="", + llm_via_clipboard=False + ) + except typer.Exit: + self.fail("Local model mode should skip validation") + + def test_missing_openai_key(self): + """Test validation fails when OpenAI key is missing.""" + with patch.dict(os.environ, {}, clear=True): + with self.assertRaises(typer.Exit) as context: + validate_api_configuration( + model="gpt-4", + azure_endpoint="", + llm_via_clipboard=False + ) + self.assertEqual(context.exception.exit_code, 1) + + def test_missing_anthropic_key(self): + """Test validation fails when Anthropic key is missing.""" + with patch.dict(os.environ, {}, clear=True): + with self.assertRaises(typer.Exit) as context: + validate_api_configuration( + model="claude-3-opus-20240229", + azure_endpoint="", + llm_via_clipboard=False + ) + self.assertEqual(context.exception.exit_code, 1) + + def test_missing_azure_key(self): + """Test validation fails when Azure key is missing.""" + with patch.dict(os.environ, {}, clear=True): + with self.assertRaises(typer.Exit) as context: + validate_api_configuration( + model="gpt-4", + azure_endpoint="https://example.openai.azure.com", + llm_via_clipboard=False + ) + self.assertEqual(context.exception.exit_code, 1) + + def test_valid_openai_key_passes(self): + """Test validation passes with valid OpenAI key.""" + with patch.dict( + os.environ, + {"OPENAI_API_KEY": "sk-proj-1234567890abcdefghijklmnop"}, + clear=True + ): + try: + validate_api_configuration( + model="gpt-4", + azure_endpoint="", + llm_via_clipboard=False + ) + except typer.Exit: + self.fail("Validation should pass with valid OpenAI key") + + def test_valid_anthropic_key_passes(self): + """Test validation passes with valid Anthropic key.""" + with patch.dict( + os.environ, + {"ANTHROPIC_API_KEY": "sk-ant-1234567890abcdefghijklmnop"}, + clear=True + ): + try: + validate_api_configuration( + model="claude-3-opus-20240229", + azure_endpoint="", + llm_via_clipboard=False + ) + except typer.Exit: + self.fail("Validation should pass with valid Anthropic key") + + def test_valid_azure_key_passes(self): + """Test validation passes with valid Azure key.""" + with patch.dict( + os.environ, + {"AZURE_OPENAI_API_KEY": "valid-azure-key-1234567890"}, + clear=True + ): + try: + validate_api_configuration( + model="gpt-4", + azure_endpoint="https://example.openai.azure.com", + llm_via_clipboard=False + ) + except typer.Exit: + self.fail("Validation should pass with valid Azure key") + + def test_invalid_openai_key_format_fails(self): + """Test validation fails with invalid OpenAI key format.""" + with patch.dict( + os.environ, + {"OPENAI_API_KEY": "invalid-key-1234567890abcdefghijklmnop"}, + clear=True + ): + with self.assertRaises(typer.Exit) as context: + validate_api_configuration( + model="gpt-4", + azure_endpoint="", + llm_via_clipboard=False + ) + self.assertEqual(context.exception.exit_code, 1) + + def test_placeholder_openai_key_fails(self): + """Test validation fails with placeholder OpenAI key.""" + with patch.dict( + os.environ, + {"OPENAI_API_KEY": "your-api-key-here"}, + clear=True + ): + with self.assertRaises(typer.Exit) as context: + validate_api_configuration( + model="gpt-4", + azure_endpoint="", + llm_via_clipboard=False + ) + self.assertEqual(context.exception.exit_code, 1) + + def test_anthropic_model_name_variations(self): + """Test that Anthropic keys are validated for various model name formats.""" + test_models = [ + "claude-3-opus-20240229", + "claude-3-sonnet-20240229", + "anthropic/claude-3-opus", + ] + + for model in test_models: + with patch.dict(os.environ, {}, clear=True): + with self.assertRaises(typer.Exit): + validate_api_configuration( + model=model, + azure_endpoint="", + llm_via_clipboard=False + ) + + +if __name__ == "__main__": + unittest.main() +