Files
enclava/backend/app/api/v1/modules.py
2025-08-20 20:39:20 +02:00

524 lines
19 KiB
Python

"""
Modules API endpoints
"""
from typing import Dict, Any, List
from fastapi import APIRouter, Depends, HTTPException
from app.services.module_manager import module_manager, ModuleConfig
from app.core.logging import log_api_request
router = APIRouter()
@router.get("/")
async def list_modules():
"""Get list of all discovered modules with their status (enabled and disabled)"""
log_api_request("list_modules", {})
# Get all discovered modules including disabled ones
all_modules = module_manager.list_all_modules()
modules = []
for module_info in all_modules:
# Convert module_info to API format with status field
name = module_info["name"]
is_loaded = module_info["loaded"] # Module is actually loaded in memory
is_enabled = module_info["enabled"] # Module is enabled in config
# Determine status based on enabled + loaded state
if is_enabled and is_loaded:
status = "running"
elif is_enabled and not is_loaded:
status = "error" # Enabled but failed to load
else: # not is_enabled (regardless of loaded state)
status = "standby" # Disabled
api_module = {
"name": name,
"version": module_info["version"],
"description": module_info["description"],
"initialized": is_loaded,
"enabled": is_enabled,
"status": status # Add status field for frontend compatibility
}
# Get module statistics if available and module is loaded
if module_info["loaded"] and module_info["name"] in module_manager.modules:
module_instance = module_manager.modules[module_info["name"]]
if hasattr(module_instance, "get_stats"):
try:
import asyncio
if asyncio.iscoroutinefunction(module_instance.get_stats):
stats = await module_instance.get_stats()
else:
stats = module_instance.get_stats()
api_module["stats"] = stats.__dict__ if hasattr(stats, "__dict__") else stats
except:
api_module["stats"] = {}
modules.append(api_module)
# Calculate stats
loaded_count = sum(1 for m in modules if m["initialized"] and m["enabled"])
return {
"total": len(modules),
"modules": modules,
"module_count": loaded_count,
"initialized": module_manager.initialized
}
@router.get("/status")
async def get_modules_status():
"""Get comprehensive module status - CONSOLIDATED endpoint"""
log_api_request("get_modules_status", {})
# Get all discovered modules including disabled ones
all_modules = module_manager.list_all_modules()
modules_with_status = []
running_count = 0
standby_count = 0
failed_count = 0
for module_info in all_modules:
name = module_info["name"]
is_loaded = module_info["loaded"] # Module is actually loaded in memory
is_enabled = module_info["enabled"] # Module is enabled in config
# Determine status based on enabled + loaded state
if is_enabled and is_loaded:
status = "running"
running_count += 1
elif is_enabled and not is_loaded:
status = "failed" # Enabled but failed to load
failed_count += 1
else: # not is_enabled (regardless of loaded state)
status = "standby" # Disabled
standby_count += 1
# Get module statistics if available and loaded
stats = {}
if is_loaded and name in module_manager.modules:
module_instance = module_manager.modules[name]
if hasattr(module_instance, "get_stats"):
try:
import asyncio
if asyncio.iscoroutinefunction(module_instance.get_stats):
stats_result = await module_instance.get_stats()
else:
stats_result = module_instance.get_stats()
stats = stats_result.__dict__ if hasattr(stats_result, "__dict__") else stats_result
except:
stats = {}
modules_with_status.append({
"name": name,
"version": module_info["version"],
"description": module_info["description"],
"status": status,
"enabled": is_enabled,
"loaded": is_loaded,
"stats": stats
})
return {
"modules": modules_with_status,
"total": len(modules_with_status),
"running": running_count,
"standby": standby_count,
"failed": failed_count,
"system_initialized": module_manager.initialized
}
@router.get("/{module_name}")
async def get_module_info(module_name: str):
"""Get detailed information about a specific module"""
log_api_request("get_module_info", {"module_name": module_name})
if module_name not in module_manager.modules:
raise HTTPException(status_code=404, detail=f"Module '{module_name}' not found")
module = module_manager.modules[module_name]
module_info = {
"name": module_name,
"version": getattr(module, "version", "1.0.0"),
"description": getattr(module, "description", ""),
"initialized": getattr(module, "initialized", False),
"enabled": module_manager.module_configs.get(module_name, ModuleConfig(module_name)).enabled,
"capabilities": []
}
# Get module capabilities
if hasattr(module, "get_module_info"):
try:
import asyncio
if asyncio.iscoroutinefunction(module.get_module_info):
info = await module.get_module_info()
else:
info = module.get_module_info()
module_info.update(info)
except:
pass
# Get module statistics
if hasattr(module, "get_stats"):
try:
import asyncio
if asyncio.iscoroutinefunction(module.get_stats):
stats = await module.get_stats()
else:
stats = module.get_stats()
module_info["stats"] = stats.__dict__ if hasattr(stats, "__dict__") else stats
except:
module_info["stats"] = {}
# List available methods
methods = []
for attr_name in dir(module):
attr = getattr(module, attr_name)
if callable(attr) and not attr_name.startswith("_"):
methods.append(attr_name)
module_info["methods"] = methods
return module_info
@router.post("/{module_name}/enable")
async def enable_module(module_name: str):
"""Enable a module"""
log_api_request("enable_module", {"module_name": module_name})
if module_name not in module_manager.module_configs:
raise HTTPException(status_code=404, detail=f"Module '{module_name}' not found")
# Enable the module in config
config = module_manager.module_configs[module_name]
config.enabled = True
# Load the module if not already loaded
if module_name not in module_manager.modules:
try:
await module_manager._load_module(module_name, config)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to enable module '{module_name}': {str(e)}")
return {
"message": f"Module '{module_name}' enabled successfully",
"enabled": True
}
@router.post("/{module_name}/disable")
async def disable_module(module_name: str):
"""Disable a module"""
log_api_request("disable_module", {"module_name": module_name})
if module_name not in module_manager.module_configs:
raise HTTPException(status_code=404, detail=f"Module '{module_name}' not found")
# Disable the module in config
config = module_manager.module_configs[module_name]
config.enabled = False
# Unload the module if loaded
if module_name in module_manager.modules:
try:
await module_manager.unload_module(module_name)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to disable module '{module_name}': {str(e)}")
return {
"message": f"Module '{module_name}' disabled successfully",
"enabled": False
}
@router.post("/all/reload")
async def reload_all_modules():
"""Reload all modules"""
log_api_request("reload_all_modules", {})
results = {}
failed_modules = []
for module_name in list(module_manager.modules.keys()):
try:
success = await module_manager.reload_module(module_name)
results[module_name] = {"success": success, "error": None}
if not success:
failed_modules.append(module_name)
except Exception as e:
results[module_name] = {"success": False, "error": str(e)}
failed_modules.append(module_name)
if failed_modules:
return {
"message": f"Reloaded {len(results) - len(failed_modules)}/{len(results)} modules successfully",
"success": False,
"results": results,
"failed_modules": failed_modules
}
else:
return {
"message": f"All {len(results)} modules reloaded successfully",
"success": True,
"results": results,
"failed_modules": []
}
@router.post("/{module_name}/reload")
async def reload_module(module_name: str):
"""Reload a specific module"""
log_api_request("reload_module", {"module_name": module_name})
if module_name not in module_manager.modules:
raise HTTPException(status_code=404, detail=f"Module '{module_name}' not found")
success = await module_manager.reload_module(module_name)
if not success:
raise HTTPException(status_code=500, detail=f"Failed to reload module '{module_name}'")
return {
"message": f"Module '{module_name}' reloaded successfully",
"reloaded": True
}
@router.post("/{module_name}/restart")
async def restart_module(module_name: str):
"""Restart a specific module (alias for reload)"""
log_api_request("restart_module", {"module_name": module_name})
if module_name not in module_manager.modules:
raise HTTPException(status_code=404, detail=f"Module '{module_name}' not found")
success = await module_manager.reload_module(module_name)
if not success:
raise HTTPException(status_code=500, detail=f"Failed to restart module '{module_name}'")
return {
"message": f"Module '{module_name}' restarted successfully",
"restarted": True
}
@router.post("/{module_name}/start")
async def start_module(module_name: str):
"""Start a specific module (enable and load)"""
log_api_request("start_module", {"module_name": module_name})
if module_name not in module_manager.module_configs:
raise HTTPException(status_code=404, detail=f"Module '{module_name}' not found")
# Enable the module
config = module_manager.module_configs[module_name]
config.enabled = True
# Load the module if not already loaded
if module_name not in module_manager.modules:
await module_manager._load_module(module_name, config)
return {
"message": f"Module '{module_name}' started successfully",
"started": True
}
@router.post("/{module_name}/stop")
async def stop_module(module_name: str):
"""Stop a specific module (disable and unload)"""
log_api_request("stop_module", {"module_name": module_name})
if module_name not in module_manager.module_configs:
raise HTTPException(status_code=404, detail=f"Module '{module_name}' not found")
# Disable the module
config = module_manager.module_configs[module_name]
config.enabled = False
# Unload the module if loaded
if module_name in module_manager.modules:
await module_manager.unload_module(module_name)
return {
"message": f"Module '{module_name}' stopped successfully",
"stopped": True
}
@router.get("/{module_name}/stats")
async def get_module_stats(module_name: str):
"""Get module statistics"""
log_api_request("get_module_stats", {"module_name": module_name})
if module_name not in module_manager.modules:
raise HTTPException(status_code=404, detail=f"Module '{module_name}' not found")
module = module_manager.modules[module_name]
if not hasattr(module, "get_stats"):
raise HTTPException(status_code=404, detail=f"Module '{module_name}' does not provide statistics")
try:
import asyncio
if asyncio.iscoroutinefunction(module.get_stats):
stats = await module.get_stats()
else:
stats = module.get_stats()
return {
"module": module_name,
"stats": stats.__dict__ if hasattr(stats, "__dict__") else stats
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get statistics: {str(e)}")
@router.post("/{module_name}/execute")
async def execute_module_action(module_name: str, request_data: Dict[str, Any]):
"""Execute a module action through the interceptor pattern"""
log_api_request("execute_module_action", {"module_name": module_name, "action": request_data.get("action")})
if module_name not in module_manager.modules:
raise HTTPException(status_code=404, detail=f"Module '{module_name}' not found")
module = module_manager.modules[module_name]
# Check if module supports the new interceptor pattern
if hasattr(module, 'execute_with_interceptors'):
try:
# Prepare context (would normally come from authentication middleware)
context = {
"user_id": "test_user", # Would come from authentication
"api_key_id": "test_api_key", # Would come from API key auth
"ip_address": "127.0.0.1", # Would come from request
"user_permissions": [f"modules:{module_name}:*"] # Would come from user/API key permissions
}
# Execute through interceptor chain
response = await module.execute_with_interceptors(request_data, context)
return {
"module": module_name,
"success": True,
"response": response,
"interceptor_pattern": True
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Module execution failed: {str(e)}")
# Fallback for legacy modules
else:
action = request_data.get("action", "execute")
if hasattr(module, action):
try:
method = getattr(module, action)
if callable(method):
import asyncio
if asyncio.iscoroutinefunction(method):
response = await method(request_data)
else:
response = method(request_data)
return {
"module": module_name,
"success": True,
"response": response,
"interceptor_pattern": False
}
else:
raise HTTPException(status_code=400, detail=f"'{action}' is not callable")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Module execution failed: {str(e)}")
else:
raise HTTPException(status_code=400, detail=f"Action '{action}' not supported by module '{module_name}'")
@router.get("/{module_name}/config")
async def get_module_config(module_name: str):
"""Get module configuration schema and current values"""
log_api_request("get_module_config", {"module_name": module_name})
from app.services.module_config_manager import module_config_manager
from app.services.litellm_client import litellm_client
import copy
# Get module manifest and schema
manifest = module_config_manager.get_module_manifest(module_name)
if not manifest:
raise HTTPException(status_code=404, detail=f"Module '{module_name}' not found")
schema = module_config_manager.get_module_schema(module_name)
current_config = module_config_manager.get_module_config(module_name)
# For Signal module, populate model options dynamically
if module_name == "signal" and schema:
try:
# Get available models from LiteLLM
models_data = await litellm_client.get_models()
model_ids = [model.get("id", model.get("model", "")) for model in models_data if model.get("id") or model.get("model")]
if model_ids:
# Create a copy of the schema to avoid modifying the original
dynamic_schema = copy.deepcopy(schema)
# Add enum options for the model field
if "properties" in dynamic_schema and "model" in dynamic_schema["properties"]:
dynamic_schema["properties"]["model"]["enum"] = model_ids
# Set a sensible default if the current default isn't in the list
current_default = dynamic_schema["properties"]["model"].get("default", "gpt-3.5-turbo")
if current_default not in model_ids and model_ids:
dynamic_schema["properties"]["model"]["default"] = model_ids[0]
schema = dynamic_schema
except Exception as e:
# If we can't get models, log warning but continue with original schema
logger.warning(f"Failed to get dynamic models for Signal config: {e}")
return {
"module": module_name,
"description": manifest.description,
"schema": schema,
"current_config": current_config,
"has_schema": schema is not None
}
@router.post("/{module_name}/config")
async def update_module_config(module_name: str, config: dict):
"""Update module configuration"""
log_api_request("update_module_config", {"module_name": module_name})
from app.services.module_config_manager import module_config_manager
# Validate module exists
manifest = module_config_manager.get_module_manifest(module_name)
if not manifest:
raise HTTPException(status_code=404, detail=f"Module '{module_name}' not found")
try:
# Save configuration
success = await module_config_manager.save_module_config(module_name, config)
if not success:
raise HTTPException(status_code=500, detail="Failed to save configuration")
# Update module manager with new config
success = await module_manager.update_module_config(module_name, config)
if not success:
raise HTTPException(status_code=500, detail="Failed to apply configuration")
return {
"message": f"Configuration updated for module '{module_name}'",
"config": config
}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))