Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ uv.lock
.env
.env.local

# Encryption key file (should never be committed)
# Encryption key file / data directory (should never be committed)
.encryption_key
.data/

# Docker
.dockerignore
Expand Down
125 changes: 67 additions & 58 deletions app/core/encryption.py
Original file line number Diff line number Diff line change
@@ -1,86 +1,96 @@
"""Encryption utilities for sensitive data like API keys."""

import logging
import os
from pathlib import Path
from cryptography.fernet import Fernet

logger = logging.getLogger(__name__)

# Get encryption key from environment or use a persistent file-based key
ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY")
ENCRYPTION_KEY_FILE = Path(".encryption_key")

# Key file search paths (first match wins, new keys are written to the first
# writable path). .data/ is a directory mount that works reliably in both
# CLI and Docker, while .encryption_key is the legacy single-file location.
_KEY_FILE_PATHS = [
Path(".data/.encryption_key"),
Path(".encryption_key"),
]

# Store the Fernet instance (singleton pattern)
_fernet_instance = None


def _read_key_from_file(path: Path) -> bytes | None:
"""Try to read and validate a Fernet key from *path*. Returns None on failure."""
if not path.is_file():
return None
try:
key_bytes = path.read_text().strip().encode()
Fernet(key_bytes)
return key_bytes
except Exception as e:
logger.warning(f"Invalid encryption key in {path}: {e}")
return None


def _write_key_to_file(key: bytes) -> Path | None:
"""Write *key* to the first writable candidate path. Returns the path used, or None."""
for path in _KEY_FILE_PATHS:
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(key)
os.chmod(path, 0o600)
logger.info(f"Encryption key saved to {path.absolute()}")
return path
except Exception:
continue
return None


def get_or_create_encryption_key() -> bytes:
"""
Get or create a persistent encryption key.

Priority:
1. ENCRYPTION_KEY environment variable
2. .encryption_key file (created if doesn't exist)
3. Generate new key and save to file

Returns:
Fernet key as bytes
1. ENCRYPTION_KEY environment variable
2. First valid key file found in _KEY_FILE_PATHS
3. Generate new key and save to the first writable path
"""
# First, try environment variable
if ENCRYPTION_KEY:
try:
key_bytes = ENCRYPTION_KEY.encode() if isinstance(ENCRYPTION_KEY, str) else ENCRYPTION_KEY
# Validate it's a valid Fernet key
Fernet(key_bytes)
return key_bytes
except Exception:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Invalid ENCRYPTION_KEY from environment, falling back to file-based key")

# Second, try to read from file
if ENCRYPTION_KEY_FILE.exists():
try:
key = ENCRYPTION_KEY_FILE.read_text().strip()
key_bytes = key.encode() if isinstance(key, str) else key
# Validate it's a valid Fernet key
Fernet(key_bytes)
logger.warning("Invalid ENCRYPTION_KEY from environment, falling back to file-based key")

for path in _KEY_FILE_PATHS:
key_bytes = _read_key_from_file(path)
if key_bytes:
return key_bytes
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to read encryption key from file: {e}, generating new key")

# Third, generate new key and save to file
import logging
logger = logging.getLogger(__name__)
logger.info("Generating new encryption key and saving to .encryption_key file")


logger.info("No existing encryption key found, generating a new one")
key = Fernet.generate_key()

# Save to file with restricted permissions (readable only by owner)
try:
ENCRYPTION_KEY_FILE.write_bytes(key)
# Set file permissions to 600 (read/write for owner only)
os.chmod(ENCRYPTION_KEY_FILE, 0o600)
logger.info(f"Encryption key saved to {ENCRYPTION_KEY_FILE.absolute()}")

# Warn if this is the first time
if not ENCRYPTION_KEY:
import warnings
warnings.warn(
f"Using file-based encryption key stored in {ENCRYPTION_KEY_FILE.absolute()}. "
f"For production, set ENCRYPTION_KEY environment variable for better security.",
UserWarning
)
except Exception as e:
logger.error(f"Failed to save encryption key to file: {e}")
# Still return the key so the app can function, but warn

saved_path = _write_key_to_file(key)
if saved_path:
import warnings
warnings.warn(
f"Could not save encryption key to file. Key will be regenerated on next restart. "
f"Set ENCRYPTION_KEY environment variable for persistence.",
UserWarning
f"Using file-based encryption key stored in {saved_path.absolute()}. "
f"For production, set ENCRYPTION_KEY environment variable for better security.",
UserWarning,
)

else:
logger.error("Failed to save encryption key to any file path")
import warnings
warnings.warn(
"Could not save encryption key to file. Key will be regenerated on next restart. "
"Set ENCRYPTION_KEY environment variable for persistence.",
UserWarning,
)

return key


Expand Down Expand Up @@ -113,7 +123,7 @@ def encrypt_api_key(api_key: str) -> str:
Encrypted API key (base64 encoded)
"""
f = get_fernet()
encrypted = f.encrypt(api_key.encode('utf-8'))
encrypted = f.encrypt(api_key.strip().encode('utf-8'))
return encrypted.decode('utf-8')


Expand Down Expand Up @@ -146,9 +156,8 @@ def decrypt_api_key(encrypted_api_key: str) -> str:

f = get_fernet()
try:
# Try to decrypt (assumes it's encrypted)
decrypted = f.decrypt(encrypted_api_key.encode('utf-8'))
return decrypted.decode('utf-8')
return decrypted.decode('utf-8').strip()
except Exception as e:
# If decryption fails
import logging
Expand All @@ -169,5 +178,5 @@ def decrypt_api_key(encrypted_api_key: str) -> str:
else:
# Key doesn't look encrypted, assume it's plain text (backward compatibility)
logger.warning("API key decryption failed, assuming plain text (backward compatibility)")
return encrypted_api_key
return encrypted_api_key.strip()

29 changes: 13 additions & 16 deletions app/services/voice_providers/retell.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,25 +94,22 @@ def create_web_call(
),
}
except Exception as e:
# Extract more detailed error information
error_message = str(e)

# Check if it's a Retell API error with more details
if hasattr(e, 'status_code'):

# Try to extract the human-readable message from Retell's API error body
upstream_msg = None
if hasattr(e, 'body') and isinstance(e.body, dict):
upstream_msg = e.body.get('message')
elif hasattr(e, 'response') and isinstance(e.response, dict):
upstream_msg = e.response.get('message')

if upstream_msg:
error_message = upstream_msg
elif hasattr(e, 'status_code'):
error_message = f"Retell API error (status {e.status_code}): {error_message}"
elif hasattr(e, 'response'):
try:
error_detail = e.response
if isinstance(error_detail, dict):
error_message = f"Retell API error: {error_detail.get('message', error_message)}"
except:
pass

# Include agent_id in error for debugging

raise ValueError(
f"Failed to create Retell web call for agent_id '{agent_id}': {error_message}. "
f"Please verify: 1) The agent_id exists in Retell, 2) The API key has access to this agent, "
f"3) The agent is configured for web calls."
f"Retell: {error_message}"
)

def create_agent(
Expand Down
18 changes: 15 additions & 3 deletions app/services/voice_providers/vapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,23 @@ def retrieve_call_metrics(self, call_id: str) -> Dict[str, Any]:
try:
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}

response = requests.get(f"{self.api_url}/call/{call_id}", headers=headers, timeout=30)
response.raise_for_status()
url = f"{self.api_url}/call/{call_id}"
logger.debug(f"[VapiProvider] Fetching call metrics: GET {url}")

response = requests.get(url, headers=headers, timeout=30)

if not response.ok:
try:
error_body = response.json()
except Exception:
error_body = response.text[:500]
logger.error(
f"[VapiProvider] GET /call/{call_id} returned {response.status_code}: {error_body}"
)
response.raise_for_status()

data = response.json()

# Log raw response for debugging
Expand Down
31 changes: 17 additions & 14 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ services:
# Use EFFICIENTAI_VERSION env var to pin to a specific version (e.g., 1.0.0)
image: ghcr.io/efficientai-tech/efficientai-api:${EFFICIENTAI_VERSION:-latest}
# For local development, uncomment below and comment out the image line:
# build:
# context: .
# dockerfile: docker/Dockerfile.api
# container_name: efficientai_api
build:
context: .
dockerfile: docker/Dockerfile.api
container_name: efficientai_api
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-efficientai}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-efficientai}
REDIS_URL: redis://redis:6379/0
Expand All @@ -56,7 +56,7 @@ services:
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
volumes:
- ./uploads:/app/uploads
- ./.encryption_key:/app/.encryption_key:ro
- ./.data:/app/.data
- ./config.docker.yml:/app/config.yml:ro
ports:
- "8000:8000"
Expand All @@ -72,24 +72,27 @@ services:
# Use EFFICIENTAI_VERSION env var to pin to a specific version (e.g., 1.0.0)
image: ghcr.io/efficientai-tech/efficientai-worker:${EFFICIENTAI_VERSION:-latest}
# For local development, uncomment below and comment out the image line:
# build:
# context: .
# dockerfile: docker/Dockerfile.worker
# args:
# INSTALL_EXTRAS: "qualitative-voice,reports"
# container_name: efficientai_worker
# Use host network mode for WebRTC connectivity (Retell/Vapi calls)
network_mode: host
build:
context: .
dockerfile: docker/Dockerfile.worker
args:
INSTALL_EXTRAS: "qualitative-voice,reports"
container_name: efficientai_worker
environment:
# Worker is on host network, so it reaches DB/Redis via localhost (exposed ports)
DATABASE_URL: postgresql://${POSTGRES_USER:-efficientai}:${POSTGRES_PASSWORD:-password}@localhost:5432/${POSTGRES_DB:-efficientai}
REDIS_URL: redis://localhost:6379/0
CELERY_BROKER_URL: redis://localhost:6379/0
CELERY_RESULT_BACKEND: redis://localhost:6379/0
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./uploads:/app/uploads
- ./.encryption_key:/app/.encryption_key:ro
- ./.data:/app/.data
- ./config.docker.yml:/app/config.yml:ro
command: eai worker --config /app/config.yml --loglevel info

Expand Down
10 changes: 2 additions & 8 deletions frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,6 @@ interface NavSection {
}

const navigationSections: NavSection[] = [
{
title: 'Prompt Partials',
icon: ScrollText,
items: [
{ name: 'Prompt Partials', href: '/prompt-partials', icon: ScrollText },
],
},
{
title: 'Simulations',
icon: Play,
Expand Down Expand Up @@ -115,6 +108,7 @@ const navigationSections: NavSection[] = [

const otherNavigation: NavItem[] = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Prompt Partials', href: '/prompt-partials', icon: ScrollText },
]

const bottomNavigation = [
Expand Down Expand Up @@ -373,7 +367,7 @@ function SidebarContent({
}) {
const { isFeatureEnabled } = useLicenseStore()
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['Prompt Partials', 'Simulations', 'Playground', 'Evaluations', 'Observability', 'Alerting', 'Configurations'])
new Set(['Simulations', 'Playground', 'Evaluations', 'Observability', 'Alerting', 'Configurations'])
)

const toggleSection = (title: string) => {
Expand Down
Loading
Loading