""" Enhanced Claude API Client with configurable URL support Provides a centralized client for all Claude API interactions with custom endpoint support """ import ssl import logging from datetime import datetime from typing import Optional, Dict, Any, AsyncContextManager, TYPE_CHECKING from contextlib import asynccontextmanager if TYPE_CHECKING: import aiohttp try: from .dependency_manager import get_dependency_manager from .config_validation import ClaudeAPIConfig from .error_handling import APIError, ConfigurationError # Try to import anthropic with graceful degradation dependency_manager = get_dependency_manager() AsyncAnthropic = dependency_manager.get_class_from_module('anthropic', 'AsyncAnthropic') aiohttp = dependency_manager.get_module('aiohttp') except ImportError: # Fallback for backward compatibility try: from anthropic import AsyncAnthropic import aiohttp from config_validation import ClaudeAPIConfig from error_handling import APIError, ConfigurationError except ImportError: AsyncAnthropic = None aiohttp = None ClaudeAPIConfig = None APIError = Exception ConfigurationError = Exception class ClaudeAPIClient: """Enhanced Claude API client with configurable endpoint support""" def __init__(self, config: ClaudeAPIConfig): """ Initialize Claude API client with enhanced configuration Args: config: ClaudeAPIConfig instance with api_url, api_key, model, etc. """ self.config = config self.base_url = config.api_url self.api_key = config.api_key self.model = config.model self.max_tokens = config.max_tokens self.temperature = config.temperature self.logger = logging.getLogger(__name__) # Validate dependencies if not AsyncAnthropic: raise ConfigurationError( message="Anthropic library is not installed. Please install it with: pip install anthropic", config_key="anthropic_dependency", ) if not aiohttp: raise ConfigurationError( message="aiohttp library is not installed. Please install it with: pip install aiohttp", config_key="aiohttp_dependency", ) # Initialize the Anthropic client with custom base URL self._anthropic_client = self._create_anthropic_client() def _create_anthropic_client(self) -> AsyncAnthropic: """Create Anthropic client with custom configuration""" client_kwargs = { 'api_key': self.api_key, } # Set custom base URL if different from default if self.base_url != "https://api.anthropic.com": client_kwargs['base_url'] = self.base_url self.logger.info(f"Using custom Claude API URL: {self.base_url}") return AsyncAnthropic(**client_kwargs) @asynccontextmanager async def create_http_session(self) -> AsyncContextManager[Any]: """ Create HTTP session with proper SSL configuration for custom endpoints This is used for direct HTTP calls when needed (e.g., connection validation) """ if not aiohttp: raise ConfigurationError( message="aiohttp library is not installed. Please install it with: pip install aiohttp", config_key="aiohttp_dependency", ) headers = { 'Authorization': f'Bearer {self.api_key}', 'Content-Type': 'application/json', 'User-Agent': 'journal-organizer/1.0', 'anthropic-version': '2023-06-01' } # Configure SSL context for custom endpoints ssl_context = ssl.create_default_context() # Handle localhost and custom endpoints with SSL considerations if ('localhost' in self.base_url or self.base_url.startswith('https://127.0.0.1') or self.base_url.startswith('http://localhost') or self.base_url.startswith('http://127.0.0.1')): # For localhost, disable SSL verification ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE self.logger.warning(f"SSL verification disabled for localhost endpoint: {self.base_url}") connector = aiohttp.TCPConnector(ssl=ssl_context) timeout = aiohttp.ClientTimeout(total=60) async with aiohttp.ClientSession( connector=connector, headers=headers, timeout=timeout ) as session: yield session async def validate_connection(self) -> bool: """ Validate API connection and credentials Returns: True if connection is valid Raises: APIError: If connection validation fails """ try: self.logger.info(f"Validating connection to Claude API at {self.base_url}") # Try a minimal API call to validate connection message = await self._anthropic_client.messages.create( model=self.model, max_tokens=10, messages=[{"role": "user", "content": "test"}] ) if message and hasattr(message, 'content') and message.content: self.logger.info("Claude API connection validated successfully") return True else: raise APIError( message="Invalid response from Claude API", api_name="claude" ) except Exception as e: error_msg = str(e) # Provide specific error messages based on common issues if "401" in error_msg or "unauthorized" in error_msg.lower(): raise APIError( message=f"Invalid API key or unauthorized access to {self.base_url}. Please check your Claude API key.", api_name="claude", cause=e ) elif "404" in error_msg or "not found" in error_msg.lower(): raise APIError( message=f"API endpoint not found. Please verify that {self.base_url} is the correct Claude API URL.", api_name="claude", cause=e ) elif "connection" in error_msg.lower() or "timeout" in error_msg.lower(): raise APIError( message=f"Connection error to {self.base_url}. Please check your network connection and API URL.", api_name="claude", cause=e ) elif "ssl" in error_msg.lower() or "certificate" in error_msg.lower(): raise APIError( message=f"SSL/Certificate error connecting to {self.base_url}. For localhost endpoints, this is expected.", api_name="claude", cause=e ) else: raise APIError( message=f"Claude API validation failed: {error_msg}", api_name="claude", cause=e ) async def validate_model_availability(self) -> bool: """ Validate that the configured model is available Returns: True if model is available Raises: APIError: If model validation fails """ try: self.logger.info(f"Validating model availability: {self.model}") # Try a minimal API call with the specific model message = await self._anthropic_client.messages.create( model=self.model, max_tokens=5, messages=[{"role": "user", "content": "hi"}] ) if message and hasattr(message, 'content') and message.content: self.logger.info(f"Model {self.model} is available and working") return True else: raise APIError( message=f"Invalid response when testing model {self.model}", api_name="claude" ) except Exception as e: error_msg = str(e) if "model" in error_msg.lower() and ("not found" in error_msg.lower() or "invalid" in error_msg.lower()): raise APIError( message=f"Model '{self.model}' is not available or invalid. Please check your model configuration.", api_name="claude", cause=e ) else: # Re-raise as general API error raise APIError( message=f"Model validation failed: {error_msg}", api_name="claude", cause=e ) async def test_api_connectivity(self) -> Dict[str, Any]: """ Comprehensive API connectivity test with detailed results Returns: Dictionary with test results and diagnostics """ results = { 'connection_test': {'status': 'unknown', 'message': '', 'error': None}, 'model_test': {'status': 'unknown', 'message': '', 'error': None}, 'authentication_test': {'status': 'unknown', 'message': '', 'error': None}, 'overall_status': 'unknown', 'api_url': self.base_url, 'model': self.model, 'timestamp': datetime.now().isoformat() } # Test 1: Basic connection try: await self.validate_connection() results['connection_test'] = { 'status': 'success', 'message': 'API connection successful', 'error': None } results['authentication_test'] = { 'status': 'success', 'message': 'API key authentication successful', 'error': None } except APIError as e: results['connection_test'] = { 'status': 'failed', 'message': str(e), 'error': type(e).__name__ } # Determine if it's an auth issue if "401" in str(e) or "unauthorized" in str(e).lower(): results['authentication_test'] = { 'status': 'failed', 'message': 'API key authentication failed', 'error': 'AuthenticationError' } else: results['authentication_test'] = { 'status': 'unknown', 'message': 'Could not test authentication due to connection failure', 'error': None } # Test 2: Model availability (only if connection succeeded) if results['connection_test']['status'] == 'success': try: await self.validate_model_availability() results['model_test'] = { 'status': 'success', 'message': f'Model {self.model} is available', 'error': None } except APIError as e: results['model_test'] = { 'status': 'failed', 'message': str(e), 'error': type(e).__name__ } else: results['model_test'] = { 'status': 'skipped', 'message': 'Model test skipped due to connection failure', 'error': None } # Determine overall status if (results['connection_test']['status'] == 'success' and results['model_test']['status'] == 'success' and results['authentication_test']['status'] == 'success'): results['overall_status'] = 'success' elif results['connection_test']['status'] == 'failed': results['overall_status'] = 'connection_failed' elif results['authentication_test']['status'] == 'failed': results['overall_status'] = 'authentication_failed' elif results['model_test']['status'] == 'failed': results['overall_status'] = 'model_unavailable' else: results['overall_status'] = 'partial_failure' return results async def create_message(self, messages: list, **kwargs) -> Any: """ Create a message using the Claude API Args: messages: List of message dictionaries **kwargs: Additional parameters (max_tokens, temperature, etc.) Returns: Claude API response Raises: APIError: If API call fails """ try: # Use configured defaults, allow override via kwargs api_params = { 'model': kwargs.get('model', self.model), 'max_tokens': kwargs.get('max_tokens', self.max_tokens), 'messages': messages } # Add temperature if specified temperature = kwargs.get('temperature', self.temperature) if temperature is not None: api_params['temperature'] = temperature # Add any other parameters passed in kwargs for key, value in kwargs.items(): if key not in ['model', 'max_tokens', 'temperature'] and value is not None: api_params[key] = value self.logger.debug(f"Making Claude API call with model: {api_params['model']}") response = await self._anthropic_client.messages.create(**api_params) self.logger.debug("Claude API call completed successfully") return response except Exception as e: error_msg = str(e) # Provide specific error handling if "rate_limit" in error_msg.lower(): raise APIError( message="Claude API rate limit exceeded. Please wait before making more requests.", api_name="claude", cause=e ) elif "invalid_request" in error_msg.lower(): raise APIError( message=f"Invalid request to Claude API. Please check your parameters: {error_msg}", api_name="claude", cause=e ) elif "model" in error_msg.lower() and "not found" in error_msg.lower(): raise APIError( message=f"Model '{api_params.get('model', self.model)}' not found. Please check your model configuration.", api_name="claude", cause=e ) else: raise APIError( message=f"Claude API call failed: {error_msg}", api_name="claude", cause=e ) def get_client_info(self) -> Dict[str, Any]: """ Get information about the client configuration Returns: Dictionary with client configuration details """ return { 'api_url': self.base_url, 'model': self.model, 'max_tokens': self.max_tokens, 'temperature': self.temperature, 'is_custom_endpoint': self.base_url != "https://api.anthropic.com" } @classmethod async def validate_configuration(cls, config: ClaudeAPIConfig, quick_test: bool = True) -> Dict[str, Any]: """ Class method to validate Claude API configuration without creating a persistent client Args: config: ClaudeAPIConfig to validate quick_test: If True, perform only basic validation. If False, run comprehensive tests. Returns: Dictionary with validation results """ validation_results = { 'config_valid': False, 'connection_valid': False, 'model_valid': False, 'errors': [], 'warnings': [], 'config_info': { 'api_url': config.api_url, 'model': config.model, 'is_custom_endpoint': config.api_url != "https://api.anthropic.com" } } try: # Create temporary client for validation client = cls(config) validation_results['config_valid'] = True if quick_test: # Quick validation - just test connection try: await client.validate_connection() validation_results['connection_valid'] = True validation_results['model_valid'] = True # Assume model is valid if connection works except APIError as e: validation_results['errors'].append(str(e)) else: # Comprehensive validation test_results = await client.test_api_connectivity() validation_results['connection_valid'] = test_results['connection_test']['status'] == 'success' validation_results['model_valid'] = test_results['model_test']['status'] == 'success' # Collect errors from detailed tests for test_name, test_result in test_results.items(): if isinstance(test_result, dict) and test_result.get('status') == 'failed': validation_results['errors'].append(f"{test_name}: {test_result['message']}") # Add test results to validation results validation_results['detailed_tests'] = test_results # Add warnings for custom endpoints if config.api_url != "https://api.anthropic.com": validation_results['warnings'].append( f"Using custom API endpoint: {config.api_url}. " "Ensure this is a valid Claude API-compatible endpoint." ) except Exception as e: validation_results['errors'].append(f"Configuration validation failed: {str(e)}") return validation_results