mirror of
https://github.com/aljazceru/enclava.git
synced 2025-12-17 07:24:34 +01:00
migration of chatbot api to openai compatibility
This commit is contained in:
@@ -5,7 +5,6 @@ Public API v1 package - for external clients
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from ..v1.llm import router as llm_router
|
from ..v1.llm import router as llm_router
|
||||||
from ..v1.chatbot import router as chatbot_router
|
from ..v1.chatbot import router as chatbot_router
|
||||||
from ..v1.tee import router as tee_router
|
|
||||||
from ..v1.openai_compat import router as openai_router
|
from ..v1.openai_compat import router as openai_router
|
||||||
|
|
||||||
# Create public API router
|
# Create public API router
|
||||||
@@ -19,6 +18,3 @@ public_api_router.include_router(llm_router, prefix="/llm", tags=["public-llm"])
|
|||||||
|
|
||||||
# Include public chatbot API (external chatbot integrations)
|
# Include public chatbot API (external chatbot integrations)
|
||||||
public_api_router.include_router(chatbot_router, prefix="/chatbot", tags=["public-chatbot"])
|
public_api_router.include_router(chatbot_router, prefix="/chatbot", tags=["public-chatbot"])
|
||||||
|
|
||||||
# Include TEE routes (public TEE services if applicable)
|
|
||||||
public_api_router.include_router(tee_router, prefix="/tee", tags=["public-tee"])
|
|
||||||
@@ -5,7 +5,6 @@ API v1 package
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from .auth import router as auth_router
|
from .auth import router as auth_router
|
||||||
from .llm import router as llm_router
|
from .llm import router as llm_router
|
||||||
from .tee import router as tee_router
|
|
||||||
from .modules import router as modules_router
|
from .modules import router as modules_router
|
||||||
from .platform import router as platform_router
|
from .platform import router as platform_router
|
||||||
from .users import router as users_router
|
from .users import router as users_router
|
||||||
@@ -29,9 +28,6 @@ api_router.include_router(auth_router, prefix="/auth", tags=["authentication"])
|
|||||||
# Include LLM proxy routes
|
# Include LLM proxy routes
|
||||||
api_router.include_router(llm_router, prefix="/llm", tags=["llm"])
|
api_router.include_router(llm_router, prefix="/llm", tags=["llm"])
|
||||||
|
|
||||||
# Include TEE routes
|
|
||||||
api_router.include_router(tee_router, prefix="/tee", tags=["tee"])
|
|
||||||
|
|
||||||
# Include modules routes
|
# Include modules routes
|
||||||
api_router.include_router(modules_router, prefix="/modules", tags=["modules"])
|
api_router.include_router(modules_router, prefix="/modules", tags=["modules"])
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ Chatbot API endpoints
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, update, delete
|
from sqlalchemy import select, update, delete
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -42,6 +43,44 @@ class ChatRequest(BaseModel):
|
|||||||
conversation_id: Optional[str] = None
|
conversation_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# OpenAI-compatible models
|
||||||
|
class ChatMessage(BaseModel):
|
||||||
|
role: str = Field(..., description="Message role (system, user, assistant)")
|
||||||
|
content: str = Field(..., description="Message content")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatbotChatCompletionRequest(BaseModel):
|
||||||
|
messages: List[ChatMessage] = Field(..., description="List of messages")
|
||||||
|
max_tokens: Optional[int] = Field(None, description="Maximum tokens to generate")
|
||||||
|
temperature: Optional[float] = Field(None, description="Temperature for sampling")
|
||||||
|
top_p: Optional[float] = Field(None, description="Top-p sampling parameter")
|
||||||
|
frequency_penalty: Optional[float] = Field(None, description="Frequency penalty")
|
||||||
|
presence_penalty: Optional[float] = Field(None, description="Presence penalty")
|
||||||
|
stop: Optional[List[str]] = Field(None, description="Stop sequences")
|
||||||
|
stream: Optional[bool] = Field(False, description="Stream response")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatChoice(BaseModel):
|
||||||
|
index: int
|
||||||
|
message: ChatMessage
|
||||||
|
finish_reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChatUsage(BaseModel):
|
||||||
|
prompt_tokens: int
|
||||||
|
completion_tokens: int
|
||||||
|
total_tokens: int
|
||||||
|
|
||||||
|
|
||||||
|
class ChatbotChatCompletionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
object: str = "chat.completion"
|
||||||
|
created: int
|
||||||
|
model: str
|
||||||
|
choices: List[ChatChoice]
|
||||||
|
usage: ChatUsage
|
||||||
|
|
||||||
|
|
||||||
@router.get("/list")
|
@router.get("/list")
|
||||||
@router.get("/instances")
|
@router.get("/instances")
|
||||||
async def list_chatbots(
|
async def list_chatbots(
|
||||||
@@ -330,6 +369,151 @@ async def chat_with_chatbot(
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to process chat: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to process chat: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{chatbot_id}/chat/completions", response_model=ChatbotChatCompletionResponse)
|
||||||
|
async def chatbot_chat_completions(
|
||||||
|
chatbot_id: str,
|
||||||
|
request: ChatbotChatCompletionRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""OpenAI-compatible chat completions endpoint for chatbot"""
|
||||||
|
user_id = current_user.get("id") if isinstance(current_user, dict) else current_user.id
|
||||||
|
log_api_request("chatbot_chat_completions", {
|
||||||
|
"user_id": user_id,
|
||||||
|
"chatbot_id": chatbot_id,
|
||||||
|
"messages_count": len(request.messages)
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get the chatbot instance
|
||||||
|
result = await db.execute(
|
||||||
|
select(ChatbotInstance)
|
||||||
|
.where(ChatbotInstance.id == chatbot_id)
|
||||||
|
.where(ChatbotInstance.created_by == str(user_id))
|
||||||
|
)
|
||||||
|
chatbot = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not chatbot:
|
||||||
|
raise HTTPException(status_code=404, detail="Chatbot not found")
|
||||||
|
|
||||||
|
if not chatbot.is_active:
|
||||||
|
raise HTTPException(status_code=400, detail="Chatbot is not active")
|
||||||
|
|
||||||
|
# Find the last user message to extract conversation context
|
||||||
|
user_messages = [msg for msg in request.messages if msg.role == "user"]
|
||||||
|
if not user_messages:
|
||||||
|
raise HTTPException(status_code=400, detail="No user message found in conversation")
|
||||||
|
|
||||||
|
last_user_message = user_messages[-1].content
|
||||||
|
|
||||||
|
# Initialize conversation service
|
||||||
|
conversation_service = ConversationService(db)
|
||||||
|
|
||||||
|
# For OpenAI format, we'll try to find an existing conversation or create a new one
|
||||||
|
# We'll use a simple hash of the conversation messages as the conversation identifier
|
||||||
|
import hashlib
|
||||||
|
conv_hash = hashlib.md5(str([f"{msg.role}:{msg.content}" for msg in request.messages]).encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
# Get or create conversation
|
||||||
|
conversation = await conversation_service.get_or_create_conversation(
|
||||||
|
chatbot_id=chatbot_id,
|
||||||
|
user_id=str(user_id),
|
||||||
|
conversation_id=conv_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build conversation history from the request messages (excluding system messages for now)
|
||||||
|
conversation_history = []
|
||||||
|
for msg in request.messages:
|
||||||
|
if msg.role in ["user", "assistant"]:
|
||||||
|
conversation_history.append({
|
||||||
|
"role": msg.role,
|
||||||
|
"content": msg.content
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get chatbot module and generate response
|
||||||
|
try:
|
||||||
|
chatbot_module = module_manager.modules.get("chatbot")
|
||||||
|
if not chatbot_module:
|
||||||
|
raise HTTPException(status_code=500, detail="Chatbot module not available")
|
||||||
|
|
||||||
|
# Merge chatbot config with request parameters
|
||||||
|
effective_config = dict(chatbot.config)
|
||||||
|
if request.temperature is not None:
|
||||||
|
effective_config["temperature"] = request.temperature
|
||||||
|
if request.max_tokens is not None:
|
||||||
|
effective_config["max_tokens"] = request.max_tokens
|
||||||
|
|
||||||
|
# Use the chatbot module to generate a response
|
||||||
|
response_data = await chatbot_module.chat(
|
||||||
|
chatbot_config=effective_config,
|
||||||
|
message=last_user_message,
|
||||||
|
conversation_history=conversation_history,
|
||||||
|
user_id=str(user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
response_content = response_data.get("response", "I'm sorry, I couldn't generate a response.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Use fallback response
|
||||||
|
fallback_responses = chatbot.config.get("fallback_responses", [
|
||||||
|
"I'm sorry, I'm having trouble processing your request right now."
|
||||||
|
])
|
||||||
|
response_content = fallback_responses[0] if fallback_responses else "I'm sorry, I couldn't process your request."
|
||||||
|
|
||||||
|
# Save the conversation messages
|
||||||
|
for msg in request.messages:
|
||||||
|
if msg.role == "user": # Only save the new user message
|
||||||
|
await conversation_service.add_message(
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
role=msg.role,
|
||||||
|
content=msg.content,
|
||||||
|
metadata={}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save assistant message
|
||||||
|
assistant_message = await conversation_service.add_message(
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
role="assistant",
|
||||||
|
content=response_content,
|
||||||
|
metadata={},
|
||||||
|
sources=response_data.get("sources")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate usage (simple approximation)
|
||||||
|
prompt_tokens = sum(len(msg.content.split()) for msg in request.messages)
|
||||||
|
completion_tokens = len(response_content.split())
|
||||||
|
total_tokens = prompt_tokens + completion_tokens
|
||||||
|
|
||||||
|
# Create OpenAI-compatible response
|
||||||
|
response_id = f"chatbot-{chatbot_id}-{int(time.time())}"
|
||||||
|
|
||||||
|
return ChatbotChatCompletionResponse(
|
||||||
|
id=response_id,
|
||||||
|
object="chat.completion",
|
||||||
|
created=int(time.time()),
|
||||||
|
model=chatbot.config.get("model", "unknown"),
|
||||||
|
choices=[
|
||||||
|
ChatChoice(
|
||||||
|
index=0,
|
||||||
|
message=ChatMessage(role="assistant", content=response_content),
|
||||||
|
finish_reason="stop"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
usage=ChatUsage(
|
||||||
|
prompt_tokens=prompt_tokens,
|
||||||
|
completion_tokens=completion_tokens,
|
||||||
|
total_tokens=total_tokens
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
log_api_request("chatbot_chat_completions_error", {"error": str(e), "user_id": user_id})
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to process chat completions: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/conversations/{chatbot_id}")
|
@router.get("/conversations/{chatbot_id}")
|
||||||
async def get_chatbot_conversations(
|
async def get_chatbot_conversations(
|
||||||
chatbot_id: str,
|
chatbot_id: str,
|
||||||
@@ -617,6 +801,164 @@ async def external_chat_with_chatbot(
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to process chat: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to process chat: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/external/{chatbot_id}/chat/completions", response_model=ChatbotChatCompletionResponse)
|
||||||
|
async def external_chatbot_chat_completions(
|
||||||
|
chatbot_id: str,
|
||||||
|
request: ChatbotChatCompletionRequest,
|
||||||
|
api_key: APIKey = Depends(get_api_key_auth),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""External OpenAI-compatible chat completions endpoint for chatbot with API key authentication"""
|
||||||
|
log_api_request("external_chatbot_chat_completions", {
|
||||||
|
"chatbot_id": chatbot_id,
|
||||||
|
"api_key_id": api_key.id,
|
||||||
|
"messages_count": len(request.messages)
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if API key can access this chatbot
|
||||||
|
if not api_key.can_access_chatbot(chatbot_id):
|
||||||
|
raise HTTPException(status_code=403, detail="API key not authorized for this chatbot")
|
||||||
|
|
||||||
|
# Get the chatbot instance
|
||||||
|
result = await db.execute(
|
||||||
|
select(ChatbotInstance)
|
||||||
|
.where(ChatbotInstance.id == chatbot_id)
|
||||||
|
)
|
||||||
|
chatbot = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not chatbot:
|
||||||
|
raise HTTPException(status_code=404, detail="Chatbot not found")
|
||||||
|
|
||||||
|
if not chatbot.is_active:
|
||||||
|
raise HTTPException(status_code=400, detail="Chatbot is not active")
|
||||||
|
|
||||||
|
# Find the last user message to extract conversation context
|
||||||
|
user_messages = [msg for msg in request.messages if msg.role == "user"]
|
||||||
|
if not user_messages:
|
||||||
|
raise HTTPException(status_code=400, detail="No user message found in conversation")
|
||||||
|
|
||||||
|
last_user_message = user_messages[-1].content
|
||||||
|
|
||||||
|
# Initialize conversation service
|
||||||
|
conversation_service = ConversationService(db)
|
||||||
|
|
||||||
|
# For OpenAI format, we'll try to find an existing conversation or create a new one
|
||||||
|
# We'll use a simple hash of the conversation messages as the conversation identifier
|
||||||
|
import hashlib
|
||||||
|
conv_hash = hashlib.md5(str([f"{msg.role}:{msg.content}" for msg in request.messages]).encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
# Get or create conversation with API key context
|
||||||
|
conversation = await conversation_service.get_or_create_conversation(
|
||||||
|
chatbot_id=chatbot_id,
|
||||||
|
user_id=f"api_key_{api_key.id}",
|
||||||
|
conversation_id=conv_hash,
|
||||||
|
title=f"API Chat {datetime.utcnow().strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add API key metadata to conversation context if new
|
||||||
|
if not conversation.context_data.get("api_key_id"):
|
||||||
|
conversation.context_data = {"api_key_id": api_key.id}
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Build conversation history from the request messages
|
||||||
|
conversation_history = []
|
||||||
|
for msg in request.messages:
|
||||||
|
if msg.role in ["user", "assistant"]:
|
||||||
|
conversation_history.append({
|
||||||
|
"role": msg.role,
|
||||||
|
"content": msg.content
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get chatbot module and generate response
|
||||||
|
try:
|
||||||
|
chatbot_module = module_manager.modules.get("chatbot")
|
||||||
|
if not chatbot_module:
|
||||||
|
raise HTTPException(status_code=500, detail="Chatbot module not available")
|
||||||
|
|
||||||
|
# Merge chatbot config with request parameters
|
||||||
|
effective_config = dict(chatbot.config)
|
||||||
|
if request.temperature is not None:
|
||||||
|
effective_config["temperature"] = request.temperature
|
||||||
|
if request.max_tokens is not None:
|
||||||
|
effective_config["max_tokens"] = request.max_tokens
|
||||||
|
|
||||||
|
# Use the chatbot module to generate a response
|
||||||
|
response_data = await chatbot_module.chat(
|
||||||
|
chatbot_config=effective_config,
|
||||||
|
message=last_user_message,
|
||||||
|
conversation_history=conversation_history,
|
||||||
|
user_id=f"api_key_{api_key.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
response_content = response_data.get("response", "I'm sorry, I couldn't generate a response.")
|
||||||
|
sources = response_data.get("sources")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Use fallback response
|
||||||
|
fallback_responses = chatbot.config.get("fallback_responses", [
|
||||||
|
"I'm sorry, I'm having trouble processing your request right now."
|
||||||
|
])
|
||||||
|
response_content = fallback_responses[0] if fallback_responses else "I'm sorry, I couldn't process your request."
|
||||||
|
sources = None
|
||||||
|
|
||||||
|
# Save the conversation messages
|
||||||
|
for msg in request.messages:
|
||||||
|
if msg.role == "user": # Only save the new user message
|
||||||
|
await conversation_service.add_message(
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
role=msg.role,
|
||||||
|
content=msg.content,
|
||||||
|
metadata={"api_key_id": api_key.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save assistant message using conversation service
|
||||||
|
assistant_message = await conversation_service.add_message(
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
role="assistant",
|
||||||
|
content=response_content,
|
||||||
|
metadata={"api_key_id": api_key.id},
|
||||||
|
sources=sources
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update API key usage stats
|
||||||
|
prompt_tokens = sum(len(msg.content.split()) for msg in request.messages)
|
||||||
|
completion_tokens = len(response_content.split())
|
||||||
|
total_tokens = prompt_tokens + completion_tokens
|
||||||
|
|
||||||
|
api_key.update_usage(tokens_used=total_tokens, cost_cents=0)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Create OpenAI-compatible response
|
||||||
|
response_id = f"chatbot-{chatbot_id}-{int(time.time())}"
|
||||||
|
|
||||||
|
return ChatbotChatCompletionResponse(
|
||||||
|
id=response_id,
|
||||||
|
object="chat.completion",
|
||||||
|
created=int(time.time()),
|
||||||
|
model=chatbot.config.get("model", "unknown"),
|
||||||
|
choices=[
|
||||||
|
ChatChoice(
|
||||||
|
index=0,
|
||||||
|
message=ChatMessage(role="assistant", content=response_content),
|
||||||
|
finish_reason="stop"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
usage=ChatUsage(
|
||||||
|
prompt_tokens=prompt_tokens,
|
||||||
|
completion_tokens=completion_tokens,
|
||||||
|
total_tokens=total_tokens
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
log_api_request("external_chatbot_chat_completions_error", {"error": str(e), "chatbot_id": chatbot_id})
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to process chat completions: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{chatbot_id}/api-key")
|
@router.post("/{chatbot_id}/api-key")
|
||||||
async def create_chatbot_api_key(
|
async def create_chatbot_api_key(
|
||||||
chatbot_id: str,
|
chatbot_id: str,
|
||||||
@@ -668,7 +1010,7 @@ async def create_chatbot_api_key(
|
|||||||
"secret_key": full_key, # Only returned on creation
|
"secret_key": full_key, # Only returned on creation
|
||||||
"chatbot_id": chatbot_id,
|
"chatbot_id": chatbot_id,
|
||||||
"chatbot_name": chatbot.name,
|
"chatbot_name": chatbot.name,
|
||||||
"endpoint": f"/api/v1/chatbot/external/{chatbot_id}/chat",
|
"endpoint": f"/api/v1/chatbot/external/{chatbot_id}/chat/completions",
|
||||||
"scopes": new_api_key.scopes,
|
"scopes": new_api_key.scopes,
|
||||||
"rate_limit_per_minute": new_api_key.rate_limit_per_minute,
|
"rate_limit_per_minute": new_api_key.rate_limit_per_minute,
|
||||||
"created_at": new_api_key.created_at.isoformat()
|
"created_at": new_api_key.created_at.isoformat()
|
||||||
|
|||||||
@@ -1,334 +0,0 @@
|
|||||||
"""
|
|
||||||
TEE (Trusted Execution Environment) API endpoints
|
|
||||||
Handles Privatemode.ai TEE integration endpoints
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from app.services.tee_service import tee_service
|
|
||||||
from app.services.api_key_auth import get_current_api_key_user
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.api_key import APIKey
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tee", tags=["tee"])
|
|
||||||
security = HTTPBearer()
|
|
||||||
|
|
||||||
|
|
||||||
class AttestationRequest(BaseModel):
|
|
||||||
"""Request model for attestation"""
|
|
||||||
nonce: Optional[str] = Field(None, description="Optional nonce for attestation")
|
|
||||||
|
|
||||||
|
|
||||||
class AttestationVerificationRequest(BaseModel):
|
|
||||||
"""Request model for attestation verification"""
|
|
||||||
report: str = Field(..., description="Attestation report")
|
|
||||||
signature: str = Field(..., description="Attestation signature")
|
|
||||||
certificate_chain: str = Field(..., description="Certificate chain")
|
|
||||||
nonce: Optional[str] = Field(None, description="Optional nonce")
|
|
||||||
|
|
||||||
|
|
||||||
class SecureSessionRequest(BaseModel):
|
|
||||||
"""Request model for secure session creation"""
|
|
||||||
capabilities: Optional[list] = Field(
|
|
||||||
default=["confidential_inference", "secure_memory", "attestation"],
|
|
||||||
description="Requested TEE capabilities"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
|
||||||
async def get_tee_health():
|
|
||||||
"""
|
|
||||||
Get TEE environment health status
|
|
||||||
|
|
||||||
Returns comprehensive health information about the TEE environment
|
|
||||||
including capabilities, status, and availability.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
health_data = await tee_service.health_check()
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": health_data
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"TEE health check failed: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Failed to get TEE health status"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/capabilities")
|
|
||||||
async def get_tee_capabilities(
|
|
||||||
current_user: tuple = Depends(get_current_api_key_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get TEE environment capabilities
|
|
||||||
|
|
||||||
Returns detailed information about TEE capabilities including
|
|
||||||
supported features, encryption algorithms, and security properties.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
user, api_key = current_user
|
|
||||||
capabilities = await tee_service.get_tee_capabilities()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": capabilities
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get TEE capabilities: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Failed to get TEE capabilities"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/attestation")
|
|
||||||
async def get_attestation(
|
|
||||||
request: AttestationRequest,
|
|
||||||
current_user: tuple = Depends(get_current_api_key_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get TEE attestation report
|
|
||||||
|
|
||||||
Generates a cryptographic attestation report that proves the integrity
|
|
||||||
and authenticity of the TEE environment. The report can be used to
|
|
||||||
verify that code is running in a genuine TEE.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
user, api_key = current_user
|
|
||||||
attestation_data = await tee_service.get_attestation(request.nonce)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": attestation_data
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get attestation: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Failed to get TEE attestation"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/attestation/verify")
|
|
||||||
async def verify_attestation(
|
|
||||||
request: AttestationVerificationRequest,
|
|
||||||
current_user: tuple = Depends(get_current_api_key_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Verify TEE attestation report
|
|
||||||
|
|
||||||
Verifies the authenticity and integrity of a TEE attestation report.
|
|
||||||
This includes validating the certificate chain, signature, and
|
|
||||||
measurements against known good values.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
user, api_key = current_user
|
|
||||||
|
|
||||||
attestation_data = {
|
|
||||||
"report": request.report,
|
|
||||||
"signature": request.signature,
|
|
||||||
"certificate_chain": request.certificate_chain,
|
|
||||||
"nonce": request.nonce
|
|
||||||
}
|
|
||||||
|
|
||||||
verification_result = await tee_service.verify_attestation(attestation_data)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": verification_result
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to verify attestation: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Failed to verify TEE attestation"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/session")
|
|
||||||
async def create_secure_session(
|
|
||||||
request: SecureSessionRequest,
|
|
||||||
current_user: tuple = Depends(get_current_api_key_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Create a secure TEE session
|
|
||||||
|
|
||||||
Creates a secure session within the TEE environment with requested
|
|
||||||
capabilities. The session provides isolated execution context with
|
|
||||||
enhanced security properties.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
user, api_key = current_user
|
|
||||||
|
|
||||||
session_data = await tee_service.create_secure_session(
|
|
||||||
user_id=str(user.id),
|
|
||||||
api_key_id=api_key.id
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": session_data
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create secure session: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Failed to create TEE secure session"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/metrics")
|
|
||||||
async def get_privacy_metrics(
|
|
||||||
current_user: tuple = Depends(get_current_api_key_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get privacy and security metrics
|
|
||||||
|
|
||||||
Returns comprehensive metrics about TEE usage, privacy protection,
|
|
||||||
and security status including request counts, data encrypted,
|
|
||||||
and performance statistics.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
user, api_key = current_user
|
|
||||||
metrics = await tee_service.get_privacy_metrics()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": metrics
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get privacy metrics: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Failed to get privacy metrics"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/models")
|
|
||||||
async def list_tee_models(
|
|
||||||
current_user: tuple = Depends(get_current_api_key_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
List available TEE models
|
|
||||||
|
|
||||||
Returns a list of AI models available through the TEE environment.
|
|
||||||
These models provide confidential inference capabilities with
|
|
||||||
enhanced privacy and security properties.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
user, api_key = current_user
|
|
||||||
models = await tee_service.list_tee_models()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": models,
|
|
||||||
"count": len(models)
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to list TEE models: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Failed to list TEE models"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
|
||||||
async def get_tee_status(
|
|
||||||
current_user: tuple = Depends(get_current_api_key_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get comprehensive TEE status
|
|
||||||
|
|
||||||
Returns combined status information including health, capabilities,
|
|
||||||
and metrics for a complete overview of the TEE environment.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
user, api_key = current_user
|
|
||||||
|
|
||||||
# Get all status information
|
|
||||||
health_data = await tee_service.health_check()
|
|
||||||
capabilities = await tee_service.get_tee_capabilities()
|
|
||||||
metrics = await tee_service.get_privacy_metrics()
|
|
||||||
models = await tee_service.list_tee_models()
|
|
||||||
|
|
||||||
status_data = {
|
|
||||||
"health": health_data,
|
|
||||||
"capabilities": capabilities,
|
|
||||||
"metrics": metrics,
|
|
||||||
"models": {
|
|
||||||
"available": len(models),
|
|
||||||
"list": models
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"tee_enabled": health_data.get("tee_enabled", False),
|
|
||||||
"secure_inference_available": len(models) > 0,
|
|
||||||
"attestation_available": health_data.get("attestation_available", False),
|
|
||||||
"privacy_score": metrics.get("privacy_score", 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": status_data
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get TEE status: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Failed to get TEE status"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/cache")
|
|
||||||
async def clear_attestation_cache(
|
|
||||||
current_user: tuple = Depends(get_current_api_key_user)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Clear attestation cache
|
|
||||||
|
|
||||||
Manually clears the attestation cache to force fresh attestation
|
|
||||||
reports. This can be useful for debugging or when attestation
|
|
||||||
requirements change.
|
|
||||||
|
|
||||||
Requires authentication.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
user, api_key = current_user
|
|
||||||
|
|
||||||
# Clear the cache
|
|
||||||
await tee_service.cleanup_expired_cache()
|
|
||||||
tee_service.attestation_cache.clear()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "Attestation cache cleared successfully"
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to clear attestation cache: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Failed to clear attestation cache"
|
|
||||||
)
|
|
||||||
@@ -299,7 +299,8 @@ class APIKey(Base):
|
|||||||
rate_limit_per_day=144000,
|
rate_limit_per_day=144000,
|
||||||
allowed_models=[], # Will use chatbot's configured model
|
allowed_models=[], # Will use chatbot's configured model
|
||||||
allowed_endpoints=[
|
allowed_endpoints=[
|
||||||
f"/api/v1/chatbot/external/{chatbot_id}/chat"
|
f"/api/v1/chatbot/external/{chatbot_id}/chat",
|
||||||
|
f"/api/v1/chatbot/external/{chatbot_id}/chat/completions"
|
||||||
],
|
],
|
||||||
allowed_ips=[],
|
allowed_ips=[],
|
||||||
allowed_chatbots=[chatbot_id],
|
allowed_chatbots=[chatbot_id],
|
||||||
|
|||||||
@@ -1,363 +0,0 @@
|
|||||||
"""
|
|
||||||
Trusted Execution Environment (TEE) Service
|
|
||||||
Handles Privatemode.ai TEE integration for confidential computing
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from typing import Dict, Any, Optional, List
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
||||||
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
|
||||||
import base64
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TEEStatus(str, Enum):
|
|
||||||
"""TEE environment status"""
|
|
||||||
HEALTHY = "healthy"
|
|
||||||
DEGRADED = "degraded"
|
|
||||||
OFFLINE = "offline"
|
|
||||||
UNKNOWN = "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
class AttestationStatus(str, Enum):
|
|
||||||
"""Attestation verification status"""
|
|
||||||
VERIFIED = "verified"
|
|
||||||
FAILED = "failed"
|
|
||||||
PENDING = "pending"
|
|
||||||
EXPIRED = "expired"
|
|
||||||
|
|
||||||
|
|
||||||
class TEEService:
|
|
||||||
"""Service for managing Privatemode.ai TEE integration"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.privatemode_base_url = "http://privatemode-proxy:8080"
|
|
||||||
self.privatemode_api_key = settings.PRIVATEMODE_API_KEY
|
|
||||||
self.session: Optional[aiohttp.ClientSession] = None
|
|
||||||
self.timeout = aiohttp.ClientTimeout(total=300) # 5 minutes timeout
|
|
||||||
self.attestation_cache = {} # Cache for attestation results
|
|
||||||
self.attestation_ttl = timedelta(hours=1) # Cache TTL
|
|
||||||
|
|
||||||
async def _get_session(self) -> aiohttp.ClientSession:
|
|
||||||
"""Get or create aiohttp session"""
|
|
||||||
if self.session is None or self.session.closed:
|
|
||||||
self.session = aiohttp.ClientSession(
|
|
||||||
timeout=self.timeout,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {self.privatemode_api_key}"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return self.session
|
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
"""Close the HTTP session"""
|
|
||||||
if self.session and not self.session.closed:
|
|
||||||
await self.session.close()
|
|
||||||
|
|
||||||
async def health_check(self) -> Dict[str, Any]:
|
|
||||||
"""Check TEE environment health"""
|
|
||||||
try:
|
|
||||||
session = await self._get_session()
|
|
||||||
async with session.get(f"{self.privatemode_base_url}/health") as response:
|
|
||||||
if response.status == 200:
|
|
||||||
health_data = await response.json()
|
|
||||||
return {
|
|
||||||
"status": TEEStatus.HEALTHY.value,
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"tee_enabled": health_data.get("tee_enabled", False),
|
|
||||||
"attestation_available": health_data.get("attestation_available", False),
|
|
||||||
"secure_memory": health_data.get("secure_memory", False),
|
|
||||||
"details": health_data
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"status": TEEStatus.DEGRADED.value,
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"error": f"HTTP {response.status}"
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"TEE health check error: {e}")
|
|
||||||
return {
|
|
||||||
"status": TEEStatus.OFFLINE.value,
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_attestation(self, nonce: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
"""Get TEE attestation report"""
|
|
||||||
try:
|
|
||||||
if not nonce:
|
|
||||||
nonce = base64.b64encode(os.urandom(32)).decode()
|
|
||||||
|
|
||||||
# Check cache first
|
|
||||||
cache_key = f"attestation_{nonce}"
|
|
||||||
if cache_key in self.attestation_cache:
|
|
||||||
cached_result = self.attestation_cache[cache_key]
|
|
||||||
if datetime.fromisoformat(cached_result["timestamp"]) + self.attestation_ttl > datetime.utcnow():
|
|
||||||
return cached_result
|
|
||||||
|
|
||||||
session = await self._get_session()
|
|
||||||
payload = {"nonce": nonce}
|
|
||||||
|
|
||||||
async with session.post(
|
|
||||||
f"{self.privatemode_base_url}/attestation",
|
|
||||||
json=payload
|
|
||||||
) as response:
|
|
||||||
if response.status == 200:
|
|
||||||
attestation_data = await response.json()
|
|
||||||
|
|
||||||
# Process attestation report
|
|
||||||
result = {
|
|
||||||
"status": AttestationStatus.VERIFIED.value,
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"nonce": nonce,
|
|
||||||
"report": attestation_data.get("report"),
|
|
||||||
"signature": attestation_data.get("signature"),
|
|
||||||
"certificate_chain": attestation_data.get("certificate_chain"),
|
|
||||||
"measurements": attestation_data.get("measurements", {}),
|
|
||||||
"tee_type": attestation_data.get("tee_type", "unknown"),
|
|
||||||
"verified": True
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache the result
|
|
||||||
self.attestation_cache[cache_key] = result
|
|
||||||
|
|
||||||
return result
|
|
||||||
else:
|
|
||||||
error_text = await response.text()
|
|
||||||
logger.error(f"TEE attestation failed: {response.status} - {error_text}")
|
|
||||||
return {
|
|
||||||
"status": AttestationStatus.FAILED.value,
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"nonce": nonce,
|
|
||||||
"error": error_text,
|
|
||||||
"verified": False
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"TEE attestation error: {e}")
|
|
||||||
return {
|
|
||||||
"status": AttestationStatus.FAILED.value,
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"nonce": nonce,
|
|
||||||
"error": str(e),
|
|
||||||
"verified": False
|
|
||||||
}
|
|
||||||
|
|
||||||
async def verify_attestation(self, attestation_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Verify TEE attestation report"""
|
|
||||||
try:
|
|
||||||
# Extract components
|
|
||||||
report = attestation_data.get("report")
|
|
||||||
signature = attestation_data.get("signature")
|
|
||||||
cert_chain = attestation_data.get("certificate_chain")
|
|
||||||
|
|
||||||
if not all([report, signature, cert_chain]):
|
|
||||||
return {
|
|
||||||
"verified": False,
|
|
||||||
"status": AttestationStatus.FAILED.value,
|
|
||||||
"error": "Missing required attestation components"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Verify signature (simplified - in production, use proper certificate validation)
|
|
||||||
try:
|
|
||||||
# This is a placeholder for actual attestation verification
|
|
||||||
# In production, you would:
|
|
||||||
# 1. Validate the certificate chain
|
|
||||||
# 2. Verify the signature using the public key
|
|
||||||
# 3. Check measurements against known good values
|
|
||||||
# 4. Validate the nonce
|
|
||||||
|
|
||||||
verification_result = {
|
|
||||||
"verified": True,
|
|
||||||
"status": AttestationStatus.VERIFIED.value,
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"certificate_valid": True,
|
|
||||||
"signature_valid": True,
|
|
||||||
"measurements_valid": True,
|
|
||||||
"nonce_valid": True
|
|
||||||
}
|
|
||||||
|
|
||||||
return verification_result
|
|
||||||
|
|
||||||
except Exception as verify_error:
|
|
||||||
logger.error(f"Attestation verification failed: {verify_error}")
|
|
||||||
return {
|
|
||||||
"verified": False,
|
|
||||||
"status": AttestationStatus.FAILED.value,
|
|
||||||
"error": str(verify_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Attestation verification error: {e}")
|
|
||||||
return {
|
|
||||||
"verified": False,
|
|
||||||
"status": AttestationStatus.FAILED.value,
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_tee_capabilities(self) -> Dict[str, Any]:
|
|
||||||
"""Get TEE environment capabilities"""
|
|
||||||
try:
|
|
||||||
session = await self._get_session()
|
|
||||||
async with session.get(f"{self.privatemode_base_url}/capabilities") as response:
|
|
||||||
if response.status == 200:
|
|
||||||
capabilities = await response.json()
|
|
||||||
return {
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"tee_type": capabilities.get("tee_type", "unknown"),
|
|
||||||
"secure_memory_size": capabilities.get("secure_memory_size", 0),
|
|
||||||
"encryption_algorithms": capabilities.get("encryption_algorithms", []),
|
|
||||||
"attestation_types": capabilities.get("attestation_types", []),
|
|
||||||
"key_management": capabilities.get("key_management", False),
|
|
||||||
"secure_storage": capabilities.get("secure_storage", False),
|
|
||||||
"network_isolation": capabilities.get("network_isolation", False),
|
|
||||||
"confidential_computing": capabilities.get("confidential_computing", False),
|
|
||||||
"details": capabilities
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"error": f"Failed to get capabilities: HTTP {response.status}"
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"TEE capabilities error: {e}")
|
|
||||||
return {
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
async def create_secure_session(self, user_id: str, api_key_id: int) -> Dict[str, Any]:
|
|
||||||
"""Create a secure TEE session"""
|
|
||||||
try:
|
|
||||||
session = await self._get_session()
|
|
||||||
payload = {
|
|
||||||
"user_id": user_id,
|
|
||||||
"api_key_id": api_key_id,
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"requested_capabilities": [
|
|
||||||
"confidential_inference",
|
|
||||||
"secure_memory",
|
|
||||||
"attestation"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
async with session.post(
|
|
||||||
f"{self.privatemode_base_url}/session",
|
|
||||||
json=payload
|
|
||||||
) as response:
|
|
||||||
if response.status == 201:
|
|
||||||
session_data = await response.json()
|
|
||||||
return {
|
|
||||||
"session_id": session_data.get("session_id"),
|
|
||||||
"status": "active",
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"capabilities": session_data.get("capabilities", []),
|
|
||||||
"expires_at": session_data.get("expires_at"),
|
|
||||||
"attestation_token": session_data.get("attestation_token")
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
error_text = await response.text()
|
|
||||||
logger.error(f"TEE session creation failed: {response.status} - {error_text}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=response.status,
|
|
||||||
detail=f"Failed to create TEE session: {error_text}"
|
|
||||||
)
|
|
||||||
except aiohttp.ClientError as e:
|
|
||||||
logger.error(f"TEE session creation error: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="TEE service unavailable"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_privacy_metrics(self) -> Dict[str, Any]:
|
|
||||||
"""Get privacy and security metrics"""
|
|
||||||
try:
|
|
||||||
session = await self._get_session()
|
|
||||||
async with session.get(f"{self.privatemode_base_url}/metrics") as response:
|
|
||||||
if response.status == 200:
|
|
||||||
metrics = await response.json()
|
|
||||||
return {
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"requests_processed": metrics.get("requests_processed", 0),
|
|
||||||
"data_encrypted": metrics.get("data_encrypted", 0),
|
|
||||||
"attestations_verified": metrics.get("attestations_verified", 0),
|
|
||||||
"secure_sessions": metrics.get("secure_sessions", 0),
|
|
||||||
"uptime": metrics.get("uptime", 0),
|
|
||||||
"memory_usage": metrics.get("memory_usage", {}),
|
|
||||||
"performance": metrics.get("performance", {}),
|
|
||||||
"privacy_score": metrics.get("privacy_score", 0)
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"error": f"Failed to get metrics: HTTP {response.status}"
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"TEE metrics error: {e}")
|
|
||||||
return {
|
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
async def list_tee_models(self) -> List[Dict[str, Any]]:
|
|
||||||
"""List available TEE models"""
|
|
||||||
try:
|
|
||||||
session = await self._get_session()
|
|
||||||
async with session.get(f"{self.privatemode_base_url}/models") as response:
|
|
||||||
if response.status == 200:
|
|
||||||
models_data = await response.json()
|
|
||||||
models = []
|
|
||||||
|
|
||||||
for model in models_data.get("models", []):
|
|
||||||
models.append({
|
|
||||||
"id": model.get("id"),
|
|
||||||
"name": model.get("name"),
|
|
||||||
"type": model.get("type", "chat"),
|
|
||||||
"provider": "privatemode",
|
|
||||||
"tee_enabled": True,
|
|
||||||
"confidential_computing": True,
|
|
||||||
"secure_inference": True,
|
|
||||||
"attestation_required": model.get("attestation_required", False),
|
|
||||||
"max_tokens": model.get("max_tokens", 4096),
|
|
||||||
"cost_per_token": model.get("cost_per_token", 0.0),
|
|
||||||
"availability": model.get("availability", "available")
|
|
||||||
})
|
|
||||||
|
|
||||||
return models
|
|
||||||
else:
|
|
||||||
logger.error(f"Failed to get TEE models: HTTP {response.status}")
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"TEE models error: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def cleanup_expired_cache(self):
|
|
||||||
"""Clean up expired attestation cache entries"""
|
|
||||||
current_time = datetime.utcnow()
|
|
||||||
expired_keys = []
|
|
||||||
|
|
||||||
for key, cached_data in self.attestation_cache.items():
|
|
||||||
if datetime.fromisoformat(cached_data["timestamp"]) + self.attestation_ttl <= current_time:
|
|
||||||
expired_keys.append(key)
|
|
||||||
|
|
||||||
for key in expired_keys:
|
|
||||||
del self.attestation_cache[key]
|
|
||||||
|
|
||||||
logger.info(f"Cleaned up {len(expired_keys)} expired attestation cache entries")
|
|
||||||
|
|
||||||
|
|
||||||
# Global TEE service instance
|
|
||||||
tee_service = TEEService()
|
|
||||||
@@ -70,6 +70,8 @@ function LLMPageContent() {
|
|||||||
const [models, setModels] = useState<Model[]>([])
|
const [models, setModels] = useState<Model[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||||
|
const [showEditDialog, setShowEditDialog] = useState(false)
|
||||||
|
const [editingKey, setEditingKey] = useState<APIKey | null>(null)
|
||||||
const [showSecretKeyDialog, setShowSecretKeyDialog] = useState(false)
|
const [showSecretKeyDialog, setShowSecretKeyDialog] = useState(false)
|
||||||
const [newSecretKey, setNewSecretKey] = useState('')
|
const [newSecretKey, setNewSecretKey] = useState('')
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
@@ -82,6 +84,13 @@ function LLMPageContent() {
|
|||||||
description: ''
|
description: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Edit API Key form state
|
||||||
|
const [editKey, setEditKey] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
is_active: true
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@@ -159,6 +168,39 @@ function LLMPageContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openEditDialog = (apiKey: APIKey) => {
|
||||||
|
setEditingKey(apiKey)
|
||||||
|
setEditKey({
|
||||||
|
name: apiKey.name,
|
||||||
|
description: apiKey.description || '',
|
||||||
|
is_active: apiKey.is_active
|
||||||
|
})
|
||||||
|
setShowEditDialog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAPIKey = async () => {
|
||||||
|
if (!editingKey) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/api-internal/v1/api-keys/${editingKey.id}`, editKey)
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "API key updated successfully"
|
||||||
|
})
|
||||||
|
|
||||||
|
setShowEditDialog(false)
|
||||||
|
setEditingKey(null)
|
||||||
|
fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to update API key",
|
||||||
|
variant: "destructive"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const deleteAPIKey = async (keyId: number) => {
|
const deleteAPIKey = async (keyId: number) => {
|
||||||
try {
|
try {
|
||||||
console.log('Deleting API key with ID:', keyId)
|
console.log('Deleting API key with ID:', keyId)
|
||||||
@@ -219,11 +261,11 @@ function LLMPageContent() {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const protocol = window.location.protocol
|
const protocol = window.location.protocol
|
||||||
const hostname = window.location.hostname
|
const hostname = window.location.hostname
|
||||||
const port = window.location.hostname === 'localhost' ? '3000' : window.location.port || (protocol === 'https:' ? '443' : '80')
|
const port = window.location.port || (protocol === 'https:' ? '443' : '80')
|
||||||
const portSuffix = (protocol === 'https:' && port === '443') || (protocol === 'http:' && port === '80') ? '' : `:${port}`
|
const portSuffix = (protocol === 'https:' && port === '443') || (protocol === 'http:' && port === '80') ? '' : `:${port}`
|
||||||
return `${protocol}//${hostname}${portSuffix}/v1`
|
return `${protocol}//${hostname}${portSuffix}/api/v1`
|
||||||
}
|
}
|
||||||
return 'http://localhost:3000/v1'
|
return 'http://localhost/api/v1'
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicApiUrl = getPublicApiUrl()
|
const publicApiUrl = getPublicApiUrl()
|
||||||
@@ -464,7 +506,7 @@ function LLMPageContent() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm" onClick={() => openEditDialog(apiKey)}>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
@@ -536,6 +578,71 @@ function LLMPageContent() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Edit API Key Dialog */}
|
||||||
|
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit API Key</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update the name, description, and status of your API key.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-name"
|
||||||
|
value={editKey.name}
|
||||||
|
onChange={(e) => setEditKey(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="e.g., Frontend Application"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="edit-description"
|
||||||
|
value={editKey.description}
|
||||||
|
onChange={(e) => setEditKey(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
placeholder="Brief description of what this key is for"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="edit-active"
|
||||||
|
checked={editKey.is_active}
|
||||||
|
onChange={(e) => setEditKey(prev => ({ ...prev, is_active: e.target.checked }))}
|
||||||
|
className="h-4 w-4 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="edit-active" className="text-sm font-medium">
|
||||||
|
Active (uncheck to disable this API key)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-blue-700">
|
||||||
|
<span className="font-medium">Note:</span> You cannot change the model restrictions or expiration date after creation.
|
||||||
|
Create a new API key if you need different settings.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button variant="outline" onClick={() => setShowEditDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={updateAPIKey} disabled={!editKey.name.trim()}>
|
||||||
|
Update API Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Secret Key Display Dialog */}
|
{/* Secret Key Display Dialog */}
|
||||||
<Dialog open={showSecretKeyDialog} onOpenChange={() => {}}>
|
<Dialog open={showSecretKeyDialog} onOpenChange={() => {}}>
|
||||||
<DialogContent className="max-w-2xl" onPointerDownOutside={(e) => e.preventDefault()}>
|
<DialogContent className="max-w-2xl" onPointerDownOutside={(e) => e.preventDefault()}>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|||||||
import { Zap } from 'lucide-react'
|
import { Zap } from 'lucide-react'
|
||||||
import ChatPlayground from '@/components/playground/ChatPlayground'
|
import ChatPlayground from '@/components/playground/ChatPlayground'
|
||||||
import EmbeddingPlayground from '@/components/playground/EmbeddingPlayground'
|
import EmbeddingPlayground from '@/components/playground/EmbeddingPlayground'
|
||||||
import TEEMonitor from '@/components/playground/TEEMonitor'
|
|
||||||
import ModelSelector from '@/components/playground/ModelSelector'
|
import ModelSelector from '@/components/playground/ModelSelector'
|
||||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute'
|
import { ProtectedRoute } from '@/components/auth/ProtectedRoute'
|
||||||
|
|
||||||
@@ -54,10 +53,9 @@ function PlaygroundContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="chat">Chat Completions</TabsTrigger>
|
<TabsTrigger value="chat">Chat Completions</TabsTrigger>
|
||||||
<TabsTrigger value="embeddings">Embeddings</TabsTrigger>
|
<TabsTrigger value="embeddings">Embeddings</TabsTrigger>
|
||||||
<TabsTrigger value="tee">TEE Monitor</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="chat" className="mt-6">
|
<TabsContent value="chat" className="mt-6">
|
||||||
@@ -67,10 +65,6 @@ function PlaygroundContent() {
|
|||||||
<TabsContent value="embeddings" className="mt-6">
|
<TabsContent value="embeddings" className="mt-6">
|
||||||
<EmbeddingPlayground />
|
<EmbeddingPlayground />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="tee" className="mt-6">
|
|
||||||
<TEEMonitor />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
import ConfidentialityDashboard from "@/components/settings/ConfidentialityDashboard";
|
||||||
|
|
||||||
interface SystemSettings {
|
interface SystemSettings {
|
||||||
// Security Settings
|
// Security Settings
|
||||||
@@ -261,10 +262,11 @@ function SettingsPageContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Tabs defaultValue="security" className="space-y-6">
|
<Tabs defaultValue="security" className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
<TabsTrigger value="security">Security</TabsTrigger>
|
<TabsTrigger value="security">Security</TabsTrigger>
|
||||||
<TabsTrigger value="api">API</TabsTrigger>
|
<TabsTrigger value="api">API</TabsTrigger>
|
||||||
<TabsTrigger value="notifications">Notifications</TabsTrigger>
|
<TabsTrigger value="notifications">Notifications</TabsTrigger>
|
||||||
|
<TabsTrigger value="confidentiality">Confidentiality</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="security" className="space-y-6">
|
<TabsContent value="security" className="space-y-6">
|
||||||
@@ -848,6 +850,11 @@ function SettingsPageContent() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
||||||
|
<TabsContent value="confidentiality" className="space-y-6">
|
||||||
|
<ConfidentialityDashboard />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,10 +70,17 @@ export function ChatInterface({ chatbotId, chatbotName, onClose }: ChatInterface
|
|||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Build conversation history in OpenAI format
|
||||||
|
const conversationHistory = messages.map(msg => ({
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content
|
||||||
|
}))
|
||||||
|
|
||||||
const data = await chatbotApi.sendMessage(
|
const data = await chatbotApi.sendMessage(
|
||||||
chatbotId,
|
chatbotId,
|
||||||
messageToSend,
|
messageToSend,
|
||||||
conversationId || undefined
|
conversationId || undefined,
|
||||||
|
conversationHistory
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update conversation ID if it's a new conversation
|
// Update conversation ID if it's a new conversation
|
||||||
@@ -106,7 +113,7 @@ export function ChatInterface({ chatbotId, chatbotName, onClose }: ChatInterface
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [input, isLoading, chatbotId, conversationId, toast])
|
}, [input, isLoading, chatbotId, conversationId, messages, toast])
|
||||||
|
|
||||||
const handleKeyPress = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyPress = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
|||||||
@@ -1108,11 +1108,11 @@ export function ChatbotManager() {
|
|||||||
<div className="bg-muted/50 p-4 rounded-lg">
|
<div className="bg-muted/50 p-4 rounded-lg">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Globe className="h-4 w-4" />
|
<Globe className="h-4 w-4" />
|
||||||
<span className="font-medium">API Endpoint</span>
|
<span className="font-medium">API Endpoint (Direct HTTP/curl)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-background p-3 rounded border overflow-x-auto">
|
<div className="bg-background p-3 rounded border overflow-x-auto">
|
||||||
<code className="text-sm whitespace-nowrap">
|
<code className="text-sm whitespace-nowrap">
|
||||||
POST {config.getPublicApiUrl()}/chatbot/external/{apiKeyChatbot?.id}/chat
|
POST {config.getPublicApiUrl()}/chatbot/external/{apiKeyChatbot?.id}/chat/completions
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
@@ -1245,20 +1245,31 @@ export function ChatbotManager() {
|
|||||||
{/* Usage Example */}
|
{/* Usage Example */}
|
||||||
<div className="bg-muted/50 p-4 rounded-lg">
|
<div className="bg-muted/50 p-4 rounded-lg">
|
||||||
<h4 className="font-medium mb-2">Usage Example</h4>
|
<h4 className="font-medium mb-2">Usage Example</h4>
|
||||||
|
|
||||||
<div className="bg-background p-4 rounded border overflow-x-auto">
|
<div className="bg-background p-4 rounded border overflow-x-auto">
|
||||||
<pre className="text-sm whitespace-pre-wrap break-all">
|
<pre className="text-sm whitespace-pre-wrap break-all">
|
||||||
{`curl -X POST "${config.getPublicApiUrl()}/chatbot/external/${apiKeyChatbot?.id}/chat" \\
|
{`curl -X POST "${config.getPublicApiUrl()}/chatbot/external/${apiKeyChatbot?.id}/chat/completions" \\
|
||||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/json" \\
|
||||||
-d '{
|
-d '{
|
||||||
"message": "Hello, how can you help me?",
|
"messages": [
|
||||||
"conversation_id": null
|
{"role": "user", "content": "Hello, how can you help me?"}
|
||||||
|
],
|
||||||
|
"max_tokens": 1000,
|
||||||
|
"temperature": 0.7
|
||||||
}'`}
|
}'`}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded">
|
|
||||||
<p className="text-sm text-yellow-800">
|
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded">
|
||||||
<strong>📌 Important:</strong> Use the unified API endpoint <code className="bg-yellow-100 px-1 rounded">{config.getAppUrl()}</code> which routes to the appropriate backend service via nginx
|
<p className="text-sm text-blue-800">
|
||||||
|
<strong>💡 OpenAI Library Usage:</strong> For OpenAI Python/JavaScript libraries, use base_url without /chat/completions:
|
||||||
|
</p>
|
||||||
|
<code className="block mt-1 text-xs bg-blue-100 p-2 rounded">
|
||||||
|
base_url="{config.getPublicApiUrl()}/chatbot/external/{apiKeyChatbot?.id}"
|
||||||
|
</code>
|
||||||
|
<p className="text-xs text-blue-700 mt-1">
|
||||||
|
The OpenAI client automatically appends /chat/completions to the base_url
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,515 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { Shield, Lock, Eye, RefreshCw, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
|
|
||||||
import { apiClient } from '@/lib/api-client';
|
|
||||||
|
|
||||||
interface TEEStatus {
|
|
||||||
health: {
|
|
||||||
tee_enabled: boolean;
|
|
||||||
attestation_available: boolean;
|
|
||||||
secure_execution: boolean;
|
|
||||||
memory_protection: boolean;
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
capabilities: {
|
|
||||||
supported_features: string[];
|
|
||||||
encryption_algorithms: string[];
|
|
||||||
secure_memory_size: number;
|
|
||||||
max_concurrent_sessions: number;
|
|
||||||
};
|
|
||||||
metrics: {
|
|
||||||
total_requests: number;
|
|
||||||
secure_requests: number;
|
|
||||||
attestations_generated: number;
|
|
||||||
privacy_score: number;
|
|
||||||
data_encrypted_mb: number;
|
|
||||||
active_sessions: number;
|
|
||||||
avg_response_time_ms: number;
|
|
||||||
};
|
|
||||||
models: {
|
|
||||||
available: number;
|
|
||||||
list: Array<{
|
|
||||||
name: string;
|
|
||||||
provider: string;
|
|
||||||
privacy_level: string;
|
|
||||||
attestation_required: boolean;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
summary: {
|
|
||||||
tee_enabled: boolean;
|
|
||||||
secure_inference_available: boolean;
|
|
||||||
attestation_available: boolean;
|
|
||||||
privacy_score: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AttestationData {
|
|
||||||
report: string;
|
|
||||||
signature: string;
|
|
||||||
certificate_chain: string;
|
|
||||||
measurements: Record<string, string>;
|
|
||||||
timestamp: string;
|
|
||||||
validity_period: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SecureSession {
|
|
||||||
session_id: string;
|
|
||||||
user_id: string;
|
|
||||||
capabilities: string[];
|
|
||||||
created_at: string;
|
|
||||||
expires_at: string;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TEEMonitor() {
|
|
||||||
const [teeStatus, setTeeStatus] = useState<TEEStatus | null>(null);
|
|
||||||
const [attestationData, setAttestationData] = useState<AttestationData | null>(null);
|
|
||||||
const [secureSession, setSecureSession] = useState<SecureSession | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
|
|
||||||
const fetchTEEStatus = async () => {
|
|
||||||
try {
|
|
||||||
const data = await apiClient.get('/api-internal/v1/tee/status');
|
|
||||||
if (data.success) {
|
|
||||||
setTeeStatus(data.data);
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to fetch TEE status');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching TEE status:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateAttestation = async () => {
|
|
||||||
try {
|
|
||||||
const data = await apiClient.post('/api-internal/v1/tee/attestation', {
|
|
||||||
nonce: Date.now().toString()
|
|
||||||
});
|
|
||||||
if (data.success) {
|
|
||||||
setAttestationData(data.data);
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to generate attestation');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error generating attestation:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createSecureSession = async () => {
|
|
||||||
try {
|
|
||||||
const data = await apiClient.post('/api-internal/v1/tee/session', {
|
|
||||||
capabilities: ['confidential_inference', 'secure_memory', 'attestation']
|
|
||||||
});
|
|
||||||
if (data.success) {
|
|
||||||
setSecureSession(data.data);
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to create secure session');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error creating secure session:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshData = async () => {
|
|
||||||
setRefreshing(true);
|
|
||||||
await fetchTEEStatus();
|
|
||||||
setRefreshing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await fetchTEEStatus();
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadData();
|
|
||||||
|
|
||||||
// Auto-refresh every 30 seconds
|
|
||||||
const interval = setInterval(refreshData, 30000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getStatusColor = (status: boolean) => {
|
|
||||||
return status ? 'bg-green-500' : 'bg-red-500';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusIcon = (status: boolean) => {
|
|
||||||
return status ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RefreshCw className="w-6 h-6 animate-spin" />
|
|
||||||
<span>Loading TEE status...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Alert className="border-red-200 bg-red-50">
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
Error loading TEE status: {error}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={refreshData}
|
|
||||||
className="ml-2"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!teeStatus) {
|
|
||||||
return (
|
|
||||||
<Alert>
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
No TEE status data available
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Shield className="w-6 h-6 text-blue-600" />
|
|
||||||
<h2 className="text-2xl font-bold">TEE Monitor</h2>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={refreshData}
|
|
||||||
disabled={refreshing}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{refreshing ? (
|
|
||||||
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-medium">TEE Status</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${getStatusColor(teeStatus.summary.tee_enabled)}`} />
|
|
||||||
<span className="text-2xl font-bold">
|
|
||||||
{teeStatus.summary.tee_enabled ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-medium">Privacy Score</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-2xl font-bold">{teeStatus.summary.privacy_score}%</div>
|
|
||||||
<Progress
|
|
||||||
value={teeStatus.summary.privacy_score}
|
|
||||||
className="h-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-medium">Secure Models</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{teeStatus.models.available}</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Available</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-medium">Active Sessions</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{teeStatus.metrics.active_sessions}</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Secure sessions</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Detailed Information */}
|
|
||||||
<Tabs defaultValue="status" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-4">
|
|
||||||
<TabsTrigger value="status">Status</TabsTrigger>
|
|
||||||
<TabsTrigger value="attestation">Attestation</TabsTrigger>
|
|
||||||
<TabsTrigger value="session">Session</TabsTrigger>
|
|
||||||
<TabsTrigger value="models">Models</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="status" className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center">
|
|
||||||
<Shield className="w-5 h-5 mr-2" />
|
|
||||||
Health Status
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>TEE Enabled</span>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{getStatusIcon(teeStatus.health.tee_enabled)}
|
|
||||||
<Badge variant={teeStatus.health.tee_enabled ? "default" : "destructive"}>
|
|
||||||
{teeStatus.health.tee_enabled ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Attestation Available</span>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{getStatusIcon(teeStatus.health.attestation_available)}
|
|
||||||
<Badge variant={teeStatus.health.attestation_available ? "default" : "destructive"}>
|
|
||||||
{teeStatus.health.attestation_available ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Secure Execution</span>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{getStatusIcon(teeStatus.health.secure_execution)}
|
|
||||||
<Badge variant={teeStatus.health.secure_execution ? "default" : "destructive"}>
|
|
||||||
{teeStatus.health.secure_execution ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Memory Protection</span>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{getStatusIcon(teeStatus.health.memory_protection)}
|
|
||||||
<Badge variant={teeStatus.health.memory_protection ? "default" : "destructive"}>
|
|
||||||
{teeStatus.health.memory_protection ? "Yes" : "No"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center">
|
|
||||||
<Lock className="w-5 h-5 mr-2" />
|
|
||||||
Privacy Metrics
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Total Requests</span>
|
|
||||||
<span className="font-mono">{teeStatus.metrics.total_requests.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Secure Requests</span>
|
|
||||||
<span className="font-mono">{teeStatus.metrics.secure_requests.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Attestations Generated</span>
|
|
||||||
<span className="font-mono">{teeStatus.metrics.attestations_generated.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Data Encrypted</span>
|
|
||||||
<span className="font-mono">{teeStatus.metrics.data_encrypted_mb.toFixed(2)} MB</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>Avg Response Time</span>
|
|
||||||
<span className="font-mono">{teeStatus.metrics.avg_response_time_ms}ms</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="attestation" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center">
|
|
||||||
<Eye className="w-5 h-5 mr-2" />
|
|
||||||
TEE Attestation
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Generate and verify cryptographic attestation reports
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button onClick={generateAttestation} className="flex-1">
|
|
||||||
Generate Attestation
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{attestationData && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Separator />
|
|
||||||
<div className="grid grid-cols-1 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">Report ID</label>
|
|
||||||
<div className="mt-1 p-2 bg-gray-50 rounded font-mono text-sm break-all">
|
|
||||||
{attestationData.report.substring(0, 64)}...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">Signature</label>
|
|
||||||
<div className="mt-1 p-2 bg-gray-50 rounded font-mono text-sm break-all">
|
|
||||||
{attestationData.signature.substring(0, 64)}...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">Timestamp</label>
|
|
||||||
<div className="mt-1 p-2 bg-gray-50 rounded font-mono text-sm">
|
|
||||||
{new Date(attestationData.timestamp).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">Validity Period</label>
|
|
||||||
<div className="mt-1 p-2 bg-gray-50 rounded font-mono text-sm">
|
|
||||||
{attestationData.validity_period} seconds
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="session" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Secure Session Management</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Create and manage secure TEE sessions
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button onClick={createSecureSession} className="flex-1">
|
|
||||||
Create Secure Session
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{secureSession && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Separator />
|
|
||||||
<div className="grid grid-cols-1 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">Session ID</label>
|
|
||||||
<div className="mt-1 p-2 bg-gray-50 rounded font-mono text-sm">
|
|
||||||
{secureSession.session_id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">Status</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
<Badge variant={secureSession.status === 'active' ? 'default' : 'secondary'}>
|
|
||||||
{secureSession.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">Capabilities</label>
|
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
|
||||||
{secureSession.capabilities.map((cap) => (
|
|
||||||
<Badge key={cap} variant="outline" className="text-xs">
|
|
||||||
{cap}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">Created</label>
|
|
||||||
<div className="mt-1 p-2 bg-gray-50 rounded font-mono text-sm">
|
|
||||||
{new Date(secureSession.created_at).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium">Expires</label>
|
|
||||||
<div className="mt-1 p-2 bg-gray-50 rounded font-mono text-sm">
|
|
||||||
{new Date(secureSession.expires_at).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="models" className="space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Available TEE Models</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
AI models with confidential computing capabilities
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ScrollArea className="h-80">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{teeStatus.models.list.map((model, index) => (
|
|
||||||
<div key={index} className="p-3 border rounded-lg">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h4 className="font-medium">{model.name}</h4>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Badge variant="secondary">{model.provider}</Badge>
|
|
||||||
<Badge variant={model.privacy_level === 'high' ? 'default' : 'outline'}>
|
|
||||||
{model.privacy_level}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
|
||||||
<span>Attestation Required:</span>
|
|
||||||
<Badge variant={model.attestation_required ? 'default' : 'secondary'}>
|
|
||||||
{model.attestation_required ? 'Yes' : 'No'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user