Fix critical deployment issues: Redis connection, Ollama URL sanitization, Hugging Face provider, config validation, and enhanced error handling
Browse files- api/status.py +70 -6
- core/memory.py +10 -88
- core/providers/base.py +44 -13
- core/providers/huggingface.py +1 -1
- core/providers/ollama.py +19 -1
- core/redis_client.py +120 -0
- core/session.py +8 -12
- utils/config.py +36 -3
- utils/config_validator.py +88 -0
api/status.py
CHANGED
|
@@ -1,12 +1,76 @@
|
|
| 1 |
-
from fastapi import APIRouter
|
|
|
|
|
|
|
| 2 |
from services.ollama_monitor import check_ollama_status
|
|
|
|
|
|
|
| 3 |
|
|
|
|
| 4 |
router = APIRouter()
|
| 5 |
|
| 6 |
@router.get("/ollama-status")
|
| 7 |
async def get_ollama_status():
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
import logging
|
| 3 |
+
from core.redis_client import redis_client
|
| 4 |
from services.ollama_monitor import check_ollama_status
|
| 5 |
+
from utils.config import config
|
| 6 |
+
from utils.config_validator import ConfigValidator
|
| 7 |
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
router = APIRouter()
|
| 10 |
|
| 11 |
@router.get("/ollama-status")
|
| 12 |
async def get_ollama_status():
|
| 13 |
+
"""Returns the current status of the Ollama service."""
|
| 14 |
+
try:
|
| 15 |
+
status = check_ollama_status()
|
| 16 |
+
return status
|
| 17 |
+
except Exception as e:
|
| 18 |
+
logger.error(f"Error checking Ollama status: {e}")
|
| 19 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 20 |
+
|
| 21 |
+
@router.get("/health")
|
| 22 |
+
async def health_check():
|
| 23 |
+
"""Comprehensive health check for all services"""
|
| 24 |
+
try:
|
| 25 |
+
# Check configuration
|
| 26 |
+
validator = ConfigValidator()
|
| 27 |
+
config_report = validator.validate_all()
|
| 28 |
+
|
| 29 |
+
# Check Redis
|
| 30 |
+
redis_healthy = redis_client.is_healthy() if redis_client.get_client() else False
|
| 31 |
+
|
| 32 |
+
# Check Ollama
|
| 33 |
+
try:
|
| 34 |
+
ollama_status = check_ollama_status()
|
| 35 |
+
ollama_healthy = ollama_status.get("running", False)
|
| 36 |
+
except Exception as e:
|
| 37 |
+
logger.error(f"Error checking Ollama status: {e}")
|
| 38 |
+
ollama_healthy = False
|
| 39 |
+
ollama_status = {"error": str(e)}
|
| 40 |
+
|
| 41 |
+
# Overall health
|
| 42 |
+
overall_healthy = redis_healthy and ollama_healthy and config_report['valid']
|
| 43 |
+
|
| 44 |
+
return {
|
| 45 |
+
"status": "healthy" if overall_healthy else "degraded",
|
| 46 |
+
"services": {
|
| 47 |
+
"redis": "healthy" if redis_healthy else "unhealthy",
|
| 48 |
+
"ollama": "healthy" if ollama_healthy else "unhealthy",
|
| 49 |
+
"configuration": "valid" if config_report['valid'] else "invalid"
|
| 50 |
+
},
|
| 51 |
+
"details": {
|
| 52 |
+
"redis_healthy": redis_healthy,
|
| 53 |
+
"ollama_status": ollama_status,
|
| 54 |
+
"config_validation": config_report
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
except Exception as e:
|
| 58 |
+
logger.error(f"Health check failed: {e}")
|
| 59 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 60 |
+
|
| 61 |
+
@router.get("/config")
|
| 62 |
+
async def config_status():
|
| 63 |
+
"""Return current configuration status"""
|
| 64 |
+
validator = ConfigValidator()
|
| 65 |
+
report = validator.validate_all()
|
| 66 |
+
|
| 67 |
+
return {
|
| 68 |
+
"configuration": {
|
| 69 |
+
"ollama_host": config.ollama_host,
|
| 70 |
+
"hf_api_url": config.hf_api_url,
|
| 71 |
+
"redis_host": config.redis_host,
|
| 72 |
+
"redis_port": config.redis_port,
|
| 73 |
+
"use_fallback": config.use_fallback
|
| 74 |
+
},
|
| 75 |
+
"validation": report
|
| 76 |
+
}
|
core/memory.py
CHANGED
|
@@ -1,94 +1,23 @@
|
|
| 1 |
import json
|
| 2 |
import time
|
| 3 |
import logging
|
|
|
|
| 4 |
from utils.config import config
|
| 5 |
|
| 6 |
# Set up logging
|
| 7 |
logging.basicConfig(level=logging.INFO)
|
| 8 |
logger = logging.getLogger(__name__)
|
| 9 |
|
| 10 |
-
# Try to import redis, but handle if not available
|
| 11 |
-
try:
|
| 12 |
-
import redis
|
| 13 |
-
REDIS_AVAILABLE = True
|
| 14 |
-
except ImportError:
|
| 15 |
-
REDIS_AVAILABLE = False
|
| 16 |
-
redis = None
|
| 17 |
-
|
| 18 |
-
def get_redis_client():
|
| 19 |
-
"""Create and return Redis client with retry logic"""
|
| 20 |
-
if not REDIS_AVAILABLE:
|
| 21 |
-
return None
|
| 22 |
-
|
| 23 |
-
# Debug print to verify secrets are loaded (separate lines to avoid formatting issues)
|
| 24 |
-
logger.info(f"[DEBUG] Redis Host = {config.redis_host}")
|
| 25 |
-
logger.info(f"[DEBUG] Redis Port = {config.redis_port}")
|
| 26 |
-
|
| 27 |
-
last_exception = None
|
| 28 |
-
|
| 29 |
-
for attempt in range(config.redis_retries + 1):
|
| 30 |
-
try:
|
| 31 |
-
# Handle empty username/password gracefully
|
| 32 |
-
# Try with SSL first (required for many cloud providers)
|
| 33 |
-
redis_client = redis.Redis(
|
| 34 |
-
host=config.redis_host,
|
| 35 |
-
port=config.redis_port,
|
| 36 |
-
username=config.redis_username if config.redis_username else None,
|
| 37 |
-
password=config.redis_password if config.redis_password else None,
|
| 38 |
-
decode_responses=True,
|
| 39 |
-
socket_connect_timeout=5,
|
| 40 |
-
socket_timeout=5,
|
| 41 |
-
ssl=True, # Enable TLS (required for Redis Cloud and many providers)
|
| 42 |
-
ssl_cert_reqs=None # Skip cert verification (commonly required)
|
| 43 |
-
)
|
| 44 |
-
# Test the connection
|
| 45 |
-
redis_client.ping()
|
| 46 |
-
logger.info("Successfully connected to Redis with SSL")
|
| 47 |
-
return redis_client
|
| 48 |
-
except Exception as e:
|
| 49 |
-
# If SSL fails, try without SSL
|
| 50 |
-
try:
|
| 51 |
-
redis_client = redis.Redis(
|
| 52 |
-
host=config.redis_host,
|
| 53 |
-
port=config.redis_port,
|
| 54 |
-
username=config.redis_username if config.redis_username else None,
|
| 55 |
-
password=config.redis_password if config.redis_password else None,
|
| 56 |
-
decode_responses=True,
|
| 57 |
-
socket_connect_timeout=5,
|
| 58 |
-
socket_timeout=5
|
| 59 |
-
)
|
| 60 |
-
# Test the connection
|
| 61 |
-
redis_client.ping()
|
| 62 |
-
logger.info("Successfully connected to Redis without SSL")
|
| 63 |
-
return redis_client
|
| 64 |
-
except Exception as e2:
|
| 65 |
-
last_exception = e2
|
| 66 |
-
if attempt < config.redis_retries:
|
| 67 |
-
time.sleep(config.redis_retry_delay * (2 ** attempt)) # Exponential backoff
|
| 68 |
-
continue
|
| 69 |
-
|
| 70 |
-
logger.warning(f"Could not connect to Redis after {config.redis_retries + 1} attempts: {last_exception}")
|
| 71 |
-
return None
|
| 72 |
-
|
| 73 |
-
# Initialize Redis connection with better error handling
|
| 74 |
-
redis_client = None
|
| 75 |
-
try:
|
| 76 |
-
redis_client = get_redis_client()
|
| 77 |
-
if redis_client is None and REDIS_AVAILABLE:
|
| 78 |
-
logger.warning("Could not connect to Redis - using in-memory storage")
|
| 79 |
-
except Exception as e:
|
| 80 |
-
logger.error(f"Redis initialization failed: {e}")
|
| 81 |
-
|
| 82 |
def save_user_state(user_id: str, state: dict):
|
| 83 |
"""Save user state to Redis with fallback to in-memory storage"""
|
| 84 |
-
|
| 85 |
-
if not
|
| 86 |
# Fallback: use in-memory storage (will not persist across restarts)
|
| 87 |
logger.info("Redis not available, using in-memory storage for user state")
|
| 88 |
return False
|
| 89 |
-
|
| 90 |
try:
|
| 91 |
-
|
| 92 |
return True
|
| 93 |
except Exception as e:
|
| 94 |
logger.error(f"Error saving user state: {e}")
|
|
@@ -96,24 +25,17 @@ def save_user_state(user_id: str, state: dict):
|
|
| 96 |
|
| 97 |
def load_user_state(user_id: str):
|
| 98 |
"""Load user state from Redis with fallback"""
|
| 99 |
-
|
| 100 |
-
if not
|
| 101 |
logger.info("Redis not available, returning empty state")
|
| 102 |
return {}
|
| 103 |
-
|
| 104 |
try:
|
| 105 |
-
return
|
| 106 |
except Exception as e:
|
| 107 |
logger.error(f"Error loading user state: {e}")
|
| 108 |
return {}
|
| 109 |
|
| 110 |
def check_redis_health():
|
| 111 |
"""Check if Redis is healthy"""
|
| 112 |
-
|
| 113 |
-
if not redis_client:
|
| 114 |
-
return False
|
| 115 |
-
try:
|
| 116 |
-
redis_client.ping()
|
| 117 |
-
return True
|
| 118 |
-
except:
|
| 119 |
-
return False
|
|
|
|
| 1 |
import json
|
| 2 |
import time
|
| 3 |
import logging
|
| 4 |
+
from core.redis_client import redis_client
|
| 5 |
from utils.config import config
|
| 6 |
|
| 7 |
# Set up logging
|
| 8 |
logging.basicConfig(level=logging.INFO)
|
| 9 |
logger = logging.getLogger(__name__)
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
def save_user_state(user_id: str, state: dict):
|
| 12 |
"""Save user state to Redis with fallback to in-memory storage"""
|
| 13 |
+
client = redis_client.get_client()
|
| 14 |
+
if not client:
|
| 15 |
# Fallback: use in-memory storage (will not persist across restarts)
|
| 16 |
logger.info("Redis not available, using in-memory storage for user state")
|
| 17 |
return False
|
| 18 |
+
|
| 19 |
try:
|
| 20 |
+
client.hset(f"user:{user_id}", mapping=state)
|
| 21 |
return True
|
| 22 |
except Exception as e:
|
| 23 |
logger.error(f"Error saving user state: {e}")
|
|
|
|
| 25 |
|
| 26 |
def load_user_state(user_id: str):
|
| 27 |
"""Load user state from Redis with fallback"""
|
| 28 |
+
client = redis_client.get_client()
|
| 29 |
+
if not client:
|
| 30 |
logger.info("Redis not available, returning empty state")
|
| 31 |
return {}
|
| 32 |
+
|
| 33 |
try:
|
| 34 |
+
return client.hgetall(f"user:{user_id}")
|
| 35 |
except Exception as e:
|
| 36 |
logger.error(f"Error loading user state: {e}")
|
| 37 |
return {}
|
| 38 |
|
| 39 |
def check_redis_health():
|
| 40 |
"""Check if Redis is healthy"""
|
| 41 |
+
return redis_client.is_healthy()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
core/providers/base.py
CHANGED
|
@@ -14,6 +14,8 @@ class LLMProvider(ABC):
|
|
| 14 |
self.timeout = timeout
|
| 15 |
self.max_retries = max_retries
|
| 16 |
self.is_available = True
|
|
|
|
|
|
|
| 17 |
|
| 18 |
@abstractmethod
|
| 19 |
def generate(self, prompt: str, conversation_history: List[Dict]) -> Optional[str]:
|
|
@@ -31,16 +33,30 @@ class LLMProvider(ABC):
|
|
| 31 |
pass
|
| 32 |
|
| 33 |
def _retry_with_backoff(self, func, *args, **kwargs):
|
| 34 |
-
"""Retry logic with exponential backoff"""
|
| 35 |
last_exception = None
|
| 36 |
|
| 37 |
for attempt in range(self.max_retries):
|
| 38 |
try:
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
except Exception as e:
|
| 41 |
last_exception = e
|
|
|
|
|
|
|
|
|
|
| 42 |
if attempt < self.max_retries - 1: # Don't sleep on last attempt
|
| 43 |
-
sleep_time = (2 ** attempt) * 0.
|
| 44 |
logger.warning(f"Attempt {attempt + 1} failed: {str(e)}. Retrying in {sleep_time}s...")
|
| 45 |
time.sleep(sleep_time)
|
| 46 |
else:
|
|
@@ -48,14 +64,29 @@ class LLMProvider(ABC):
|
|
| 48 |
|
| 49 |
raise last_exception
|
| 50 |
|
| 51 |
-
def
|
| 52 |
-
"""
|
| 53 |
error_str = str(error).lower()
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
]
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
self.timeout = timeout
|
| 15 |
self.max_retries = max_retries
|
| 16 |
self.is_available = True
|
| 17 |
+
self.failure_count = 0
|
| 18 |
+
self.last_failure_time = None
|
| 19 |
|
| 20 |
@abstractmethod
|
| 21 |
def generate(self, prompt: str, conversation_history: List[Dict]) -> Optional[str]:
|
|
|
|
| 33 |
pass
|
| 34 |
|
| 35 |
def _retry_with_backoff(self, func, *args, **kwargs):
|
| 36 |
+
"""Retry logic with exponential backoff and circuit breaker"""
|
| 37 |
last_exception = None
|
| 38 |
|
| 39 |
for attempt in range(self.max_retries):
|
| 40 |
try:
|
| 41 |
+
# Simple circuit breaker - fail fast if too many recent failures
|
| 42 |
+
if self.failure_count > 5 and self.last_failure_time:
|
| 43 |
+
time_since_failure = time.time() - self.last_failure_time
|
| 44 |
+
if time_since_failure < 60: # Wait 1 minute after 5 failures
|
| 45 |
+
raise Exception("Circuit breaker tripped - too many recent failures")
|
| 46 |
+
|
| 47 |
+
result = func(*args, **kwargs)
|
| 48 |
+
# Reset failure count on success
|
| 49 |
+
self.failure_count = 0
|
| 50 |
+
self.last_failure_time = None
|
| 51 |
+
return result
|
| 52 |
+
|
| 53 |
except Exception as e:
|
| 54 |
last_exception = e
|
| 55 |
+
self.failure_count += 1
|
| 56 |
+
self.last_failure_time = time.time()
|
| 57 |
+
|
| 58 |
if attempt < self.max_retries - 1: # Don't sleep on last attempt
|
| 59 |
+
sleep_time = min((2 ** attempt) * 1.0, 10.0) # Cap at 10 seconds
|
| 60 |
logger.warning(f"Attempt {attempt + 1} failed: {str(e)}. Retrying in {sleep_time}s...")
|
| 61 |
time.sleep(sleep_time)
|
| 62 |
else:
|
|
|
|
| 64 |
|
| 65 |
raise last_exception
|
| 66 |
|
| 67 |
+
def _classify_error(self, error: Exception) -> str:
|
| 68 |
+
"""Classify error type for better handling"""
|
| 69 |
error_str = str(error).lower()
|
| 70 |
+
|
| 71 |
+
# Network errors
|
| 72 |
+
if any(term in error_str for term in ['connection', 'timeout', 'resolve', 'unreachable']):
|
| 73 |
+
return 'network'
|
| 74 |
+
|
| 75 |
+
# Authentication errors
|
| 76 |
+
if any(term in error_str for term in ['auth', 'unauthorized', 'invalid token', '401', '403']):
|
| 77 |
+
return 'authentication'
|
| 78 |
+
|
| 79 |
+
# Rate limiting
|
| 80 |
+
if any(term in error_str for term in ['rate limit', 'too many requests', 'quota exceeded', '429']):
|
| 81 |
+
return 'rate_limit'
|
| 82 |
+
|
| 83 |
+
# Server errors
|
| 84 |
+
if any(term in error_str for term in ['500', '502', '503', 'server error']):
|
| 85 |
+
return 'server'
|
| 86 |
+
|
| 87 |
+
return 'other'
|
| 88 |
+
|
| 89 |
+
def _is_recoverable_error(self, error: Exception) -> bool:
|
| 90 |
+
"""Determine if error is likely recoverable"""
|
| 91 |
+
error_type = self._classify_error(error)
|
| 92 |
+
return error_type in ['network', 'rate_limit', 'server']
|
core/providers/huggingface.py
CHANGED
|
@@ -25,6 +25,7 @@ class HuggingFaceProvider(LLMProvider):
|
|
| 25 |
if not config.hf_token:
|
| 26 |
raise ValueError("HF_TOKEN not set - required for Hugging Face provider")
|
| 27 |
|
|
|
|
| 28 |
self.client = OpenAI(
|
| 29 |
base_url=config.hf_api_url,
|
| 30 |
api_key=config.hf_token
|
|
@@ -49,7 +50,6 @@ class HuggingFaceProvider(LLMProvider):
|
|
| 49 |
def validate_model(self) -> bool:
|
| 50 |
"""Validate if the model is available"""
|
| 51 |
# For Hugging Face endpoints, we'll assume the model is valid if we can connect
|
| 52 |
-
# In production, you might want to ping the endpoint specifically
|
| 53 |
try:
|
| 54 |
# Simple connectivity check
|
| 55 |
self.client.models.list()
|
|
|
|
| 25 |
if not config.hf_token:
|
| 26 |
raise ValueError("HF_TOKEN not set - required for Hugging Face provider")
|
| 27 |
|
| 28 |
+
# Fixed OpenAI client instantiation without deprecated parameters
|
| 29 |
self.client = OpenAI(
|
| 30 |
base_url=config.hf_api_url,
|
| 31 |
api_key=config.hf_token
|
|
|
|
| 50 |
def validate_model(self) -> bool:
|
| 51 |
"""Validate if the model is available"""
|
| 52 |
# For Hugging Face endpoints, we'll assume the model is valid if we can connect
|
|
|
|
| 53 |
try:
|
| 54 |
# Simple connectivity check
|
| 55 |
self.client.models.list()
|
core/providers/ollama.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import requests
|
| 2 |
import logging
|
|
|
|
| 3 |
from typing import List, Dict, Optional, Union
|
| 4 |
from core.providers.base import LLMProvider
|
| 5 |
from utils.config import config
|
|
@@ -11,13 +12,30 @@ class OllamaProvider(LLMProvider):
|
|
| 11 |
|
| 12 |
def __init__(self, model_name: str, timeout: int = 30, max_retries: int = 3):
|
| 13 |
super().__init__(model_name, timeout, max_retries)
|
| 14 |
-
self.host = config.ollama_host or "http://localhost:11434"
|
| 15 |
# Headers to skip ngrok browser warning
|
| 16 |
self.headers = {
|
| 17 |
"ngrok-skip-browser-warning": "true",
|
| 18 |
"User-Agent": "AI-Life-Coach-Ollama"
|
| 19 |
}
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def generate(self, prompt: str, conversation_history: List[Dict]) -> Optional[str]:
|
| 22 |
"""Generate a response synchronously"""
|
| 23 |
try:
|
|
|
|
| 1 |
import requests
|
| 2 |
import logging
|
| 3 |
+
import re
|
| 4 |
from typing import List, Dict, Optional, Union
|
| 5 |
from core.providers.base import LLMProvider
|
| 6 |
from utils.config import config
|
|
|
|
| 12 |
|
| 13 |
def __init__(self, model_name: str, timeout: int = 30, max_retries: int = 3):
|
| 14 |
super().__init__(model_name, timeout, max_retries)
|
| 15 |
+
self.host = self._sanitize_host(config.ollama_host or "http://localhost:11434")
|
| 16 |
# Headers to skip ngrok browser warning
|
| 17 |
self.headers = {
|
| 18 |
"ngrok-skip-browser-warning": "true",
|
| 19 |
"User-Agent": "AI-Life-Coach-Ollama"
|
| 20 |
}
|
| 21 |
|
| 22 |
+
def _sanitize_host(self, host: str) -> str:
|
| 23 |
+
"""Sanitize host URL by removing whitespace and control characters"""
|
| 24 |
+
if not host:
|
| 25 |
+
return "http://localhost:11434"
|
| 26 |
+
|
| 27 |
+
# Remove leading/trailing whitespace and control characters
|
| 28 |
+
host = host.strip()
|
| 29 |
+
|
| 30 |
+
# Remove any newlines or control characters
|
| 31 |
+
host = re.sub(r'[\r\n\t\0]+', '', host)
|
| 32 |
+
|
| 33 |
+
# Ensure URL has a scheme
|
| 34 |
+
if not host.startswith(('http://', 'https://')):
|
| 35 |
+
host = 'http://' + host
|
| 36 |
+
|
| 37 |
+
return host
|
| 38 |
+
|
| 39 |
def generate(self, prompt: str, conversation_history: List[Dict]) -> Optional[str]:
|
| 40 |
"""Generate a response synchronously"""
|
| 41 |
try:
|
core/redis_client.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import redis
|
| 2 |
+
import logging
|
| 3 |
+
import time
|
| 4 |
+
import re
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from utils.config import config
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
class RedisClient:
|
| 11 |
+
"""Enhanced Redis client with proper connection handling"""
|
| 12 |
+
|
| 13 |
+
_instance = None
|
| 14 |
+
_redis_client = None
|
| 15 |
+
|
| 16 |
+
def __new__(cls):
|
| 17 |
+
if cls._instance is None:
|
| 18 |
+
cls._instance = super(RedisClient, cls).__new__(cls)
|
| 19 |
+
return cls._instance
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
if not hasattr(self, '_initialized'):
|
| 23 |
+
self._initialized = True
|
| 24 |
+
self._connect()
|
| 25 |
+
|
| 26 |
+
def _connect(self):
|
| 27 |
+
"""Establish Redis connection with proper error handling"""
|
| 28 |
+
if not config.redis_host or config.redis_host == "localhost":
|
| 29 |
+
logger.info("Redis not configured, skipping connection")
|
| 30 |
+
return None
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
# Parse host and port if port is included in host
|
| 34 |
+
host, port = self._parse_host_port(config.redis_host, config.redis_port)
|
| 35 |
+
|
| 36 |
+
logger.info(f"Connecting to Redis at {host}:{port}")
|
| 37 |
+
|
| 38 |
+
# Try SSL connection first (required for Redis Cloud)
|
| 39 |
+
self._redis_client = redis.Redis(
|
| 40 |
+
host=host,
|
| 41 |
+
port=port,
|
| 42 |
+
username=config.redis_username or None,
|
| 43 |
+
password=config.redis_password or None,
|
| 44 |
+
decode_responses=True,
|
| 45 |
+
socket_connect_timeout=5,
|
| 46 |
+
socket_timeout=5,
|
| 47 |
+
ssl=True,
|
| 48 |
+
ssl_cert_reqs=None, # Skip cert verification for Redis Cloud
|
| 49 |
+
retry_on_timeout=True,
|
| 50 |
+
health_check_interval=30
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# Test connection
|
| 54 |
+
self._redis_client.ping()
|
| 55 |
+
logger.info("Successfully connected to Redis with SSL")
|
| 56 |
+
|
| 57 |
+
except Exception as e:
|
| 58 |
+
logger.warning(f"SSL connection failed: {e}, trying without SSL")
|
| 59 |
+
try:
|
| 60 |
+
# Fallback to non-SSL connection
|
| 61 |
+
host, port = self._parse_host_port(config.redis_host, config.redis_port)
|
| 62 |
+
|
| 63 |
+
self._redis_client = redis.Redis(
|
| 64 |
+
host=host,
|
| 65 |
+
port=port,
|
| 66 |
+
username=config.redis_username or None,
|
| 67 |
+
password=config.redis_password or None,
|
| 68 |
+
decode_responses=True,
|
| 69 |
+
socket_connect_timeout=5,
|
| 70 |
+
socket_timeout=5,
|
| 71 |
+
retry_on_timeout=True,
|
| 72 |
+
health_check_interval=30
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# Test connection
|
| 76 |
+
self._redis_client.ping()
|
| 77 |
+
logger.info("Successfully connected to Redis without SSL")
|
| 78 |
+
|
| 79 |
+
except Exception as e2:
|
| 80 |
+
logger.error(f"Could not connect to Redis: {e2}")
|
| 81 |
+
self._redis_client = None
|
| 82 |
+
|
| 83 |
+
def _parse_host_port(self, host_string: str, default_port: int) -> tuple:
|
| 84 |
+
"""Parse host and port from host string"""
|
| 85 |
+
# Remove any whitespace or control characters
|
| 86 |
+
host_string = host_string.strip()
|
| 87 |
+
|
| 88 |
+
# Check if port is included in host (e.g., "host:port")
|
| 89 |
+
if ':' in host_string and not host_string.startswith('['): # IPv6 addresses start with [
|
| 90 |
+
parts = host_string.rsplit(':', 1) # Split only on the last colon
|
| 91 |
+
try:
|
| 92 |
+
host = parts[0]
|
| 93 |
+
port = int(parts[1])
|
| 94 |
+
return host, port
|
| 95 |
+
except ValueError:
|
| 96 |
+
# Port is not a valid integer
|
| 97 |
+
return host_string, default_port
|
| 98 |
+
|
| 99 |
+
return host_string, default_port
|
| 100 |
+
|
| 101 |
+
def get_client(self) -> Optional[redis.Redis]:
|
| 102 |
+
"""Get Redis client instance"""
|
| 103 |
+
return self._redis_client
|
| 104 |
+
|
| 105 |
+
def is_healthy(self) -> bool:
|
| 106 |
+
"""Check if Redis connection is healthy"""
|
| 107 |
+
if not self._redis_client:
|
| 108 |
+
return False
|
| 109 |
+
try:
|
| 110 |
+
self._redis_client.ping()
|
| 111 |
+
return True
|
| 112 |
+
except:
|
| 113 |
+
return False
|
| 114 |
+
|
| 115 |
+
def reconnect(self):
|
| 116 |
+
"""Reconnect to Redis"""
|
| 117 |
+
self._connect()
|
| 118 |
+
|
| 119 |
+
# Global Redis client instance
|
| 120 |
+
redis_client = RedisClient()
|
core/session.py
CHANGED
|
@@ -12,8 +12,7 @@ class SessionManager:
|
|
| 12 |
"""Manages user sessions and conversation context"""
|
| 13 |
|
| 14 |
def __init__(self, session_timeout: int = 3600):
|
| 15 |
-
"""
|
| 16 |
-
Initialize session manager
|
| 17 |
|
| 18 |
Args:
|
| 19 |
session_timeout: Session timeout in seconds (default: 1 hour)
|
|
@@ -21,8 +20,7 @@ class SessionManager:
|
|
| 21 |
self.session_timeout = session_timeout
|
| 22 |
|
| 23 |
def get_session(self, user_id: str) -> Dict[str, Any]:
|
| 24 |
-
"""
|
| 25 |
-
Retrieve user session data
|
| 26 |
|
| 27 |
Args:
|
| 28 |
user_id: Unique identifier for the user
|
|
@@ -35,21 +33,20 @@ class SessionManager:
|
|
| 35 |
if not state:
|
| 36 |
logger.info(f"Creating new session for user {user_id}")
|
| 37 |
return self._create_new_session()
|
| 38 |
-
|
| 39 |
# Check if session has expired
|
| 40 |
last_activity = float(state.get('last_activity', 0))
|
| 41 |
if time.time() - last_activity > self.session_timeout:
|
| 42 |
logger.info(f"Session expired for user {user_id}, creating new session")
|
| 43 |
return self._create_new_session()
|
| 44 |
-
|
| 45 |
return state
|
| 46 |
except Exception as e:
|
| 47 |
logger.error(f"Error retrieving session for user {user_id}: {e}")
|
| 48 |
return self._create_new_session()
|
| 49 |
|
| 50 |
def update_session(self, user_id: str, data: Dict[str, Any]) -> bool:
|
| 51 |
-
"""
|
| 52 |
-
Update user session data
|
| 53 |
|
| 54 |
Args:
|
| 55 |
user_id: Unique identifier for the user
|
|
@@ -72,14 +69,14 @@ class SessionManager:
|
|
| 72 |
logger.debug(f"Successfully updated session for user {user_id}")
|
| 73 |
else:
|
| 74 |
logger.warning(f"Failed to save session for user {user_id}")
|
|
|
|
| 75 |
return result
|
| 76 |
except Exception as e:
|
| 77 |
logger.error(f"Error updating session for user {user_id}: {e}")
|
| 78 |
return False
|
| 79 |
|
| 80 |
def clear_session(self, user_id: str) -> bool:
|
| 81 |
-
"""
|
| 82 |
-
Clear user session data
|
| 83 |
|
| 84 |
Args:
|
| 85 |
user_id: Unique identifier for the user
|
|
@@ -97,8 +94,7 @@ class SessionManager:
|
|
| 97 |
return False
|
| 98 |
|
| 99 |
def _create_new_session(self) -> Dict[str, Any]:
|
| 100 |
-
"""
|
| 101 |
-
Create a new session with default values
|
| 102 |
|
| 103 |
Returns:
|
| 104 |
Dictionary containing new session data
|
|
|
|
| 12 |
"""Manages user sessions and conversation context"""
|
| 13 |
|
| 14 |
def __init__(self, session_timeout: int = 3600):
|
| 15 |
+
"""Initialize session manager
|
|
|
|
| 16 |
|
| 17 |
Args:
|
| 18 |
session_timeout: Session timeout in seconds (default: 1 hour)
|
|
|
|
| 20 |
self.session_timeout = session_timeout
|
| 21 |
|
| 22 |
def get_session(self, user_id: str) -> Dict[str, Any]:
|
| 23 |
+
"""Retrieve user session data
|
|
|
|
| 24 |
|
| 25 |
Args:
|
| 26 |
user_id: Unique identifier for the user
|
|
|
|
| 33 |
if not state:
|
| 34 |
logger.info(f"Creating new session for user {user_id}")
|
| 35 |
return self._create_new_session()
|
| 36 |
+
|
| 37 |
# Check if session has expired
|
| 38 |
last_activity = float(state.get('last_activity', 0))
|
| 39 |
if time.time() - last_activity > self.session_timeout:
|
| 40 |
logger.info(f"Session expired for user {user_id}, creating new session")
|
| 41 |
return self._create_new_session()
|
| 42 |
+
|
| 43 |
return state
|
| 44 |
except Exception as e:
|
| 45 |
logger.error(f"Error retrieving session for user {user_id}: {e}")
|
| 46 |
return self._create_new_session()
|
| 47 |
|
| 48 |
def update_session(self, user_id: str, data: Dict[str, Any]) -> bool:
|
| 49 |
+
"""Update user session data
|
|
|
|
| 50 |
|
| 51 |
Args:
|
| 52 |
user_id: Unique identifier for the user
|
|
|
|
| 69 |
logger.debug(f"Successfully updated session for user {user_id}")
|
| 70 |
else:
|
| 71 |
logger.warning(f"Failed to save session for user {user_id}")
|
| 72 |
+
|
| 73 |
return result
|
| 74 |
except Exception as e:
|
| 75 |
logger.error(f"Error updating session for user {user_id}: {e}")
|
| 76 |
return False
|
| 77 |
|
| 78 |
def clear_session(self, user_id: str) -> bool:
|
| 79 |
+
"""Clear user session data
|
|
|
|
| 80 |
|
| 81 |
Args:
|
| 82 |
user_id: Unique identifier for the user
|
|
|
|
| 94 |
return False
|
| 95 |
|
| 96 |
def _create_new_session(self) -> Dict[str, Any]:
|
| 97 |
+
"""Create a new session with default values
|
|
|
|
| 98 |
|
| 99 |
Returns:
|
| 100 |
Dictionary containing new session data
|
utils/config.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
import os
|
|
|
|
|
|
|
| 2 |
from dotenv import load_dotenv
|
| 3 |
|
| 4 |
class Config:
|
|
@@ -13,13 +15,13 @@ class Config:
|
|
| 13 |
self.openai_api_key = os.getenv("OPENAI_API_KEY")
|
| 14 |
|
| 15 |
# API endpoints
|
| 16 |
-
self.hf_api_url = os.getenv("HF_API_ENDPOINT_URL", "https://api-inference.huggingface.co/v1/")
|
| 17 |
|
| 18 |
# Fallback settings
|
| 19 |
self.use_fallback = os.getenv("USE_FALLBACK", "true").lower() == "true"
|
| 20 |
|
| 21 |
# Redis configuration (optional for HF)
|
| 22 |
-
self.redis_host = os.getenv("REDIS_HOST", "
|
| 23 |
self.redis_port = int(os.getenv("REDIS_PORT", "6379"))
|
| 24 |
self.redis_username = os.getenv("REDIS_USERNAME", "")
|
| 25 |
self.redis_password = os.getenv("REDIS_PASSWORD", "")
|
|
@@ -28,10 +30,41 @@ class Config:
|
|
| 28 |
|
| 29 |
# Local model configuration
|
| 30 |
self.local_model_name = os.getenv("LOCAL_MODEL_NAME", "mistral:latest")
|
| 31 |
-
self.ollama_host = os.getenv("OLLAMA_HOST", "")
|
| 32 |
|
| 33 |
# OpenWeather API
|
| 34 |
self.openweather_api_key = os.getenv("OPENWEATHER_API_KEY")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
# Global config instance
|
| 37 |
config = Config()
|
|
|
|
| 1 |
import os
|
| 2 |
+
import re
|
| 3 |
+
import urllib.parse
|
| 4 |
from dotenv import load_dotenv
|
| 5 |
|
| 6 |
class Config:
|
|
|
|
| 15 |
self.openai_api_key = os.getenv("OPENAI_API_KEY")
|
| 16 |
|
| 17 |
# API endpoints
|
| 18 |
+
self.hf_api_url = self._sanitize_url(os.getenv("HF_API_ENDPOINT_URL", "https://api-inference.huggingface.co/v1/"))
|
| 19 |
|
| 20 |
# Fallback settings
|
| 21 |
self.use_fallback = os.getenv("USE_FALLBACK", "true").lower() == "true"
|
| 22 |
|
| 23 |
# Redis configuration (optional for HF)
|
| 24 |
+
self.redis_host = self._sanitize_host(os.getenv("REDIS_HOST", ""))
|
| 25 |
self.redis_port = int(os.getenv("REDIS_PORT", "6379"))
|
| 26 |
self.redis_username = os.getenv("REDIS_USERNAME", "")
|
| 27 |
self.redis_password = os.getenv("REDIS_PASSWORD", "")
|
|
|
|
| 30 |
|
| 31 |
# Local model configuration
|
| 32 |
self.local_model_name = os.getenv("LOCAL_MODEL_NAME", "mistral:latest")
|
| 33 |
+
self.ollama_host = self._sanitize_url(os.getenv("OLLAMA_HOST", ""))
|
| 34 |
|
| 35 |
# OpenWeather API
|
| 36 |
self.openweather_api_key = os.getenv("OPENWEATHER_API_KEY")
|
| 37 |
+
|
| 38 |
+
def _sanitize_url(self, url: str) -> str:
|
| 39 |
+
"""Sanitize URL by removing whitespace and control characters"""
|
| 40 |
+
if not url:
|
| 41 |
+
return ""
|
| 42 |
+
|
| 43 |
+
# Remove leading/trailing whitespace and control characters
|
| 44 |
+
url = url.strip()
|
| 45 |
+
|
| 46 |
+
# Remove any trailing newlines or control characters
|
| 47 |
+
url = re.sub(r'[\r\n\t\0]+', '', url)
|
| 48 |
+
|
| 49 |
+
# Validate URL format
|
| 50 |
+
if url and not re.match(r'^https?://', url):
|
| 51 |
+
# If no scheme, add http:// by default
|
| 52 |
+
url = 'http://' + url
|
| 53 |
+
|
| 54 |
+
return url
|
| 55 |
+
|
| 56 |
+
def _sanitize_host(self, host: str) -> str:
|
| 57 |
+
"""Sanitize host by removing whitespace and control characters"""
|
| 58 |
+
if not host:
|
| 59 |
+
return ""
|
| 60 |
+
|
| 61 |
+
# Remove leading/trailing whitespace and control characters
|
| 62 |
+
host = host.strip()
|
| 63 |
+
|
| 64 |
+
# Remove any newlines or control characters
|
| 65 |
+
host = re.sub(r'[\r\n\t\0]+', '', host)
|
| 66 |
+
|
| 67 |
+
return host
|
| 68 |
|
| 69 |
# Global config instance
|
| 70 |
config = Config()
|
utils/config_validator.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import re
|
| 3 |
+
import urllib.parse
|
| 4 |
+
from utils.config import config
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
class ConfigValidator:
|
| 9 |
+
"""Utility for validating configuration values"""
|
| 10 |
+
|
| 11 |
+
@staticmethod
|
| 12 |
+
def validate_all() -> dict:
|
| 13 |
+
"""Validate all configuration values and return report"""
|
| 14 |
+
report = {
|
| 15 |
+
'valid': True,
|
| 16 |
+
'errors': [],
|
| 17 |
+
'warnings': []
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
# Validate Ollama host
|
| 21 |
+
if config.ollama_host:
|
| 22 |
+
if not ConfigValidator._is_valid_url(config.ollama_host):
|
| 23 |
+
report['errors'].append(f"Invalid OLLAMA_HOST format: {config.ollama_host}")
|
| 24 |
+
report['valid'] = False
|
| 25 |
+
else:
|
| 26 |
+
report['warnings'].append("OLLAMA_HOST not set, local Ollama won't be available")
|
| 27 |
+
|
| 28 |
+
# Validate Redis configuration
|
| 29 |
+
if config.redis_host:
|
| 30 |
+
if not ConfigValidator._is_valid_host(config.redis_host):
|
| 31 |
+
report['warnings'].append(f"Redis host may be invalid: {config.redis_host}")
|
| 32 |
+
else:
|
| 33 |
+
report['warnings'].append("Redis not configured, using in-memory storage")
|
| 34 |
+
|
| 35 |
+
# Validate Hugging Face token
|
| 36 |
+
if config.hf_token:
|
| 37 |
+
if len(config.hf_token) < 10: # Basic sanity check
|
| 38 |
+
report['warnings'].append("HF_TOKEN seems too short to be valid")
|
| 39 |
+
else:
|
| 40 |
+
report['warnings'].append("HF_TOKEN not set, Hugging Face models won't be available")
|
| 41 |
+
|
| 42 |
+
# Validate OpenAI API key
|
| 43 |
+
if config.openai_api_key:
|
| 44 |
+
if not re.match(r'^sk-[a-zA-Z0-9]{32,}', config.openai_api_key):
|
| 45 |
+
report['warnings'].append("OPENAI_API_KEY format looks invalid")
|
| 46 |
+
else:
|
| 47 |
+
report['warnings'].append("OPENAI_API_KEY not set, OpenAI models won't be available")
|
| 48 |
+
|
| 49 |
+
return report
|
| 50 |
+
|
| 51 |
+
@staticmethod
|
| 52 |
+
def _is_valid_url(url: str) -> bool:
|
| 53 |
+
"""Check if URL is valid"""
|
| 54 |
+
try:
|
| 55 |
+
result = urllib.parse.urlparse(url)
|
| 56 |
+
return all([result.scheme, result.netloc])
|
| 57 |
+
except:
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
@staticmethod
|
| 61 |
+
def _is_valid_host(host: str) -> bool:
|
| 62 |
+
"""Check if host is valid"""
|
| 63 |
+
if not host:
|
| 64 |
+
return False
|
| 65 |
+
|
| 66 |
+
# Basic regex for hostname validation
|
| 67 |
+
pattern = r'^[a-zA-Z0-9.-]+$'
|
| 68 |
+
return bool(re.match(pattern, host))
|
| 69 |
+
|
| 70 |
+
def validate_configuration():
|
| 71 |
+
"""Validate configuration and log results"""
|
| 72 |
+
validator = ConfigValidator()
|
| 73 |
+
report = validator.validate_all()
|
| 74 |
+
|
| 75 |
+
if report['errors']:
|
| 76 |
+
logger.error("Configuration validation errors:")
|
| 77 |
+
for error in report['errors']:
|
| 78 |
+
logger.error(f" - {error}")
|
| 79 |
+
|
| 80 |
+
if report['warnings']:
|
| 81 |
+
logger.warning("Configuration warnings:")
|
| 82 |
+
for warning in report['warnings']:
|
| 83 |
+
logger.warning(f" - {warning}")
|
| 84 |
+
|
| 85 |
+
return report['valid']
|
| 86 |
+
|
| 87 |
+
# Run validation on import
|
| 88 |
+
validate_configuration()
|