- Add core agent architecture with Command + Skill pattern - Implement Claude API integration for content analysis - Add Obsidian REST API integration for vault operations - Create conversational interface (v2.0) with natural language processing - Add comprehensive configuration management and validation - Include project documentation and developer guides - Set up testing framework with unit, integration, and property tests - Add Kiro specs for Claude API configuration and code quality improvements - Configure project steering files for development guidelines
473 lines
18 KiB
Python
473 lines
18 KiB
Python
"""
|
|
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 |