Files

473 lines
18 KiB
Python
Raw Permalink Normal View History

"""
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