Initial project setup: Obsidian intelligent journal organizer
- 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
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user