mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
feat(guardrail_endpoints/): working BE
This commit is contained in:
parent
a4d9c44191
commit
e593dac922
12 changed files with 997 additions and 445 deletions
|
|
@ -1,6 +0,0 @@
|
|||
-- DropIndex
|
||||
DROP INDEX "LiteLLM_GuardrailsTable_guardrail_name_team_id_key";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LiteLLM_GuardrailsTable_guardrail_name_key" ON "LiteLLM_GuardrailsTable"("guardrail_name");
|
||||
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_GuardrailsTable" ADD COLUMN "reviewed_at" TIMESTAMP(3),
|
||||
ADD COLUMN "status" TEXT NOT NULL DEFAULT 'active',
|
||||
ADD COLUMN "submitted_at" TIMESTAMP(3),
|
||||
ADD COLUMN "submitted_by_email" TEXT,
|
||||
ADD COLUMN "submitted_by_user_id" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LiteLLM_GuardrailsTable_status_idx" ON "LiteLLM_GuardrailsTable"("status");
|
||||
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- This is an empty migration.
|
||||
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
-- AlterTable: add submission lifecycle columns to LiteLLM_GuardrailsTable
|
||||
-- status: pending_review (team-registered), active (approved), rejected. Default active for existing rows.
|
||||
ALTER TABLE "LiteLLM_GuardrailsTable" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'active';
|
||||
ALTER TABLE "LiteLLM_GuardrailsTable" ADD COLUMN "submitted_by_user_id" TEXT;
|
||||
ALTER TABLE "LiteLLM_GuardrailsTable" ADD COLUMN "submitted_by_email" TEXT;
|
||||
ALTER TABLE "LiteLLM_GuardrailsTable" ADD COLUMN "submitted_at" TIMESTAMP(3);
|
||||
ALTER TABLE "LiteLLM_GuardrailsTable" ADD COLUMN "reviewed_at" TIMESTAMP(3);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LiteLLM_GuardrailsTable_status_idx" ON "LiteLLM_GuardrailsTable"("status");
|
||||
|
|
@ -4,6 +4,7 @@ CRUD ENDPOINTS FOR GUARDRAILS
|
|||
|
||||
import concurrent.futures
|
||||
import inspect
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Type, TypeVar, Union, cast
|
||||
|
||||
|
|
@ -524,6 +525,387 @@ async def delete_guardrail(
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# --- Team guardrail registration (Generic Guardrail API spec) ---
|
||||
|
||||
GENERIC_GUARDRAIL_API = "generic_guardrail_api"
|
||||
|
||||
|
||||
class RegisterGuardrailRequest(BaseModel):
|
||||
"""Request body for POST /guardrails/register. Follows Generic Guardrail API config."""
|
||||
|
||||
guardrail_name: str
|
||||
litellm_params: Dict[str, Any] # guardrail, mode, api_base required; api_key, headers, etc. optional
|
||||
guardrail_info: Optional[Dict[str, Any]] = None
|
||||
|
||||
def get_litellm_params_dict(self) -> Dict[str, Any]:
|
||||
return dict(self.litellm_params)
|
||||
|
||||
|
||||
class RegisterGuardrailResponse(BaseModel):
|
||||
guardrail_id: str
|
||||
guardrail_name: str
|
||||
status: str
|
||||
submitted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class GuardrailSubmissionSummary(BaseModel):
|
||||
total: int
|
||||
pending_review: int
|
||||
active: int
|
||||
rejected: int
|
||||
|
||||
|
||||
class GuardrailSubmissionItem(BaseModel):
|
||||
guardrail_id: str
|
||||
guardrail_name: str
|
||||
status: str
|
||||
team_id: Optional[str] = None
|
||||
litellm_params: Optional[Dict[str, Any]] = None
|
||||
guardrail_info: Optional[Dict[str, Any]] = None
|
||||
submitted_by_user_id: Optional[str] = None
|
||||
submitted_by_email: Optional[str] = None
|
||||
submitted_at: Optional[datetime] = None
|
||||
reviewed_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ListGuardrailSubmissionsResponse(BaseModel):
|
||||
submissions: List[GuardrailSubmissionItem]
|
||||
summary: GuardrailSubmissionSummary
|
||||
|
||||
|
||||
@router.post(
|
||||
"/guardrails/register",
|
||||
tags=["Guardrails"],
|
||||
response_model=RegisterGuardrailResponse,
|
||||
)
|
||||
async def register_guardrail(
|
||||
request: RegisterGuardrailRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Register a guardrail for onboarding (team submission).
|
||||
|
||||
Accepts a guardrail config in the
|
||||
[Generic Guardrail API](https://docs.litellm.ai/docs/adding_provider/generic_guardrail_api) format.
|
||||
The submission is stored with status `pending_review` until an admin approves it.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail="Prisma client not initialized")
|
||||
|
||||
if not user_api_key_dict.team_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Registration requires an API key associated with a team. Use a team-scoped key.",
|
||||
)
|
||||
|
||||
params = request.get_litellm_params_dict()
|
||||
if params.get("guardrail") != GENERIC_GUARDRAIL_API:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Only guardrails with litellm_params.guardrail={GENERIC_GUARDRAIL_API!r} are accepted for registration",
|
||||
)
|
||||
if not params.get("api_base"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="litellm_params.api_base is required for generic_guardrail_api",
|
||||
)
|
||||
mode = params.get("mode")
|
||||
if mode is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="litellm_params.mode is required (e.g. pre_call, post_call)",
|
||||
)
|
||||
|
||||
try:
|
||||
existing = await prisma_client.db.litellm_guardrailstable.find_unique(
|
||||
where={"guardrail_name": request.guardrail_name}
|
||||
)
|
||||
if existing is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Guardrail with name {request.guardrail_name!r} already exists",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Error checking guardrail name uniqueness: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
litellm_params_str = safe_dumps(params)
|
||||
guardrail_info_str = safe_dumps(request.guardrail_info or {})
|
||||
|
||||
try:
|
||||
created = await prisma_client.db.litellm_guardrailstable.create(
|
||||
data={
|
||||
"guardrail_name": request.guardrail_name,
|
||||
"litellm_params": litellm_params_str,
|
||||
"guardrail_info": guardrail_info_str,
|
||||
"status": "pending_review",
|
||||
"team_id": user_api_key_dict.team_id,
|
||||
"submitted_by_user_id": user_api_key_dict.user_id,
|
||||
"submitted_by_email": user_api_key_dict.user_email,
|
||||
"submitted_at": now,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
return RegisterGuardrailResponse(
|
||||
guardrail_id=created.guardrail_id,
|
||||
guardrail_name=created.guardrail_name,
|
||||
status=created.status,
|
||||
submitted_at=created.submitted_at,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Error registering guardrail: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def _parse_json_field(value: Any) -> Optional[Dict[str, Any]]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _row_to_submission_item(row: Any) -> GuardrailSubmissionItem:
|
||||
return GuardrailSubmissionItem(
|
||||
guardrail_id=row.guardrail_id,
|
||||
guardrail_name=row.guardrail_name,
|
||||
status=row.status or "active",
|
||||
team_id=row.team_id,
|
||||
litellm_params=_parse_json_field(row.litellm_params),
|
||||
guardrail_info=_parse_json_field(row.guardrail_info),
|
||||
submitted_by_user_id=getattr(row, "submitted_by_user_id", None),
|
||||
submitted_by_email=getattr(row, "submitted_by_email", None),
|
||||
submitted_at=getattr(row, "submitted_at", None),
|
||||
reviewed_at=getattr(row, "reviewed_at", None),
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/guardrails/submissions",
|
||||
tags=["Guardrails"],
|
||||
response_model=ListGuardrailSubmissionsResponse,
|
||||
)
|
||||
async def list_guardrail_submissions(
|
||||
status: Optional[str] = None,
|
||||
team_id: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
List guardrail submissions (admin only). Optional filters: status, team_id, search (name/description).
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail="Prisma client not initialized")
|
||||
|
||||
try:
|
||||
where: Dict[str, Any] = {}
|
||||
if status:
|
||||
where["status"] = status
|
||||
if team_id:
|
||||
where["team_id"] = team_id
|
||||
|
||||
rows = await prisma_client.db.litellm_guardrailstable.find_many(
|
||||
where=where,
|
||||
order={"created_at": "desc"},
|
||||
)
|
||||
|
||||
# Summary counts (all rows for consistent counts)
|
||||
all_rows = await prisma_client.db.litellm_guardrailstable.find_many()
|
||||
total = len(all_rows)
|
||||
pending_review = sum(1 for r in all_rows if (r.status or "active") == "pending_review")
|
||||
active_count = sum(1 for r in all_rows if (r.status or "active") == "active")
|
||||
rejected = sum(1 for r in all_rows if (r.status or "active") == "rejected")
|
||||
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
rows = [
|
||||
r
|
||||
for r in rows
|
||||
if search_lower in (r.guardrail_name or "").lower()
|
||||
or (isinstance(r.guardrail_info, dict) and search_lower in str((r.guardrail_info or {}).get("description", "")).lower())
|
||||
or (isinstance(r.guardrail_info, str) and search_lower in r.guardrail_info.lower())
|
||||
]
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append(_row_to_submission_item(r))
|
||||
return ListGuardrailSubmissionsResponse(
|
||||
submissions=items,
|
||||
summary=GuardrailSubmissionSummary(
|
||||
total=total,
|
||||
pending_review=pending_review,
|
||||
active=active_count,
|
||||
rejected=rejected,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Error listing guardrail submissions: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/guardrails/submissions/{guardrail_id}",
|
||||
tags=["Guardrails"],
|
||||
response_model=GuardrailSubmissionItem,
|
||||
)
|
||||
async def get_guardrail_submission(
|
||||
guardrail_id: str,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""Get a single guardrail submission by id (admin only)."""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail="Prisma client not initialized")
|
||||
|
||||
try:
|
||||
row = await prisma_client.db.litellm_guardrailstable.find_unique(
|
||||
where={"guardrail_id": guardrail_id}
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Guardrail submission not found")
|
||||
return _row_to_submission_item(row)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Error getting guardrail submission: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/guardrails/submissions/{guardrail_id}/approve",
|
||||
tags=["Guardrails"],
|
||||
)
|
||||
async def approve_guardrail_submission(
|
||||
guardrail_id: str,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""Approve a pending guardrail submission: set status to active and initialize in memory (admin only)."""
|
||||
from litellm.proxy.guardrails.guardrail_registry import \
|
||||
IN_MEMORY_GUARDRAIL_HANDLER
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail="Prisma client not initialized")
|
||||
|
||||
try:
|
||||
row = await prisma_client.db.litellm_guardrailstable.find_unique(
|
||||
where={"guardrail_id": guardrail_id}
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Guardrail submission not found")
|
||||
if row.status != "pending_review":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Guardrail is not pending review (status={row.status})",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
await prisma_client.db.litellm_guardrailstable.update(
|
||||
where={"guardrail_id": guardrail_id},
|
||||
data={"status": "active", "reviewed_at": now, "updated_at": now},
|
||||
)
|
||||
|
||||
litellm_params = _parse_json_field(row.litellm_params)
|
||||
guardrail_info = _parse_json_field(row.guardrail_info)
|
||||
if not litellm_params:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Guardrail litellm_params is missing or invalid",
|
||||
)
|
||||
guardrail_dict = {
|
||||
"guardrail_id": row.guardrail_id,
|
||||
"guardrail_name": row.guardrail_name,
|
||||
"litellm_params": litellm_params,
|
||||
"guardrail_info": guardrail_info or {},
|
||||
}
|
||||
try:
|
||||
IN_MEMORY_GUARDRAIL_HANDLER.initialize_guardrail(
|
||||
guardrail=cast(Guardrail, guardrail_dict)
|
||||
)
|
||||
verbose_proxy_logger.info(
|
||||
"Approved guardrail %s (ID: %s) and initialized in memory",
|
||||
row.guardrail_name,
|
||||
guardrail_id,
|
||||
)
|
||||
except Exception as init_err:
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to initialize approved guardrail %s in memory: %s",
|
||||
guardrail_id,
|
||||
init_err,
|
||||
)
|
||||
|
||||
return {"guardrail_id": guardrail_id, "status": "active", "message": "Guardrail approved"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Error approving guardrail submission: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/guardrails/submissions/{guardrail_id}/reject",
|
||||
tags=["Guardrails"],
|
||||
)
|
||||
async def reject_guardrail_submission(
|
||||
guardrail_id: str,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""Reject a guardrail submission (admin only)."""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail="Prisma client not initialized")
|
||||
|
||||
try:
|
||||
row = await prisma_client.db.litellm_guardrailstable.find_unique(
|
||||
where={"guardrail_id": guardrail_id}
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Guardrail submission not found")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
await prisma_client.db.litellm_guardrailstable.update(
|
||||
where={"guardrail_id": guardrail_id},
|
||||
data={"status": "rejected", "reviewed_at": now, "updated_at": now},
|
||||
)
|
||||
return {"guardrail_id": guardrail_id, "status": "rejected", "message": "Guardrail rejected"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Error rejecting guardrail submission: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/guardrails/{guardrail_id}",
|
||||
tags=["Guardrails"],
|
||||
|
|
|
|||
126
scripts/test_guardrails_register_endpoints.sh
Executable file
126
scripts/test_guardrails_register_endpoints.sh
Executable file
|
|
@ -0,0 +1,126 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Test guardrail register and submissions endpoints.
|
||||
# Requires: proxy running with DB (migrations applied), valid admin API key.
|
||||
#
|
||||
# Usage:
|
||||
# export LITELLM_API_KEY="sk-..." # required, use an admin key
|
||||
# ./scripts/test_guardrails_register_endpoints.sh
|
||||
# BASE_URL=http://localhost:4000 LITELLM_API_KEY="sk-..." ./scripts/test_guardrails_register_endpoints.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:4000}"
|
||||
API_KEY="${LITELLM_API_KEY:-}"
|
||||
|
||||
if ! command -v jq &>/dev/null; then
|
||||
echo "Error: jq is required. Install with: brew install jq (macOS) or apt-get install jq (Linux)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$API_KEY" ]]; then
|
||||
echo "Error: LITELLM_API_KEY is not set. Use an admin key to test list/approve/reject."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
AUTH_HEADER="Authorization: Bearer $API_KEY"
|
||||
TIMESTAMP=$(date +%s)
|
||||
NAME_APPROVE="test-guardrail-approve-$TIMESTAMP"
|
||||
NAME_REJECT="test-guardrail-reject-$TIMESTAMP"
|
||||
|
||||
echo "BASE_URL=$BASE_URL"
|
||||
echo "Testing guardrail register and submissions endpoints..."
|
||||
echo ""
|
||||
|
||||
# --- 1. Register a guardrail (will approve later) ---
|
||||
echo "[1/6] POST /guardrails/register (guardrail: $NAME_APPROVE)"
|
||||
REGISTER_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/guardrails/register" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"guardrail_name\": \"$NAME_APPROVE\",
|
||||
\"litellm_params\": {
|
||||
\"guardrail\": \"generic_guardrail_api\",
|
||||
\"mode\": \"pre_call\",
|
||||
\"api_base\": \"https://guardrails.example.com/validate\"
|
||||
},
|
||||
\"guardrail_info\": { \"description\": \"Test guardrail for approve flow\" }
|
||||
}")
|
||||
REGISTER_HTTP=$(echo "$REGISTER_RESPONSE" | tail -n1)
|
||||
REGISTER_BODY=$(echo "$REGISTER_RESPONSE" | sed '$d')
|
||||
if [[ "$REGISTER_HTTP" -ne 200 ]]; then
|
||||
echo " FAIL: expected 200, got $REGISTER_HTTP"
|
||||
echo "$REGISTER_BODY" | jq . 2>/dev/null || echo "$REGISTER_BODY"
|
||||
exit 1
|
||||
fi
|
||||
GUARDRAIL_ID_APPROVE=$(echo "$REGISTER_BODY" | jq -r '.guardrail_id')
|
||||
echo " OK (201/200) guardrail_id=$GUARDRAIL_ID_APPROVE"
|
||||
|
||||
# --- 2. Register a second guardrail (will reject later) ---
|
||||
echo "[2/6] POST /guardrails/register (guardrail: $NAME_REJECT)"
|
||||
REJECT_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/guardrails/register" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"guardrail_name\": \"$NAME_REJECT\",
|
||||
\"litellm_params\": {
|
||||
\"guardrail\": \"generic_guardrail_api\",
|
||||
\"mode\": \"post_call\",
|
||||
\"api_base\": \"https://guardrails.example.com/reject-test\"
|
||||
},
|
||||
\"guardrail_info\": { \"description\": \"Test guardrail for reject flow\" }
|
||||
}")
|
||||
REJECT_HTTP=$(echo "$REJECT_RESPONSE" | tail -n1)
|
||||
if [[ "$REJECT_HTTP" -ne 200 ]]; then
|
||||
echo " FAIL: expected 200, got $REJECT_HTTP"
|
||||
echo "$REJECT_RESPONSE" | sed '$d' | jq . 2>/dev/null || echo "$REJECT_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
GUARDRAIL_ID_REJECT=$(echo "$REJECT_RESPONSE" | sed '$d' | jq -r '.guardrail_id')
|
||||
echo " OK guardrail_id=$GUARDRAIL_ID_REJECT"
|
||||
|
||||
# --- 3. List submissions (admin) ---
|
||||
echo "[3/6] GET /guardrails/submissions"
|
||||
LIST_RESPONSE=$(curl -s -w "\n%{http_code}" -X GET "$BASE_URL/guardrails/submissions" -H "$AUTH_HEADER")
|
||||
LIST_HTTP=$(echo "$LIST_RESPONSE" | tail -n1)
|
||||
LIST_BODY=$(echo "$LIST_RESPONSE" | sed '$d')
|
||||
if [[ "$LIST_HTTP" -ne 200 ]]; then
|
||||
echo " FAIL: expected 200, got $LIST_HTTP"
|
||||
echo "$LIST_BODY" | jq . 2>/dev/null || echo "$LIST_BODY"
|
||||
exit 1
|
||||
fi
|
||||
echo " OK summary: $(echo "$LIST_BODY" | jq -c '.summary' 2>/dev/null || echo "N/A")"
|
||||
|
||||
# --- 4. Get one submission by id ---
|
||||
echo "[4/6] GET /guardrails/submissions/$GUARDRAIL_ID_APPROVE"
|
||||
GET_RESPONSE=$(curl -s -w "\n%{http_code}" -X GET "$BASE_URL/guardrails/submissions/$GUARDRAIL_ID_APPROVE" -H "$AUTH_HEADER")
|
||||
GET_HTTP=$(echo "$GET_RESPONSE" | tail -n1)
|
||||
if [[ "$GET_HTTP" -ne 200 ]]; then
|
||||
echo " FAIL: expected 200, got $GET_HTTP"
|
||||
exit 1
|
||||
fi
|
||||
echo " OK status=$(echo "$GET_RESPONSE" | sed '$d' | jq -r '.status')"
|
||||
|
||||
# --- 5. Approve first submission ---
|
||||
echo "[5/6] POST /guardrails/submissions/$GUARDRAIL_ID_APPROVE/approve"
|
||||
APPROVE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/guardrails/submissions/$GUARDRAIL_ID_APPROVE/approve" -H "$AUTH_HEADER")
|
||||
APPROVE_HTTP=$(echo "$APPROVE_RESPONSE" | tail -n1)
|
||||
if [[ "$APPROVE_HTTP" -ne 200 ]]; then
|
||||
echo " FAIL: expected 200, got $APPROVE_HTTP"
|
||||
echo "$APPROVE_RESPONSE" | sed '$d' | jq . 2>/dev/null || echo "$APPROVE_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
echo " OK $(echo "$APPROVE_RESPONSE" | sed '$d' | jq -c '.' 2>/dev/null)"
|
||||
|
||||
# --- 6. Reject second submission ---
|
||||
echo "[6/6] POST /guardrails/submissions/$GUARDRAIL_ID_REJECT/reject"
|
||||
REJECT_POST_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/guardrails/submissions/$GUARDRAIL_ID_REJECT/reject" -H "$AUTH_HEADER")
|
||||
REJECT_POST_HTTP=$(echo "$REJECT_POST_RESPONSE" | tail -n1)
|
||||
if [[ "$REJECT_POST_HTTP" -ne 200 ]]; then
|
||||
echo " FAIL: expected 200, got $REJECT_POST_HTTP"
|
||||
exit 1
|
||||
fi
|
||||
echo " OK $(echo "$REJECT_POST_RESPONSE" | sed '$d' | jq -c '.' 2>/dev/null)"
|
||||
|
||||
echo ""
|
||||
echo "All 6 requests succeeded. Guardrail register and submissions endpoints are working."
|
||||
|
|
@ -15,30 +15,19 @@ from fastapi import HTTPException
|
|||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.guardrails.guardrail_endpoints import (
|
||||
CreateGuardrailRequest,
|
||||
PatchGuardrailRequest,
|
||||
UpdateGuardrailRequest,
|
||||
apply_guardrail,
|
||||
create_guardrail,
|
||||
delete_guardrail,
|
||||
get_guardrail_info,
|
||||
list_guardrails_v2,
|
||||
patch_guardrail,
|
||||
update_guardrail,
|
||||
)
|
||||
CreateGuardrailRequest, PatchGuardrailRequest, RegisterGuardrailRequest,
|
||||
UpdateGuardrailRequest, apply_guardrail, approve_guardrail_submission,
|
||||
create_guardrail, delete_guardrail, get_guardrail_info,
|
||||
get_guardrail_submission, list_guardrail_submissions, list_guardrails_v2,
|
||||
patch_guardrail, register_guardrail, reject_guardrail_submission,
|
||||
update_guardrail)
|
||||
|
||||
MOCK_ADMIN_USER = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
from litellm.proxy.guardrails.guardrail_registry import (
|
||||
IN_MEMORY_GUARDRAIL_HANDLER,
|
||||
InMemoryGuardrailHandler,
|
||||
)
|
||||
from litellm.types.guardrails import (
|
||||
ApplyGuardrailRequest,
|
||||
BaseLitellmParams,
|
||||
Guardrail,
|
||||
GuardrailInfoResponse,
|
||||
LitellmParams,
|
||||
)
|
||||
IN_MEMORY_GUARDRAIL_HANDLER, InMemoryGuardrailHandler)
|
||||
from litellm.types.guardrails import (ApplyGuardrailRequest, BaseLitellmParams,
|
||||
Guardrail, GuardrailInfoResponse,
|
||||
LitellmParams)
|
||||
|
||||
# Mock data for testing
|
||||
MOCK_DB_GUARDRAIL = {
|
||||
|
|
@ -320,10 +309,10 @@ async def test_get_guardrail_info_not_found(
|
|||
|
||||
def test_get_provider_specific_params():
|
||||
"""Test getting provider-specific parameters"""
|
||||
from litellm.proxy.guardrails.guardrail_endpoints import _get_fields_from_model
|
||||
from litellm.proxy.guardrails.guardrail_hooks.azure import (
|
||||
AzureContentSafetyTextModerationGuardrail,
|
||||
)
|
||||
from litellm.proxy.guardrails.guardrail_endpoints import \
|
||||
_get_fields_from_model
|
||||
from litellm.proxy.guardrails.guardrail_hooks.azure import \
|
||||
AzureContentSafetyTextModerationGuardrail
|
||||
|
||||
config_model = AzureContentSafetyTextModerationGuardrail.get_config_model()
|
||||
if config_model is None:
|
||||
|
|
@ -388,8 +377,10 @@ def test_optional_params_not_returned_when_not_overridden():
|
|||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from litellm.proxy.guardrails.guardrail_endpoints import _get_fields_from_model
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
|
||||
from litellm.proxy.guardrails.guardrail_endpoints import \
|
||||
_get_fields_from_model
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.base import \
|
||||
GuardrailConfigModel
|
||||
|
||||
class TestGuardrailConfig(GuardrailConfigModel):
|
||||
api_key: Optional[str] = Field(
|
||||
|
|
@ -417,8 +408,10 @@ def test_optional_params_returned_when_properly_overridden():
|
|||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from litellm.proxy.guardrails.guardrail_endpoints import _get_fields_from_model
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
|
||||
from litellm.proxy.guardrails.guardrail_endpoints import \
|
||||
_get_fields_from_model
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.base import \
|
||||
GuardrailConfigModel
|
||||
|
||||
# Create specific optional params model
|
||||
class SpecificOptionalParams(BaseModel):
|
||||
|
|
@ -454,9 +447,8 @@ async def test_bedrock_guardrail_prepare_request_with_api_key():
|
|||
"""Test _prepare_request method uses Bearer token when api_key is provided in data"""
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
|
||||
BedrockGuardrail,
|
||||
)
|
||||
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import \
|
||||
BedrockGuardrail
|
||||
|
||||
# Setup guardrail hook
|
||||
guardrail_hook = BedrockGuardrail(
|
||||
|
|
@ -491,9 +483,8 @@ async def test_bedrock_guardrail_prepare_request_without_api_key():
|
|||
"""Test _prepare_request method falls back to SigV4 when no api_key is provided"""
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
|
||||
BedrockGuardrail,
|
||||
)
|
||||
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import \
|
||||
BedrockGuardrail
|
||||
|
||||
# Setup guardrail hook
|
||||
guardrail_hook = BedrockGuardrail(
|
||||
|
|
@ -544,9 +535,8 @@ async def test_bedrock_guardrail_prepare_request_with_bearer_token_env():
|
|||
"""Test _prepare_request method uses Bearer token from environment when available"""
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
|
||||
BedrockGuardrail,
|
||||
)
|
||||
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import \
|
||||
BedrockGuardrail
|
||||
|
||||
# Setup guardrail hook
|
||||
guardrail_hook = BedrockGuardrail(
|
||||
|
|
@ -590,9 +580,8 @@ async def test_bedrock_guardrail_make_api_request_passes_api_key():
|
|||
"""Test make_bedrock_api_request method correctly passes api_key from request_data"""
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import (
|
||||
BedrockGuardrail,
|
||||
)
|
||||
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import \
|
||||
BedrockGuardrail
|
||||
|
||||
guardrail_hook = BedrockGuardrail(
|
||||
guardrailIdentifier="test-guardrail-id",
|
||||
|
|
@ -1103,4 +1092,205 @@ async def test_get_guardrail_info_endpoint_db_guardrail(mocker):
|
|||
assert isinstance(result, GuardrailInfoResponse)
|
||||
assert result.guardrail_id == "test-db-guardrail"
|
||||
assert result.guardrail_name == "Test DB Guardrail"
|
||||
assert result.guardrail_definition_location == "db"
|
||||
assert result.guardrail_definition_location == "db"
|
||||
|
||||
|
||||
# --- Team guardrail registration (register / submissions) ---
|
||||
|
||||
MOCK_REGISTER_REQUEST = RegisterGuardrailRequest(
|
||||
guardrail_name="team-prompt-guard",
|
||||
litellm_params={
|
||||
"guardrail": "generic_guardrail_api",
|
||||
"mode": "pre_call",
|
||||
"api_base": "https://guardrails.example.com/validate",
|
||||
},
|
||||
guardrail_info={"description": "Team prompt injection detector"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_guardrail_success(mocker):
|
||||
"""Register creates a row with status pending_review and returns guardrail_id."""
|
||||
mock_prisma = mocker.Mock()
|
||||
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=None)
|
||||
created_row = mocker.Mock(
|
||||
guardrail_id="reg-123",
|
||||
guardrail_name=MOCK_REGISTER_REQUEST.guardrail_name,
|
||||
status="pending_review",
|
||||
submitted_at=datetime.now(),
|
||||
)
|
||||
mock_prisma.db.litellm_guardrailstable.create = AsyncMock(return_value=created_row)
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
|
||||
user = UserAPIKeyAuth(user_id="u1", user_email="alice@co.com", team_id="team-1")
|
||||
result = await register_guardrail(MOCK_REGISTER_REQUEST, user)
|
||||
|
||||
assert result.guardrail_id == "reg-123"
|
||||
assert result.guardrail_name == MOCK_REGISTER_REQUEST.guardrail_name
|
||||
assert result.status == "pending_review"
|
||||
mock_prisma.db.litellm_guardrailstable.create.assert_called_once()
|
||||
call_data = mock_prisma.db.litellm_guardrailstable.create.call_args[1]["data"]
|
||||
assert call_data["status"] == "pending_review"
|
||||
assert call_data["guardrail_name"] == MOCK_REGISTER_REQUEST.guardrail_name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_guardrail_rejects_non_generic_api(mocker):
|
||||
"""Register returns 400 when litellm_params.guardrail is not generic_guardrail_api."""
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.Mock())
|
||||
req = RegisterGuardrailRequest(
|
||||
guardrail_name="other-guard",
|
||||
litellm_params={"guardrail": "bedrock", "mode": "pre_call", "api_base": "https://x.com"},
|
||||
)
|
||||
user = UserAPIKeyAuth(user_id="u1", user_email="a@b.com")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await register_guardrail(req, user)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "generic_guardrail_api" in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_guardrail_requires_team_id(mocker):
|
||||
"""Register returns 400 when API key has no associated team_id."""
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.Mock())
|
||||
user = UserAPIKeyAuth(user_id="u1", user_email="a@b.com", team_id=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await register_guardrail(MOCK_REGISTER_REQUEST, user)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "team" in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_guardrail_duplicate_name(mocker):
|
||||
"""Register returns 400 when guardrail_name already exists."""
|
||||
mock_prisma = mocker.Mock()
|
||||
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(
|
||||
return_value={"guardrail_name": MOCK_REGISTER_REQUEST.guardrail_name}
|
||||
)
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
user = UserAPIKeyAuth(user_id="u1", user_email="a@b.com", team_id="team-1")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await register_guardrail(MOCK_REGISTER_REQUEST, user)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "already exists" in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_guardrail_submissions_requires_admin(mocker):
|
||||
"""List submissions returns 403 when user is not admin."""
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.Mock())
|
||||
user = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await list_guardrail_submissions(user_api_key_dict=user)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_guardrail_submissions_success(mocker):
|
||||
"""List submissions returns list and summary for admin."""
|
||||
mock_prisma = mocker.Mock()
|
||||
row = mocker.Mock(
|
||||
guardrail_id="sub-1",
|
||||
guardrail_name="pending-guard",
|
||||
status="pending_review",
|
||||
team_id="t1",
|
||||
litellm_params={"guardrail": "generic_guardrail_api", "api_base": "https://x.com"},
|
||||
guardrail_info={"description": "A guard"},
|
||||
submitted_by_user_id="u1",
|
||||
submitted_by_email="alice@co.com",
|
||||
submitted_at=datetime.now(),
|
||||
reviewed_at=None,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
mock_prisma.db.litellm_guardrailstable.find_many = AsyncMock(return_value=[row])
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
result = await list_guardrail_submissions(user_api_key_dict=user)
|
||||
|
||||
assert len(result.submissions) == 1
|
||||
assert result.submissions[0].guardrail_id == "sub-1"
|
||||
assert result.submissions[0].status == "pending_review"
|
||||
assert result.summary.total >= 1
|
||||
assert result.summary.pending_review >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_guardrail_submission_not_found(mocker):
|
||||
"""Get submission returns 404 when guardrail_id does not exist."""
|
||||
mock_prisma = mocker.Mock()
|
||||
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=None)
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_guardrail_submission("nonexistent-id", user)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_guardrail_submission_success(mocker):
|
||||
"""Approve sets status to active and initializes guardrail in memory."""
|
||||
mock_prisma = mocker.Mock()
|
||||
row = mocker.Mock(
|
||||
guardrail_id="approve-me",
|
||||
guardrail_name="my-guard",
|
||||
status="pending_review",
|
||||
litellm_params={"guardrail": "generic_guardrail_api", "mode": "pre_call", "api_base": "https://g.com"},
|
||||
guardrail_info={},
|
||||
)
|
||||
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row)
|
||||
mock_prisma.db.litellm_guardrailstable.update = AsyncMock()
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
mock_handler = mocker.Mock()
|
||||
mock_handler.initialize_guardrail = mocker.Mock()
|
||||
mocker.patch(
|
||||
"litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER",
|
||||
mock_handler,
|
||||
)
|
||||
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
result = await approve_guardrail_submission("approve-me", user)
|
||||
|
||||
assert result["status"] == "active"
|
||||
assert result["guardrail_id"] == "approve-me"
|
||||
mock_prisma.db.litellm_guardrailstable.update.assert_called_once()
|
||||
call_data = mock_prisma.db.litellm_guardrailstable.update.call_args[1]["data"]
|
||||
assert call_data["status"] == "active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_guardrail_submission_not_pending(mocker):
|
||||
"""Approve returns 400 when status is not pending_review."""
|
||||
mock_prisma = mocker.Mock()
|
||||
row = mocker.Mock(guardrail_id="x", guardrail_name="y", status="active")
|
||||
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row)
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await approve_guardrail_submission("x", user)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_guardrail_submission_success(mocker):
|
||||
"""Reject sets status to rejected."""
|
||||
mock_prisma = mocker.Mock()
|
||||
row = mocker.Mock(guardrail_id="rej-1", guardrail_name="r", status="pending_review")
|
||||
mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row)
|
||||
mock_prisma.db.litellm_guardrailstable.update = AsyncMock()
|
||||
mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
result = await reject_guardrail_submission("rej-1", user)
|
||||
|
||||
assert result["status"] == "rejected"
|
||||
mock_prisma.db.litellm_guardrailstable.update.assert_called_once()
|
||||
call_data = mock_prisma.db.litellm_guardrailstable.update.call_args[1]["data"]
|
||||
assert call_data["status"] == "rejected"
|
||||
|
|
@ -1,15 +1,256 @@
|
|||
import React from "react";
|
||||
import { GuardrailsPage } from "./guardrails/GuardrailsPage";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
|
||||
import { Dropdown } from "antd";
|
||||
import { DownOutlined, PlusOutlined, CodeOutlined } from "@ant-design/icons";
|
||||
import { getGuardrailsList, deleteGuardrailCall } from "./networking";
|
||||
import AddGuardrailForm from "./guardrails/add_guardrail_form";
|
||||
import GuardrailTable from "./guardrails/guardrail_table";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import GuardrailInfoView from "./guardrails/guardrail_info";
|
||||
import GuardrailTestPlayground from "./guardrails/GuardrailTestPlayground";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
import { Guardrail, GuardrailDefinitionLocation } from "./guardrails/types";
|
||||
import DeleteResourceModal from "./common_components/DeleteResourceModal";
|
||||
import { getGuardrailLogoAndName } from "./guardrails/guardrail_info_helpers";
|
||||
import { CustomCodeModal } from "./guardrails/custom_code";
|
||||
import GuardrailGarden from "./guardrails/guardrail_garden";
|
||||
import { TeamGuardrailsTab } from "./guardrails/TeamGuardrailsTab";
|
||||
|
||||
interface GuardrailsPanelProps {
|
||||
accessToken: string | null;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken }) => {
|
||||
interface GuardrailItem {
|
||||
guardrail_id?: string;
|
||||
guardrail_name: string | null;
|
||||
litellm_params: {
|
||||
guardrail: string;
|
||||
mode: string;
|
||||
default_on: boolean;
|
||||
};
|
||||
guardrail_info: Record<string, any> | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
guardrail_definition_location: GuardrailDefinitionLocation;
|
||||
}
|
||||
|
||||
interface GuardrailsResponse {
|
||||
guardrails: Guardrail[];
|
||||
}
|
||||
|
||||
const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole }) => {
|
||||
const [guardrailsList, setGuardrailsList] = useState<Guardrail[]>([]);
|
||||
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
|
||||
const [isCustomCodeModalVisible, setIsCustomCodeModalVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [guardrailToDelete, setGuardrailToDelete] = useState<Guardrail | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedGuardrailId, setSelectedGuardrailId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<number>(0);
|
||||
|
||||
const isAdmin = userRole ? isAdminRole(userRole) : false;
|
||||
|
||||
const fetchGuardrails = async () => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response: GuardrailsResponse = await getGuardrailsList(accessToken);
|
||||
console.log(`guardrails: ${JSON.stringify(response)}`);
|
||||
setGuardrailsList(response.guardrails);
|
||||
} catch (error) {
|
||||
console.error("Error fetching guardrails:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchGuardrails();
|
||||
}, [accessToken]);
|
||||
|
||||
const handleAddGuardrail = () => {
|
||||
if (selectedGuardrailId) {
|
||||
setSelectedGuardrailId(null);
|
||||
}
|
||||
setIsAddModalVisible(true);
|
||||
};
|
||||
|
||||
const handleAddCustomCodeGuardrail = () => {
|
||||
if (selectedGuardrailId) {
|
||||
setSelectedGuardrailId(null);
|
||||
}
|
||||
setIsCustomCodeModalVisible(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsAddModalVisible(false);
|
||||
};
|
||||
|
||||
const handleCloseCustomCodeModal = () => {
|
||||
setIsCustomCodeModalVisible(false);
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
fetchGuardrails();
|
||||
};
|
||||
|
||||
const handleDeleteClick = (guardrailId: string, guardrailName: string) => {
|
||||
const guardrail = guardrailsList.find((g) => g.guardrail_id === guardrailId) || null;
|
||||
setGuardrailToDelete(guardrail);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!guardrailToDelete || !accessToken) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteGuardrailCall(accessToken, guardrailToDelete.guardrail_id);
|
||||
NotificationsManager.success(`Guardrail "${guardrailToDelete.guardrail_name}" deleted successfully`);
|
||||
await fetchGuardrails();
|
||||
} catch (error) {
|
||||
console.error("Error deleting guardrail:", error);
|
||||
NotificationsManager.fromBackend("Failed to delete guardrail");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteModalOpen(false);
|
||||
setGuardrailToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setGuardrailToDelete(null);
|
||||
};
|
||||
|
||||
const providerDisplayName =
|
||||
guardrailToDelete && guardrailToDelete.litellm_params
|
||||
? getGuardrailLogoAndName(guardrailToDelete.litellm_params.guardrail).displayName
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
|
||||
<GuardrailsPage accessToken={accessToken} />
|
||||
<TabGroup index={activeTab} onIndexChange={setActiveTab}>
|
||||
<TabList className="mb-4">
|
||||
<Tab>Guardrail Garden</Tab>
|
||||
<Tab>Guardrails</Tab>
|
||||
<Tab disabled={!accessToken || guardrailsList.length === 0}>Test Playground</Tab>
|
||||
<Tab>Team Guardrails</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* Guardrail Garden Tab */}
|
||||
<TabPanel>
|
||||
<GuardrailGarden
|
||||
accessToken={accessToken}
|
||||
onGuardrailCreated={handleSuccess}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
{/* Existing Guardrails Tab */}
|
||||
<TabPanel>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: "provider",
|
||||
icon: <PlusOutlined />,
|
||||
label: "Add Provider Guardrail",
|
||||
onClick: handleAddGuardrail,
|
||||
},
|
||||
{
|
||||
key: "custom_code",
|
||||
icon: <CodeOutlined />,
|
||||
label: "Create Custom Code Guardrail",
|
||||
onClick: handleAddCustomCodeGuardrail,
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={["click"]}
|
||||
disabled={!accessToken}
|
||||
>
|
||||
<Button disabled={!accessToken}>
|
||||
+ Add New Guardrail <DownOutlined className="ml-2" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{selectedGuardrailId ? (
|
||||
<GuardrailInfoView
|
||||
guardrailId={selectedGuardrailId}
|
||||
onClose={() => setSelectedGuardrailId(null)}
|
||||
accessToken={accessToken}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
) : (
|
||||
<GuardrailTable
|
||||
guardrailsList={guardrailsList}
|
||||
isLoading={isLoading}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
accessToken={accessToken}
|
||||
onGuardrailUpdated={fetchGuardrails}
|
||||
isAdmin={isAdmin}
|
||||
onGuardrailClick={(id) => setSelectedGuardrailId(id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddGuardrailForm
|
||||
visible={isAddModalVisible}
|
||||
onClose={handleCloseModal}
|
||||
accessToken={accessToken}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
<CustomCodeModal
|
||||
visible={isCustomCodeModalVisible}
|
||||
onClose={handleCloseCustomCodeModal}
|
||||
accessToken={accessToken}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Guardrail"
|
||||
message={`Are you sure you want to delete guardrail: ${guardrailToDelete?.guardrail_name}? This action cannot be undone.`}
|
||||
resourceInformationTitle="Guardrail Information"
|
||||
resourceInformation={[
|
||||
{ label: "Name", value: guardrailToDelete?.guardrail_name },
|
||||
{ label: "ID", value: guardrailToDelete?.guardrail_id, code: true },
|
||||
{ label: "Provider", value: providerDisplayName },
|
||||
{ label: "Mode", value: guardrailToDelete?.litellm_params.mode },
|
||||
{
|
||||
label: "Default On",
|
||||
value: guardrailToDelete?.litellm_params.default_on ? "Yes" : "No",
|
||||
},
|
||||
]}
|
||||
onCancel={handleDeleteCancel}
|
||||
onOk={handleDeleteConfirm}
|
||||
confirmLoading={isDeleting}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
{/* Test Playground Tab */}
|
||||
<TabPanel>
|
||||
<GuardrailTestPlayground
|
||||
guardrailsList={guardrailsList}
|
||||
isLoading={isLoading}
|
||||
accessToken={accessToken}
|
||||
onClose={() => setActiveTab(0)}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
{/* Team Guardrails Tab */}
|
||||
<TabPanel>
|
||||
<TeamGuardrailsTab />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
SearchIcon,
|
||||
ArrowRightIcon,
|
||||
CheckCircleIcon,
|
||||
ShieldIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
const MOCK_GARDEN_CARDS = [
|
||||
{
|
||||
title: "Denied Financial Advice",
|
||||
description:
|
||||
"Detects requests for personalized financial advice, investment recommendations, or financial...",
|
||||
f1Score: "100%",
|
||||
testCases: 207,
|
||||
},
|
||||
{
|
||||
title: "Insults & Personal Attacks",
|
||||
description:
|
||||
"Detects insults, name-calling, and personal attacks directed at the chatbot, staff, or other people.",
|
||||
f1Score: "100%",
|
||||
testCases: 299,
|
||||
},
|
||||
{
|
||||
title: "Denied Legal Advice",
|
||||
description:
|
||||
"Detects requests for unauthorized legal advice, case analysis, or legal recommendations.",
|
||||
},
|
||||
{
|
||||
title: "Denied Medical Advice",
|
||||
description:
|
||||
"Detects requests for medical diagnosis, treatment recommendations, or health advice.",
|
||||
},
|
||||
{
|
||||
title: "Harmful Violence",
|
||||
description:
|
||||
"Detects content related to violence, criminal planning, attacks, and violent threats.",
|
||||
},
|
||||
{
|
||||
title: "Harmful Self-Harm",
|
||||
description:
|
||||
"Detects content related to self-harm, suicide, and dangerous self-destructive behavior.",
|
||||
},
|
||||
{
|
||||
title: "Harmful Child Safety",
|
||||
description:
|
||||
"Detects content that could endanger child safety or exploit minors.",
|
||||
},
|
||||
{
|
||||
title: "Harmful Illegal Weapons",
|
||||
description:
|
||||
"Detects content related to illegal weapons manufacturing, distribution, or acquisition.",
|
||||
},
|
||||
{
|
||||
title: "Bias: Gender",
|
||||
description:
|
||||
"Detects gender-based discrimination, stereotypes, and biased language.",
|
||||
},
|
||||
{
|
||||
title: "Bias: Racial",
|
||||
description:
|
||||
"Detects racial discrimination, stereotypes, and racially biased content.",
|
||||
},
|
||||
];
|
||||
|
||||
type GuardrailCardProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
f1Score?: string;
|
||||
testCases?: number;
|
||||
};
|
||||
|
||||
function GuardrailCard({
|
||||
title,
|
||||
description,
|
||||
f1Score,
|
||||
testCases,
|
||||
}: GuardrailCardProps) {
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4 bg-white hover:border-gray-300 transition-colors cursor-pointer">
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
<div className="flex-shrink-0 w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<ShieldIcon className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 leading-tight">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-relaxed mb-2">
|
||||
{description}
|
||||
</p>
|
||||
{f1Score && testCases !== undefined && (
|
||||
<div className="flex items-center gap-1 text-xs text-green-600">
|
||||
<CheckCircleIcon className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
F1: {f1Score} · {testCases} test cases
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GuardrailGardenTab() {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filteredCards = MOCK_GARDEN_CARDS.filter(
|
||||
(card) =>
|
||||
!search ||
|
||||
card.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
card.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Search */}
|
||||
<div className="relative mb-8">
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search guardrails"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 border border-gray-200 rounded-md text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* LiteLLM Content Filter Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
LiteLLM Content Filter
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-blue-500 hover:text-blue-600 flex items-center gap-1"
|
||||
>
|
||||
<ArrowRightIcon className="h-3.5 w-3.5" />
|
||||
Show all ({MOCK_GARDEN_CARDS.length})
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-5">
|
||||
Built-in guardrails powered by LiteLLM. Zero latency, no external
|
||||
dependencies, no additional cost.
|
||||
</p>
|
||||
|
||||
{/* Row 1 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-3">
|
||||
{filteredCards.slice(0, 6).map((card) => (
|
||||
<GuardrailCard
|
||||
key={card.title}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
f1Score={card.f1Score}
|
||||
testCases={card.testCases}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{filteredCards.slice(6, 10).map((card) => (
|
||||
<GuardrailCard
|
||||
key={card.title}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
f1Score={card.f1Score}
|
||||
testCases={card.testCases}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
const MOCK_GUARDRAILS = [
|
||||
{
|
||||
name: "Denied Financial Advice",
|
||||
type: "LiteLLM Built-in",
|
||||
status: "Active" as const,
|
||||
appliedTo: "All routes",
|
||||
},
|
||||
{
|
||||
name: "Insults & Personal Attacks",
|
||||
type: "LiteLLM Built-in",
|
||||
status: "Active" as const,
|
||||
appliedTo: "Customer-facing",
|
||||
},
|
||||
{
|
||||
name: "Prompt Injection Detector",
|
||||
type: "Team Custom",
|
||||
status: "Active" as const,
|
||||
appliedTo: "ML Platform team",
|
||||
},
|
||||
];
|
||||
|
||||
export function GuardrailsListTab() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">Guardrails</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Configure and manage active guardrails for your AI gateway.
|
||||
</p>
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Name
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Type
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Status
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Applied To
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MOCK_GUARDRAILS.map((row, i) => (
|
||||
<tr
|
||||
key={row.name}
|
||||
className={
|
||||
i < MOCK_GUARDRAILS.length - 1
|
||||
? "border-b border-gray-100"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-gray-900">
|
||||
{row.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">{row.type}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full bg-green-50 text-green-700">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">{row.appliedTo}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { GuardrailGardenTab } from "./GuardrailGardenTab";
|
||||
import { GuardrailsListTab } from "./GuardrailsListTab";
|
||||
import { PlaygroundTab } from "./PlaygroundTab";
|
||||
import { TeamGuardrailsTab } from "./TeamGuardrailsTab";
|
||||
|
||||
type Tab = "garden" | "guardrails" | "playground" | "team";
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "garden", label: "Guardrail Garden" },
|
||||
{ id: "guardrails", label: "Guardrails" },
|
||||
{ id: "playground", label: "Test Playground" },
|
||||
{ id: "team", label: "Team Guardrails" },
|
||||
];
|
||||
|
||||
interface GuardrailsPageProps {
|
||||
accessToken?: string | null;
|
||||
}
|
||||
|
||||
export function GuardrailsPage({ accessToken }: GuardrailsPageProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("garden");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full min-h-0 flex-1">
|
||||
{/* Tab bar */}
|
||||
<div className="border-b border-gray-200 px-6 flex-shrink-0 bg-white">
|
||||
<div className="flex items-center gap-0">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative px-4 py-3.5 text-sm font-medium transition-colors focus:outline-none ${
|
||||
activeTab === tab.id
|
||||
? "text-blue-500"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
} ${tab.id === "team" ? "flex items-center gap-1.5" : ""}`}
|
||||
>
|
||||
{tab.id === "team" && (
|
||||
<span className="inline-flex items-center justify-center w-1.5 h-1.5 rounded-full bg-blue-500" />
|
||||
)}
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500 rounded-t-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-auto bg-white">
|
||||
{activeTab === "garden" && <GuardrailGardenTab />}
|
||||
{activeTab === "guardrails" && <GuardrailsListTab />}
|
||||
{activeTab === "playground" && <PlaygroundTab />}
|
||||
{activeTab === "team" && <TeamGuardrailsTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
export function PlaygroundTab() {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
|
||||
function handleTest() {
|
||||
setResult(
|
||||
prompt.toLowerCase().includes("financial")
|
||||
? "🚫 Blocked by: Denied Financial Advice guardrail (confidence: 97%)"
|
||||
: "✅ Passed all guardrails. Safe to proceed."
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">
|
||||
Test Playground
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Test your guardrails against sample prompts to verify they work as
|
||||
expected.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-700 mb-1.5">
|
||||
Test Prompt
|
||||
</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Enter a prompt to test against your guardrails..."
|
||||
rows={4}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={!prompt.trim()}
|
||||
className="bg-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium px-4 py-2 rounded-md transition-colors"
|
||||
>
|
||||
Run Test
|
||||
</button>
|
||||
{result && (
|
||||
<div
|
||||
className={`border rounded-lg px-4 py-3 text-sm font-medium ${
|
||||
result.startsWith("🚫")
|
||||
? "border-red-200 bg-red-50 text-red-700"
|
||||
: "border-green-200 bg-green-50 text-green-700"
|
||||
}`}
|
||||
>
|
||||
{result}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue