diff --git a/docs/my-website/docs/adding_provider/generic_guardrail_api.md b/docs/my-website/docs/adding_provider/generic_guardrail_api.md index eb567a69fcb..cc0dbf1f4e9 100644 --- a/docs/my-website/docs/adding_provider/generic_guardrail_api.md +++ b/docs/my-website/docs/adding_provider/generic_guardrail_api.md @@ -244,6 +244,35 @@ litellm_settings: language: "en" ``` +### Static and dynamic headers + +You can send two kinds of headers to your guardrail endpoint: + +- **Static headers** (`headers`): A key/value map sent with **every** request to your guardrail. Use this for fixed values (e.g. API keys, `X-Service-Name`). Configure in `litellm_params`: + + ```yaml + litellm_params: + guardrail: generic_guardrail_api + api_base: https://your-guardrail-api.com + headers: + X-Service-Name: "my-app" + X-API-Key: "secret" + ``` + +- **Dynamic headers** (`extra_headers`): A list of **header names** that are forwarded from the **client request** to your guardrail. Only headers in this list (plus a small default allowlist such as `x-litellm-*`) have their values sent; others are sent as `[present]`. Use this to pass through client-provided headers (e.g. `x-request-id`, `x-correlation-id`). Configure in `litellm_params`: + + ```yaml + litellm_params: + guardrail: generic_guardrail_api + api_base: https://your-guardrail-api.com + extra_headers: + - x-request-id + - x-correlation-id + - x-custom-auth + ``` + +This mirrors the [MCP static and extra headers](/docs/mcp#forwarding-custom-headers-to-mcp-servers) behavior. + ### Example: Pillar Security [Pillar Security](https://pillar.security) uses the Generic Guardrail API to provide comprehensive AI security scanning including prompt injection protection, PII/PCI detection, secret detection, and content moderation. diff --git a/docs/my-website/docs/proxy/guardrails/quick_start.md b/docs/my-website/docs/proxy/guardrails/quick_start.md index ddb215fcb66..e5a90f74a8a 100644 --- a/docs/my-website/docs/proxy/guardrails/quick_start.md +++ b/docs/my-website/docs/proxy/guardrails/quick_start.md @@ -73,6 +73,7 @@ guardrails: plr_scanners: true ``` +For generic guardrail APIs you can also set **static headers** (`headers`: key/value sent on every request) and **dynamic headers** (`extra_headers`: list of client header names to forward). See [Generic Guardrail API - Static and dynamic headers](/docs/adding_provider/generic_guardrail_api#static-and-dynamic-headers). ### Supported values for `mode` (Event Hooks) diff --git a/docs/my-website/docs/proxy/guardrails/team_based_guardrails.md b/docs/my-website/docs/proxy/guardrails/team_based_guardrails.md new file mode 100644 index 00000000000..2d55294a711 --- /dev/null +++ b/docs/my-website/docs/proxy/guardrails/team_based_guardrails.md @@ -0,0 +1,137 @@ +import Image from '@theme/IdealImage'; + +# Team-Based Guardrails + +Team-based guardrails let **developers** register a guardrail for their team via the API; an **admin** then reviews and approves or rejects it in the LiteLLM UI. Only [Generic Guardrail API](/docs/adding_provider/generic_guardrail_api) guardrails can be registered this way. + +## Overview + +- **Developer flow:** Use a **team-scoped API key** to `POST /guardrails/register` with your guardrail config. The submission is stored with status `pending_review`. +- **Admin flow:** In the proxy UI, open **Guardrails → Team Guardrails**, review pending submissions, and **Approve** or **Reject**. Approved guardrails become active and are initialized in memory. + +--- + +## Developer flow: Register a guardrail + +### Prerequisites + +- A **team-scoped** API key (the key must be associated with a team). Keys without a team cannot register guardrails. +- Your guardrail must follow the [Generic Guardrail API](/docs/adding_provider/generic_guardrail_api) contract and config. + +### Request + +**Endpoint:** `POST /guardrails/register` + +**Headers:** `Authorization: Bearer ` + +**Body:** JSON matching the Generic Guardrail API config. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `guardrail_name` | string | Yes | Unique name for the guardrail. | +| `litellm_params` | object | Yes | Must include `guardrail: "generic_guardrail_api"`, `mode` (e.g. `pre_call`, `post_call`), and `api_base`. See [Generic Guardrail API](/docs/adding_provider/generic_guardrail_api#litellm-configuration). | +| `guardrail_info` | object | No | Optional metadata (e.g. `description`). | + +### Requirements for `litellm_params` + +- `guardrail` must be exactly `"generic_guardrail_api"`. +- `api_base` is required (your guardrail API base URL). +- `mode` is required (e.g. `pre_call`, `post_call`, `during_call`). + +### Example + +```bash +curl -X POST "http://localhost:4000/guardrails/register" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "guardrail_name": "my-team-guard", + "litellm_params": { + "guardrail": "generic_guardrail_api", + "mode": "pre_call", + "api_base": "https://your-guardrail-api.com", + "api_key": "optional-api-key", + "unreachable_fallback": "fail_closed", + "forward_api_key": true + }, + "guardrail_info": { + "description": "Team content moderation guardrail" + } + }' +``` + +### Example response + +```json +{ + "guardrail_id": "123e4567-e89b-12d3-a456-426614174000", + "guardrail_name": "my-team-guard", + "status": "pending_review", + "submitted_at": "2025-02-28T12:00:00.000Z" +} +``` + +### Errors + +- **400** – Missing or invalid body (e.g. `guardrail` not `generic_guardrail_api`, missing `api_base` or `mode`), or a guardrail with the same `guardrail_name` already exists. +- **400** – "Registration requires an API key associated with a team. Use a team-scoped key." → Use an API key that has a team. +- **500** – Server/database error. + +After a successful register, the guardrail stays in `pending_review` until an admin approves or rejects it. + +--- + +## Admin flow: Approve or reject in the UI + +Admins review and approve or reject team guardrail submissions in the LiteLLM proxy UI. + +### 1. Open the Guardrails page + +In the proxy dashboard, go to **Guardrails** (sidebar or navigation). + +### 2. Open the Team Guardrails tab + +Switch to the **Team Guardrails** tab. This tab lists all team-submitted guardrails and their status. + +Team Guardrails admin view: status summary (Total, Pending Review, Active, Rejected), guardrail list with Pending Review tag, and detail panel with Approve/Reject buttons and configuration options. + +### 3. Review submissions + +The table shows: + +- **Name**, **Team**, **Endpoint** (api_base), **Status** (Pending Review / Active / Rejected), **Submitted** date, **Submitted by** (user/email), and other config details. + +Summary cards show counts for **Total**, **Pending Review**, **Active**, and **Rejected**. + + + +### 4. Approve or reject + +- **Pending Review:** Use **Approve** to activate the guardrail. The proxy sets its status to `active` and initializes it in memory so it can be used on requests. +- Use **Reject** to decline the submission (status becomes `rejected`). + +Approval triggers the same initialization as adding a guardrail via config or the admin guardrail API; rejection only updates the status and does not load the guardrail. + + + +### API equivalent (admin only) + +Admins can also use the REST API: + +- **List submissions:** `GET /guardrails/submissions` (optional query: `status`, `team_id`, `search`) +- **Get one:** `GET /guardrails/submissions/{guardrail_id}` +- **Approve:** `POST /guardrails/submissions/{guardrail_id}/approve` +- **Reject:** `POST /guardrails/submissions/{guardrail_id}/reject` + +These endpoints require **admin** (e.g. `PROXY_ADMIN`) authentication. + +--- + +## Summary + +| Role | Action | +|------|--------| +| **Developer** | Call `POST /guardrails/register` with a team-scoped key and a `generic_guardrail_api` config. Submission enters `pending_review`. | +| **Admin** | Open **Guardrails → Team Guardrails** in the UI (or use the submissions API), then **Approve** or **Reject** each submission. Approved guardrails become active. | + +Only guardrails with `litellm_params.guardrail: "generic_guardrail_api"` are accepted for registration. For the full contract and config options, see [Generic Guardrail API](/docs/adding_provider/generic_guardrail_api). diff --git a/docs/my-website/img/admin_team_guardrails.png b/docs/my-website/img/admin_team_guardrails.png new file mode 100644 index 00000000000..5ce3c2687a9 Binary files /dev/null and b/docs/my-website/img/admin_team_guardrails.png differ diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 004114c8e08..f7487d24b12 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -42,6 +42,7 @@ const sidebars = { label: "Guardrails", items: [ "proxy/guardrails/quick_start", + "proxy/guardrails/team_based_guardrails", "proxy/guardrails/guardrail_load_balancing", "proxy/guardrails/test_playground", "proxy/guardrails/litellm_content_filter", diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260228170127_support_team_based_guardrails/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260228170127_support_team_based_guardrails/migration.sql new file mode 100644 index 00000000000..8af167950ec --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260228170127_support_team_based_guardrails/migration.sql @@ -0,0 +1,8 @@ +-- 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); + +-- CreateIndex +CREATE INDEX "LiteLLM_GuardrailsTable_status_idx" ON "LiteLLM_GuardrailsTable"("status"); + diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index f18556ac329..e0b28a4e012 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -871,6 +871,13 @@ model LiteLLM_GuardrailsTable { team_id String? created_at DateTime @default(now()) updated_at DateTime @updatedAt + // Submission lifecycle. Possible values: pending_review (team-registered, awaiting approval), active (approved), rejected + status String @default("active") + submitted_at DateTime? + reviewed_at DateTime? + // submitted_by_user_id and submitted_by_email live in guardrail_info JSON + + @@index([status]) } // Daily guardrail metrics for usage dashboard (one row per guardrail per day) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 5d4ceca890b..cbf683d226e 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -646,6 +646,8 @@ class LiteLLMRoutes(enum.Enum): # Invitation routes - org/team admins checked in endpoint via _user_has_admin_privileges "/invitation/new", "/invitation/delete", + # Team guardrail submission - requires team-scoped key; endpoint enforces team_id + "/guardrails/register", ] # routes that manage their own allowed/disallowed logic ## Org Admin Routes ## diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 5215fca0293..c6a709534e1 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -4,7 +4,10 @@ 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 +from urllib.parse import urlparse from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel @@ -12,6 +15,7 @@ from pydantic import BaseModel from litellm._logging import verbose_proxy_logger from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.guardrails.guardrail_hooks.custom_code.code_validator import ( @@ -525,6 +529,456 @@ 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 # pending_review | active | rejected + team_id: Optional[str] = None + team_guardrail: bool = ( + False # True when submitted via team (team_id set); use to distinguish team vs regular guardrails + ) + 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", + ) + api_base = params.get("api_base") + if not api_base: + raise HTTPException( + status_code=400, + detail="litellm_params.api_base is required for generic_guardrail_api", + ) + parsed = urlparse(api_base) + if parsed.scheme not in ("http", "https"): + raise HTTPException( + status_code=400, + detail="litellm_params.api_base must use http or https scheme", + ) + if not parsed.hostname: + raise HTTPException( + status_code=400, + detail="litellm_params.api_base must contain a valid hostname", + ) + 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 = dict(request.guardrail_info or {}) + guardrail_info["submitted_by_user_id"] = user_api_key_dict.user_id + guardrail_info["submitted_by_email"] = user_api_key_dict.user_email + guardrail_info["team_guardrail"] = ( + True # Mark as team submission for filtering/display + ) + guardrail_info_str = safe_dumps(guardrail_info) + + 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_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: + guardrail_info = _parse_json_field(row.guardrail_info) or {} + team_guardrail = row.team_id is not None + return GuardrailSubmissionItem( + guardrail_id=row.guardrail_id, + guardrail_name=row.guardrail_name, + status=row.status or "active", + team_id=row.team_id, + team_guardrail=team_guardrail, + litellm_params=_parse_json_field(row.litellm_params), + guardrail_info=guardrail_info, + submitted_by_user_id=guardrail_info.get("submitted_by_user_id"), + submitted_by_email=guardrail_info.get("submitted_by_email"), + 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 team guardrail submissions (admin only). Returns only guardrails with a team_id. + + Status values: pending_review (team-registered, awaiting approval), active (approved), rejected. + + Optional filters: + - status: pending_review | active | rejected + - team_id: filter by specific team + - 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: + # Single query: fetch all team guardrails (team_id is not null) + all_team_rows = await prisma_client.db.litellm_guardrailstable.find_many( + where={"team_id": {"not": None}}, + order={"created_at": "desc"}, + ) + + # Derive summary counts from the full result set + total = len(all_team_rows) + pending_review = sum( + 1 for r in all_team_rows if (r.status or "active") == "pending_review" + ) + active_count = sum( + 1 for r in all_team_rows if (r.status or "active") == "active" + ) + rejected = sum( + 1 for r in all_team_rows if (r.status or "active") == "rejected" + ) + + # Apply filters to get the submissions list + rows = all_team_rows + if status: + rows = [r for r in rows if r.status == status] + if team_id: + rows = [r for r in rows if r.team_id == team_id] + 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 = [_row_to_submission_item(r) for r in rows] + 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", + "warning": f"Guardrail was marked active but failed to initialize in memory: {init_err}. " + "It will be picked up on the next sync cycle.", + } + + 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" + ) + 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": "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"], @@ -1356,9 +1810,9 @@ async def get_provider_specific_params(): lakera_v2_fields = _get_fields_from_model(LakeraV2GuardrailConfigModel) tool_permission_fields = _get_fields_from_model(ToolPermissionGuardrailConfigModel) - tool_permission_fields[ - "ui_friendly_name" - ] = ToolPermissionGuardrailConfigModel.ui_friendly_name() + tool_permission_fields["ui_friendly_name"] = ( + ToolPermissionGuardrailConfigModel.ui_friendly_name() + ) # Return the provider-specific parameters provider_params = { @@ -1497,7 +1951,6 @@ async def test_custom_code_guardrail( ``` """ - if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: raise HTTPException( status_code=403, @@ -1632,10 +2085,10 @@ async def apply_guardrail( from litellm.proxy.utils import handle_exception_on_proxy try: - active_guardrail: Optional[ - CustomGuardrail - ] = GUARDRAIL_REGISTRY.get_initialized_guardrail_callback( - guardrail_name=request.guardrail_name + active_guardrail: Optional[CustomGuardrail] = ( + GUARDRAIL_REGISTRY.get_initialized_guardrail_callback( + guardrail_name=request.guardrail_name + ) ) if active_guardrail is None: raise HTTPException( diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py index a0c2113b7ab..bb0d0a99b31 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py @@ -21,6 +21,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail" unreachable_fallback=getattr( litellm_params, "unreachable_fallback", "fail_closed" ), + extra_headers=getattr(litellm_params, "extra_headers", None), guardrail_name=guardrail.get("guardrail_name", ""), event_hook=litellm_params.mode, default_on=litellm_params.default_on, diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py index 1892424e86d..990e7b3ede6 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py @@ -7,7 +7,7 @@ import fnmatch import os -from typing import TYPE_CHECKING, Any, Dict, Literal, Optional +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Set import httpx @@ -54,22 +54,30 @@ _HEADER_VALUE_ALLOWLIST = frozenset( _HEADER_PRESENT_PLACEHOLDER = "[present]" -def _header_value_allowed(header_name: str) -> bool: - """Return True if this header's value may be forwarded (allowlist, including globs).""" +def _header_value_allowed( + header_name: str, + extra_allowlist: Optional[Set[str]] = None, +) -> bool: + """Return True if this header's value may be forwarded (allowlist, including globs and extra_headers).""" lower = header_name.lower() if lower in _HEADER_VALUE_ALLOWLIST: return True for pattern in _HEADER_VALUE_ALLOWLIST: if "*" in pattern and fnmatch.fnmatch(lower, pattern): return True + if extra_allowlist and lower in extra_allowlist: + return True return False -def _sanitize_inbound_headers(headers: Any) -> Optional[Dict[str, str]]: +def _sanitize_inbound_headers( + headers: Any, + extra_allowlist: Optional[Set[str]] = None, +) -> Optional[Dict[str, str]]: """ Sanitize inbound headers before passing them to a 3rd party guardrail service. - - Allowlist: only headers in the allowlist have their values forwarded (exact + glob: x-stainless-*, x-litellm-*). + - Allowlist: default allowlist + extra_allowlist (from litellm_params.extra_headers); only these have values forwarded. - All other headers are included with value "[present]" so the guardrail knows the header existed. - Coerces values to str (for JSON serialization). """ @@ -81,7 +89,7 @@ def _sanitize_inbound_headers(headers: Any) -> Optional[Dict[str, str]]: if k is None: continue key = str(k) - if _header_value_allowed(key): + if _header_value_allowed(key, extra_allowlist=extra_allowlist): try: sanitized[key] = str(v) except Exception: @@ -93,7 +101,9 @@ def _sanitize_inbound_headers(headers: Any) -> Optional[Dict[str, str]]: def _extract_inbound_headers( - request_data: dict, logging_obj: Optional["LiteLLMLoggingObj"] + request_data: dict, + logging_obj: Optional["LiteLLMLoggingObj"], + extra_allowlist: Optional[Set[str]] = None, ) -> Optional[Dict[str, str]]: """ Extract inbound headers from available request context. @@ -107,23 +117,27 @@ def _extract_inbound_headers( # 1) Most common path (proxy): full request context in proxy_server_request headers = request_data.get("proxy_server_request", {}).get("headers") if headers: - return _sanitize_inbound_headers(headers) + return _sanitize_inbound_headers(headers, extra_allowlist=extra_allowlist) # 2) Some guardrails pass proxy_server_request as request_data itself headers = request_data.get("headers") if headers: - return _sanitize_inbound_headers(headers) + return _sanitize_inbound_headers(headers, extra_allowlist=extra_allowlist) # 3) Pre-call: headers stored in request metadata metadata_headers = (request_data.get("metadata") or {}).get("headers") if metadata_headers: - return _sanitize_inbound_headers(metadata_headers) + return _sanitize_inbound_headers( + metadata_headers, extra_allowlist=extra_allowlist + ) litellm_metadata_headers = (request_data.get("litellm_metadata") or {}).get( "headers" ) if litellm_metadata_headers: - return _sanitize_inbound_headers(litellm_metadata_headers) + return _sanitize_inbound_headers( + litellm_metadata_headers, extra_allowlist=extra_allowlist + ) # 4) Post-call: headers not present on response; fallback to logging object if logging_obj and getattr(logging_obj, "model_call_details", None): @@ -135,7 +149,9 @@ def _extract_inbound_headers( .get("headers", None) ) if headers: - return _sanitize_inbound_headers(headers) + return _sanitize_inbound_headers( + headers, extra_allowlist=extra_allowlist + ) except Exception: pass @@ -171,12 +187,14 @@ class GenericGuardrailAPI(CustomGuardrail): api_key: Optional[str] = None, additional_provider_specific_params: Optional[Dict[str, Any]] = None, unreachable_fallback: Literal["fail_closed", "fail_open"] = "fail_closed", + extra_headers: Optional[list] = None, **kwargs, ): self.async_handler = get_async_httpx_client( llm_provider=httpxSpecialProvider.GuardrailCallback ) self.headers = headers or {} + self.extra_headers = extra_headers or [] # If api_key is provided, add it as x-api-key header if api_key: @@ -370,8 +388,15 @@ class GenericGuardrailAPI(CustomGuardrail): # Extract user API key metadata user_metadata = self._extract_user_api_key_metadata(request_data) + extra_allowlist = ( + {h.lower() for h in self.extra_headers if isinstance(h, str)} + if self.extra_headers + else None + ) inbound_headers = _extract_inbound_headers( - request_data=request_data, logging_obj=logging_obj + request_data=request_data, + logging_obj=logging_obj, + extra_allowlist=extra_allowlist, ) # Create request payload diff --git a/litellm/proxy/guardrails/guardrail_registry.py b/litellm/proxy/guardrails/guardrail_registry.py index c0903a35b6d..46ea667f464 100644 --- a/litellm/proxy/guardrails/guardrail_registry.py +++ b/litellm/proxy/guardrails/guardrail_registry.py @@ -11,8 +11,12 @@ from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.litellm_core_utils.safe_json_dumps import safe_dumps -from litellm.proxy.utils import PrismaClient +from litellm.proxy.guardrails.guardrail_hooks.grayswan import GraySwanGuardrail +from litellm.proxy.guardrails.guardrail_hooks.grayswan import ( + initialize_guardrail as initialize_grayswan, +) from litellm.proxy.types_utils.utils import get_instance_fn +from litellm.proxy.utils import PrismaClient from litellm.secret_managers.main import get_secret from litellm.types.guardrails import ( Guardrail, @@ -21,10 +25,6 @@ from litellm.types.guardrails import ( LitellmParams, SupportedGuardrailIntegrations, ) -from litellm.proxy.guardrails.guardrail_hooks.grayswan import ( - GraySwanGuardrail, - initialize_guardrail as initialize_grayswan, -) from .guardrail_initializers import ( initialize_bedrock, @@ -327,11 +327,13 @@ class GuardrailRegistry: prisma_client: PrismaClient, ) -> List[Guardrail]: """ - Get all guardrails from the database + Get all active guardrails from the database. + Only rows with status == "active" are returned (pending_review and rejected are excluded). """ try: guardrails_from_db = ( await prisma_client.db.litellm_guardrailstable.find_many( + where={"status": "active"}, order={"created_at": "desc"}, ) ) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index f18556ac329..e0b28a4e012 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -871,6 +871,13 @@ model LiteLLM_GuardrailsTable { team_id String? created_at DateTime @default(now()) updated_at DateTime @updatedAt + // Submission lifecycle. Possible values: pending_review (team-registered, awaiting approval), active (approved), rejected + status String @default("active") + submitted_at DateTime? + reviewed_at DateTime? + // submitted_by_user_id and submitted_by_email live in guardrail_info JSON + + @@index([status]) } // Daily guardrail metrics for usage dashboard (one row per guardrail per day) diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 7381a3038f2..a68c4e2f762 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -698,6 +698,15 @@ class BaseLitellmParams( ), ) + extra_headers: Optional[List[str]] = Field( + default=None, + description=( + "Header names to forward from the client request to the guardrail (e.g. x-request-id). " + "Only these headers' values are sent; others may be omitted or sent as [present]. " + "Used by generic_guardrail_api (similar to MCP extra_headers)." + ), + ) + # Custom code guardrail params custom_code: Optional[str] = Field( default=None, diff --git a/schema.prisma b/schema.prisma index f18556ac329..e0b28a4e012 100644 --- a/schema.prisma +++ b/schema.prisma @@ -871,6 +871,13 @@ model LiteLLM_GuardrailsTable { team_id String? created_at DateTime @default(now()) updated_at DateTime @updatedAt + // Submission lifecycle. Possible values: pending_review (team-registered, awaiting approval), active (approved), rejected + status String @default("active") + submitted_at DateTime? + reviewed_at DateTime? + // submitted_by_user_id and submitted_by_email live in guardrail_info JSON + + @@index([status]) } // Daily guardrail metrics for usage dashboard (one row per guardrail per day) diff --git a/scripts/create_team_key_and_submit_guardrail.sh b/scripts/create_team_key_and_submit_guardrail.sh new file mode 100755 index 00000000000..339137f886e --- /dev/null +++ b/scripts/create_team_key_and_submit_guardrail.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# +# Creates a team, generates a team key, and submits a test guardrail with it. +# Requires: curl, jq +# +# Usage: +# ADMIN_KEY=sk-your-admin-key ./scripts/create_team_key_and_submit_guardrail.sh +# BASE_URL=http://localhost:4000 ADMIN_KEY=sk-your-admin-key ./scripts/create_team_key_and_submit_guardrail.sh + +set -e + +BASE_URL="${BASE_URL:-http://localhost:4000}" +BASE_URL="${BASE_URL%/}" + +if [ -z "${ADMIN_KEY}" ]; then + echo "Error: ADMIN_KEY is required (admin API key for the proxy)." + echo "Usage: ADMIN_KEY=sk-your-admin-key $0" + exit 1 +fi + +AUTH_HEADER="Authorization: Bearer ${ADMIN_KEY}" + +echo "Using BASE_URL=${BASE_URL}" +echo "Creating team..." + +TEAM_RESP=$(curl -s -X POST "${BASE_URL}/team/new" \ + -H "${AUTH_HEADER}" \ + -H "Content-Type: application/json" \ + -d '{ + "team_alias": "guardrail-test-team" + }') + +if ! echo "$TEAM_RESP" | jq -e .team_id >/dev/null 2>&1; then + echo "Failed to create team. Response:" + echo "$TEAM_RESP" | jq . 2>/dev/null || echo "$TEAM_RESP" + exit 1 +fi + +TEAM_ID=$(echo "$TEAM_RESP" | jq -r .team_id) +echo "Created team_id: ${TEAM_ID}" + +echo "Creating key for team..." + +KEY_RESP=$(curl -s -X POST "${BASE_URL}/key/generate" \ + -H "${AUTH_HEADER}" \ + -H "Content-Type: application/json" \ + -d "{ + \"team_id\": \"${TEAM_ID}\" + }") + +if ! echo "$KEY_RESP" | jq -e .key >/dev/null 2>&1; then + echo "Failed to create key. Response:" + echo "$KEY_RESP" | jq . 2>/dev/null || echo "$KEY_RESP" + exit 1 +fi + +TEAM_KEY=$(echo "$KEY_RESP" | jq -r .key) +echo "Created team key: ${TEAM_KEY}" + +GUARDRAIL_NAME="test-guardrail-$(date +%s)" +echo "Submitting guardrail: ${GUARDRAIL_NAME}" + +REGISTER_RESP=$(curl -s -X POST "${BASE_URL}/guardrails/register" \ + -H "Authorization: Bearer ${TEAM_KEY}" \ + -H "Content-Type: application/json" \ + -d "{ + \"guardrail_name\": \"${GUARDRAIL_NAME}\", + \"litellm_params\": { + \"guardrail\": \"generic_guardrail_api\", + \"mode\": \"pre_call\", + \"api_base\": \"https://example.com/guardrail\" + }, + \"guardrail_info\": { + \"description\": \"Test guardrail submitted via team key\" + } + }") + +if ! echo "$REGISTER_RESP" | jq -e .guardrail_id >/dev/null 2>&1; then + echo "Failed to register guardrail. Response:" + echo "$REGISTER_RESP" | jq . 2>/dev/null || echo "$REGISTER_RESP" + exit 1 +fi + +GUARDRAIL_ID=$(echo "$REGISTER_RESP" | jq -r .guardrail_id) +echo "Registered guardrail_id: ${GUARDRAIL_ID}" + +echo "" +echo "Done." +echo " team_id: ${TEAM_ID}" +echo " team_key: ${TEAM_KEY}" +echo " guardrail_id: ${GUARDRAIL_ID}" +echo " guardrail_name: ${GUARDRAIL_NAME}" diff --git a/scripts/test_guardrails_register_endpoints.sh b/scripts/test_guardrails_register_endpoints.sh new file mode 100755 index 00000000000..89fd53b5b8c --- /dev/null +++ b/scripts/test_guardrails_register_endpoints.sh @@ -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." diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py index a3c1fd9ea05..e01038cd35f 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py @@ -13,8 +13,8 @@ import pytest import litellm from litellm import ModelResponse -from litellm.exceptions import GuardrailRaisedException, Timeout from litellm._version import version as litellm_version +from litellm.exceptions import GuardrailRaisedException, Timeout from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( GenericGuardrailAPI, @@ -188,6 +188,97 @@ class TestGenericGuardrailAPIConfiguration: ) assert "x-api-key" not in guardrail.headers + def test_init_with_extra_headers(self): + """Test that extra_headers is stored for forwarding client headers to the guardrail""" + guardrail = GenericGuardrailAPI( + api_base="https://api.test.guardrail.com", + extra_headers=["x-request-id", "x-custom-auth"], + ) + assert guardrail.extra_headers == ["x-request-id", "x-custom-auth"] + + +class TestExtraHeadersForwarding: + """Test extra_headers: client headers allowed to be forwarded to the guardrail""" + + @pytest.mark.asyncio + async def test_extra_headers_values_forwarded_to_guardrail(self): + """When extra_headers is set, those client header values are sent to the guardrail.""" + guardrail = GenericGuardrailAPI( + api_base="https://api.test.guardrail.com", + extra_headers=["x-my-header", "x-request-id"], + ) + request_data = { + "proxy_server_request": { + "headers": { + "x-my-header": "my-value", + "x-request-id": "req-123", + "x-private": "secret", + }, + }, + } + mock_response = MagicMock() + mock_response.json.return_value = { + "action": "NONE", + "texts": ["test"], + } + mock_response.raise_for_status = MagicMock() + + with patch.object( + guardrail.async_handler, "post", return_value=mock_response + ) as mock_post: + await guardrail.apply_guardrail( + inputs={"texts": ["test"]}, + request_data=request_data, + input_type="request", + ) + + call_args = mock_post.call_args + json_payload = call_args.kwargs["json"] + request_headers = json_payload.get("request_headers") or {} + + # Headers in extra_headers have their values forwarded + assert request_headers.get("x-my-header") == "my-value" + assert request_headers.get("x-request-id") == "req-123" + # Headers not in allowlist are sent as placeholder + assert request_headers.get("x-private") == _HEADER_PRESENT_PLACEHOLDER + + @pytest.mark.asyncio + async def test_without_extra_headers_custom_header_value_not_forwarded(self): + """Without extra_headers, a custom client header is sent as [present] only.""" + guardrail = GenericGuardrailAPI( + api_base="https://api.test.guardrail.com", + # no extra_headers + ) + request_data = { + "proxy_server_request": { + "headers": { + "x-custom-auth": "bearer secret-token", + }, + }, + } + mock_response = MagicMock() + mock_response.json.return_value = { + "action": "NONE", + "texts": ["test"], + } + mock_response.raise_for_status = MagicMock() + + with patch.object( + guardrail.async_handler, "post", return_value=mock_response + ) as mock_post: + await guardrail.apply_guardrail( + inputs={"texts": ["test"]}, + request_data=request_data, + input_type="request", + ) + + call_args = mock_post.call_args + json_payload = call_args.kwargs["json"] + request_headers = json_payload.get("request_headers") or {} + + # x-custom-auth is not in default allowlist nor extra_headers, so value is not forwarded + assert request_headers.get("x-custom-auth") == _HEADER_PRESENT_PLACEHOLDER + class TestMetadataExtraction: """Test metadata extraction from request data""" diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py index 0ac3637b380..62a6e777b0d 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py @@ -17,13 +17,19 @@ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.guardrails.guardrail_endpoints import ( 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, ) @@ -1103,4 +1109,466 @@ 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" \ No newline at end of file + 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", team_id="team-1") + + 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.submissions[0].team_guardrail is True # team_id is set + assert result.summary.total >= 1 + assert result.summary.pending_review >= 1 + + +@pytest.mark.asyncio +async def test_list_guardrail_submissions_returns_only_team_guardrails(mocker): + """List submissions only returns team guardrails (team_id not null).""" + mock_prisma = mocker.Mock() + find_many = AsyncMock(return_value=[]) + mock_prisma.db.litellm_guardrailstable.find_many = find_many + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma) + user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + + await list_guardrail_submissions(user_api_key_dict=user) + + calls = find_many.call_args_list + assert len(calls) >= 1 + first_where = calls[0].kwargs.get("where", {}) + assert first_where.get("team_id") == {"not": None} + + +@pytest.mark.asyncio +async def test_list_guardrail_submissions_team_id_filter(mocker): + """List submissions with team_id filter returns only that team's guardrails.""" + mock_prisma = mocker.Mock() + row_abc = mocker.Mock( + guardrail_id="team-1", + guardrail_name="team-guard", + status="active", + team_id="team-abc", + litellm_params={}, + guardrail_info={}, + submitted_at=None, + reviewed_at=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + row_other = mocker.Mock( + guardrail_id="team-2", + guardrail_name="other-guard", + status="active", + team_id="team-xyz", + litellm_params={}, + guardrail_info={}, + submitted_at=None, + reviewed_at=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + find_many = AsyncMock(return_value=[row_abc, row_other]) + mock_prisma.db.litellm_guardrailstable.find_many = find_many + 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, team_id="team-abc" + ) + + assert len(result.submissions) == 1 + assert result.submissions[0].guardrail_id == "team-1" + assert result.submissions[0].team_guardrail is True + assert result.summary.total == 2 # summary counts all team guardrails + + +@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" + + +@pytest.mark.asyncio +async def test_reject_guardrail_submission_not_pending(mocker): + """Reject returns 400 when status is not pending_review (e.g. already active).""" + mock_prisma = mocker.Mock() + row = mocker.Mock(guardrail_id="already-active", guardrail_name="g", 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 reject_guardrail_submission("already-active", user) + assert exc_info.value.status_code == 400 + assert "not pending review" in exc_info.value.detail.lower() + + +# --- Tests for review fixes --- + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "api_base,expected_detail", + [ + ("file:///etc/passwd", "http or https scheme"), + ("ftp://internal.host/data", "http or https scheme"), + ("javascript:alert(1)", "http or https scheme"), + ("://missing-scheme", "http or https scheme"), + ("https://", "valid hostname"), + ], + ids=[ + "file_scheme", + "ftp_scheme", + "javascript_scheme", + "no_scheme", + "no_hostname", + ], +) +async def test_register_guardrail_rejects_bad_api_base(mocker, api_base, expected_detail): + """Register returns 400 when api_base has invalid scheme or missing hostname.""" + mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.Mock()) + req = RegisterGuardrailRequest( + guardrail_name="bad-url-guard", + litellm_params={ + "guardrail": "generic_guardrail_api", + "mode": "pre_call", + "api_base": api_base, + }, + ) + user = UserAPIKeyAuth(user_id="u1", user_email="a@b.com", team_id="team-1") + + with pytest.raises(HTTPException) as exc_info: + await register_guardrail(req, user) + assert exc_info.value.status_code == 400 + assert expected_detail in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_register_guardrail_accepts_valid_https_url(mocker): + """Register accepts valid https api_base URLs.""" + mock_prisma = mocker.Mock() + mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=None) + created_row = mocker.Mock( + guardrail_id="valid-url-123", + guardrail_name="valid-guard", + 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) + + req = RegisterGuardrailRequest( + guardrail_name="valid-guard", + litellm_params={ + "guardrail": "generic_guardrail_api", + "mode": "pre_call", + "api_base": "https://guardrails.example.com/v1/check", + }, + ) + user = UserAPIKeyAuth(user_id="u1", user_email="a@b.com", team_id="team-1") + + result = await register_guardrail(req, user) + assert result.guardrail_id == "valid-url-123" + assert result.status == "pending_review" + + +@pytest.mark.asyncio +async def test_approve_guardrail_init_failure_returns_warning(mocker): + """Approve returns a warning field when in-memory initialization fails.""" + mock_prisma = mocker.Mock() + row = mocker.Mock( + guardrail_id="warn-me", + guardrail_name="fragile-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( + side_effect=Exception("missing dependency") + ) + 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("warn-me", user) + + assert result["status"] == "active" + assert "warning" in result + assert "failed to initialize" in result["warning"].lower() + assert "missing dependency" in result["warning"] + + +@pytest.mark.asyncio +async def test_approve_guardrail_no_warning_on_success(mocker): + """Approve does NOT include a warning field when init succeeds.""" + mock_prisma = mocker.Mock() + row = mocker.Mock( + guardrail_id="ok-guard", + guardrail_name="good-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() # no exception + 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("ok-guard", user) + + assert result["status"] == "active" + assert "warning" not in result + + +@pytest.mark.asyncio +async def test_list_submissions_single_db_query(mocker): + """List submissions makes exactly one find_many call (no redundant query).""" + mock_prisma = mocker.Mock() + find_many = AsyncMock(return_value=[]) + mock_prisma.db.litellm_guardrailstable.find_many = find_many + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma) + user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + + await list_guardrail_submissions(user_api_key_dict=user) + + assert find_many.call_count == 1 + + +@pytest.mark.asyncio +async def test_list_submissions_summary_counts_unaffected_by_filters(mocker): + """Summary counts reflect all team guardrails regardless of status filter.""" + mock_prisma = mocker.Mock() + pending_row = mocker.Mock( + guardrail_id="p1", guardrail_name="p", status="pending_review", + team_id="t1", litellm_params={}, guardrail_info={}, + submitted_at=None, reviewed_at=None, + created_at=datetime.now(), updated_at=datetime.now(), + ) + active_row = mocker.Mock( + guardrail_id="a1", guardrail_name="a", status="active", + team_id="t1", litellm_params={}, guardrail_info={}, + submitted_at=None, reviewed_at=None, + created_at=datetime.now(), updated_at=datetime.now(), + ) + all_rows = [pending_row, active_row] + mock_prisma.db.litellm_guardrailstable.find_many = AsyncMock(return_value=all_rows) + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma) + user = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + + # Filter to only pending, but summary should still show both + result = await list_guardrail_submissions(status="pending_review", user_api_key_dict=user) + + assert len(result.submissions) == 1 # filtered + assert result.summary.total == 2 # unfiltered + assert result.summary.pending_review == 1 + assert result.summary.active == 1 \ No newline at end of file diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 200182cf551..697ec68e0bb 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -12984,6 +12984,21 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/ui/litellm-dashboard/src/components/guardrails.tsx b/ui/litellm-dashboard/src/components/guardrails.tsx index a8de7dd2f4f..aa31c3af613 100644 --- a/ui/litellm-dashboard/src/components/guardrails.tsx +++ b/ui/litellm-dashboard/src/components/guardrails.tsx @@ -14,6 +14,7 @@ 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; @@ -139,6 +140,7 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole Guardrail Garden Guardrails Test Playground + Team Guardrails @@ -242,6 +244,11 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole onClose={() => setActiveTab(0)} /> + + {/* Team Guardrails Tab */} + + + diff --git a/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx b/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx new file mode 100644 index 00000000000..a2246fd976d --- /dev/null +++ b/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx @@ -0,0 +1,1081 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { + SearchIcon, + PlusIcon, + ChevronDownIcon, + ChevronUpIcon, + XIcon, + CheckIcon, + ExternalLinkIcon, + KeyIcon, + ServerIcon, + AlertCircleIcon, + InfoIcon, +} from "lucide-react"; +import { + listGuardrailSubmissions, + approveGuardrailSubmission, + rejectGuardrailSubmission, + updateGuardrailCall, + type GuardrailSubmissionItem, +} from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +type GuardrailStatus = "active" | "pending" | "rejected"; + +type TeamGuardrail = { + id: string; + team: string; + name: string; + endpoint: string; + status: GuardrailStatus; + model: string; + forwardKey: boolean; + description: string; + method: "POST" | "GET"; + customHeaders: { + key: string; + value: string; + }[]; + extraHeaders: string[]; + submittedAt: string; + submittedBy: string; + mode?: string; + unreachable_fallback?: string; + additionalProviderParams?: Record; + guardrailType?: string; +}; + +function mapStatus(apiStatus: string): GuardrailStatus { + if (apiStatus === "pending_review") return "pending"; + if (apiStatus === "active" || apiStatus === "rejected") return apiStatus; + return "active"; +} + +function formatSubmissionDate(value: string | null | undefined): string { + if (!value) return "—"; + try { + const d = new Date(value); + return isNaN(d.getTime()) ? value : d.toISOString().slice(0, 10); + } catch { + return value; + } +} + +function submissionToTeamGuardrail(item: GuardrailSubmissionItem): TeamGuardrail { + const params = item.litellm_params ?? {}; + const info = item.guardrail_info ?? {}; + const headers = params.headers; + const customHeaders: { key: string; value: string }[] = Array.isArray(headers) + ? headers.map((h: { key?: string; name?: string; value: string }) => ({ + key: (h.key ?? h.name ?? "").toString(), + value: String(h.value ?? ""), + })) + : typeof headers === "object" && headers !== null + ? Object.entries(headers).map(([key, value]) => ({ + key, + value: String(value ?? ""), + })) + : []; + const endpoint = + (params.api_base as string) ?? (params.url as string) ?? ""; + const model = + (info.model as string) ?? (params.model as string) ?? "—"; + const forwardKey = (params.forward_api_key as boolean) ?? true; + const extraHeaders = Array.isArray(params.extra_headers) + ? (params.extra_headers as string[]).filter((h): h is string => typeof h === "string") + : []; + return { + id: item.guardrail_id, + team: item.team_id ?? "—", + name: item.guardrail_name, + endpoint, + status: mapStatus(item.status), + model, + forwardKey, + description: (info.description as string) ?? "", + method: (params.method as "POST" | "GET") ?? "POST", + customHeaders, + extraHeaders, + submittedAt: formatSubmissionDate(item.submitted_at), + submittedBy: item.submitted_by_email ?? item.submitted_by_user_id ?? "—", + mode: params.mode as string | undefined, + unreachable_fallback: params.unreachable_fallback as string | undefined, + additionalProviderParams: params.additional_provider_specific_params as Record | undefined, + guardrailType: params.guardrail as string | undefined, + }; +} + +const STATUS_CONFIG: Record< + GuardrailStatus, + { label: string; bg: string; text: string; dot: string } +> = { + active: { + label: "Active", + bg: "bg-green-50", + text: "text-green-700", + dot: "bg-green-500", + }, + pending: { + label: "Pending Review", + bg: "bg-yellow-50", + text: "text-yellow-700", + dot: "bg-yellow-500", + }, + rejected: { + label: "Rejected", + bg: "bg-red-50", + text: "text-red-700", + dot: "bg-red-500", + }, +}; + +const TEAM_COLORS: Record = { + "ML Platform": "bg-purple-100 text-purple-700", + "Data Science": "bg-blue-100 text-blue-700", + Security: "bg-red-100 text-red-700", + "Customer Success": "bg-orange-100 text-orange-700", + Legal: "bg-gray-100 text-gray-700", + Finance: "bg-green-100 text-green-700", +}; + +function buildEquivalentConfigYaml(g: TeamGuardrail): string { + const lines: string[] = [ + "litellm_settings:", + " guardrails:", + ` - guardrail_name: "${g.name.replace(/"/g, '\\"')}"`, + " litellm_params:", + ` guardrail: ${g.guardrailType ?? "generic_guardrail_api"}`, + ` mode: ${g.mode ?? "pre_call"} # or post_call, during_call`, + ` api_base: ${g.endpoint || "https://your-guardrail-api.com"}`, + " api_key: os.environ/YOUR_GUARDRAIL_API_KEY # optional", + ` unreachable_fallback: ${g.unreachable_fallback ?? "fail_closed"} # default: fail_closed. Set to fail_open to proceed if the guardrail endpoint is unreachable.`, + ` forward_api_key: ${g.forwardKey}`, + ]; + if (g.model && g.model !== "—") { + lines.push(` model: "${g.model}" # LLM model name sent to the guardrail for context`); + } + if (g.customHeaders.length > 0) { + lines.push(" headers: # static headers (sent with every request)"); + for (const h of g.customHeaders) { + lines.push(` ${h.key}: "${String(h.value).replace(/"/g, '\\"')}"`); + } + } + if (g.extraHeaders.length > 0) { + lines.push(" extra_headers: # forward these client request headers to the guardrail"); + for (const name of g.extraHeaders) { + lines.push(` - ${name}`); + } + } + if (g.additionalProviderParams && Object.keys(g.additionalProviderParams).length > 0) { + lines.push(" additional_provider_specific_params:"); + for (const [k, v] of Object.entries(g.additionalProviderParams)) { + const val = typeof v === "string" ? `"${v}"` : String(v); + lines.push(` ${k}: ${val}`); + } + } + return lines.join("\n"); +} + +function StatCard({ + label, + value, + color, +}: { + label: string; + value: number; + color: string; +}) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function Toggle({ + enabled, + onToggle, +}: { + enabled: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +type GuardrailCardProps = { + guardrail: TeamGuardrail; + isSelected: boolean; + isHeadersExpanded: boolean; + onSelect: () => void; + onToggleForwardKey: () => void; + onToggleHeaders: () => void; + onApprove: () => void; + onReject: () => void; +}; + +function GuardrailCard({ + guardrail: g, + isSelected, + isHeadersExpanded, + onSelect, + onToggleForwardKey, + onToggleHeaders, + onApprove, + onReject, +}: GuardrailCardProps) { + const status = STATUS_CONFIG[g.status]; + const teamColor = TEAM_COLORS[g.team] ?? "bg-gray-100 text-gray-700"; + return ( +
+
+
+
+ + Team: {g.team} + + + + {status.label} + +
+

{g.name}

+

+ {g.description} +

+
+ + + {g.endpoint} + +
+
+ + Model: {g.model} + + + Submitted:{" "} + {g.submittedAt} + +
+
+
+
+ + Forward API Key + + +
+
+ + {g.status === "pending" && ( + <> + + + + )} +
+
+
+
+ + {isHeadersExpanded && ( +
+ {g.customHeaders.length === 0 ? ( +

+ No static headers configured. +

+ ) : ( +
+ {g.customHeaders.map((h, i) => ( +
+ + {h.key} + + : + + {h.value} + +
+ ))} +
+ )} +
+ )} +
+
+ ); +} + +function ConfigRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +type DetailPanelProps = { + guardrail: TeamGuardrail; + onClose: () => void; + onApprove: () => void; + onReject: () => void; + onToggleForwardKey: () => void; + onUpdateCustomHeaders: ( + customHeaders: { key: string; value: string }[] + ) => Promise; + onUpdateExtraHeaders: (extraHeaders: string[]) => Promise; +}; + +function DetailPanel({ + guardrail: g, + onClose, + onApprove, + onReject, + onToggleForwardKey, + onUpdateCustomHeaders, + onUpdateExtraHeaders, +}: DetailPanelProps) { + const [configExpanded, setConfigExpanded] = useState(false); + const [newExtraHeader, setNewExtraHeader] = useState(""); + const [newStaticHeaderKey, setNewStaticHeaderKey] = useState(""); + const [newStaticHeaderValue, setNewStaticHeaderValue] = useState(""); + const status = STATUS_CONFIG[g.status]; + const teamColor = TEAM_COLORS[g.team] ?? "bg-gray-100 text-gray-700"; + return ( +
+
+
+
+
+ + Team: {g.team} + + + + {status.label} + +
+

{g.name}

+

+ Submitted by {g.submittedBy} on {g.submittedAt} +

+
+ +
+

{g.description}

+
+ +
+ + {g.endpoint} + + + + +
+
+ + + {g.method} + + +
+
+
+ + + Forward LiteLLM API Key + +
+ +
+

+ When enabled, the caller's LiteLLM API key is forwarded as an{" "} + + Authorization + {" "} + header to your guardrail endpoint. This allows your guardrail to + authenticate model calls using the original caller's + credentials. +

+
+
+
+ + Static headers + + {g.customHeaders.length > 0 && ( + + {g.customHeaders.length} + + )} +
+

+ Sent with every request to the guardrail. +

+ {g.customHeaders.length === 0 ? ( +

+ No static headers configured. +

+ ) : ( +
    + {g.customHeaders.map((h, i) => ( +
  • + + {h.key}: {h.value} + + +
  • + ))} +
+ )} +
+ setNewStaticHeaderKey(e.target.value)} + placeholder="Header name (e.g. X-API-Key)" + className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const key = newStaticHeaderKey.trim(); + const value = newStaticHeaderValue.trim(); + if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) { + onUpdateCustomHeaders([...g.customHeaders, { key, value }]); + setNewStaticHeaderKey(""); + setNewStaticHeaderValue(""); + } + } + }} + /> + setNewStaticHeaderValue(e.target.value)} + placeholder="Value" + className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const key = newStaticHeaderKey.trim(); + const value = newStaticHeaderValue.trim(); + if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) { + onUpdateCustomHeaders([...g.customHeaders, { key, value }]); + setNewStaticHeaderKey(""); + setNewStaticHeaderValue(""); + } + } + }} + /> + +
+
+
+
+ + Forward client headers + + {g.extraHeaders.length > 0 && ( + + {g.extraHeaders.length} + + )} +
+

+ Allowed header names to forward from the client request to the guardrail (e.g. x-request-id). +

+ {g.extraHeaders.length === 0 ? ( +

+ No forward client headers configured. +

+ ) : ( +
    + {g.extraHeaders.map((name, i) => ( +
  • + {name} + +
  • + ))} +
+ )} +
+ setNewExtraHeader(e.target.value)} + placeholder="e.g. x-request-id" + className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const name = newExtraHeader.trim().toLowerCase(); + if (name && !g.extraHeaders.map((h) => h.toLowerCase()).includes(name)) { + onUpdateExtraHeaders([...g.extraHeaders, name]); + setNewExtraHeader(""); + } + } + }} + /> + +
+
+
+ + {configExpanded && ( +
+                {buildEquivalentConfigYaml(g)}
+              
+ )} +
+
+ +

+ This guardrail runs on a separate instance. It receives the user + request and forwards the result to the next step in the pipeline. See{" "} + + LiteLLM Generic Guardrail API docs + {" "} + for configuration details. +

+
+
+
+ + {g.status === "pending" && ( +
+ + +
+ )} +
+
+
+ ); +} + +type ConfirmDialogProps = { + action: "approve" | "reject"; + guardrailName: string; + onConfirm: () => void; + onCancel: () => void; +}; + +function ConfirmDialog({ + action, + guardrailName, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const isApprove = action === "approve"; + return ( +
+
+
+ {isApprove ? ( + + ) : ( + + )} +
+

+ {isApprove ? "Approve Guardrail" : "Reject Guardrail"} +

+

+ Are you sure you want to {action}{" "} + "{guardrailName}"?{" "} + {isApprove + ? "This will make it active and available for use." + : "This will mark it as rejected and notify the team."} +

+
+ + +
+
+
+ ); +} + +interface TeamGuardrailsTabProps { + accessToken: string | null; +} + +export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { + const [guardrails, setGuardrails] = useState([]); + const [summary, setSummary] = useState({ + total: 0, + pending_review: 0, + active: 0, + rejected: 0, + }); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState< + "all" | GuardrailStatus + >("all"); + const [selectedId, setSelectedId] = useState(null); + const [expandedHeaders, setExpandedHeaders] = useState>(new Set()); + const [confirmAction, setConfirmAction] = useState<{ + id: string; + action: "approve" | "reject"; + } | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [searchDebounced, setSearchDebounced] = useState(""); + + useEffect(() => { + const t = setTimeout(() => setSearchDebounced(search), 300); + return () => clearTimeout(t); + }, [search]); + + const fetchSubmissions = useCallback(async () => { + if (!accessToken) { + setIsLoading(false); + return; + } + setIsLoading(true); + setError(null); + try { + const statusParam = + statusFilter === "all" + ? undefined + : statusFilter === "pending" + ? "pending_review" + : statusFilter; + const res = await listGuardrailSubmissions(accessToken, { + status: statusParam, + search: searchDebounced.trim() || undefined, + }); + setGuardrails(res.submissions.map(submissionToTeamGuardrail)); + setSummary(res.summary); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load submissions"); + setGuardrails([]); + } finally { + setIsLoading(false); + } + }, [accessToken, statusFilter, searchDebounced]); + + useEffect(() => { + fetchSubmissions(); + }, [fetchSubmissions]); + + const filtered = guardrails; + const selected = guardrails.find((g) => g.id === selectedId) ?? null; + const totalCount = summary.total; + const pendingCount = summary.pending_review; + const activeCount = summary.active; + const rejectedCount = summary.rejected; + + async function toggleForwardKey(id: string) { + if (!accessToken) return; + const g = guardrails.find((x) => x.id === id); + if (!g) return; + const newValue = !g.forwardKey; + try { + await updateGuardrailCall(accessToken, id, { + litellm_params: { forward_api_key: newValue }, + }); + setGuardrails((prev) => + prev.map((x) => (x.id === id ? { ...x, forwardKey: newValue } : x)) + ); + NotificationsManager.success( + newValue ? "Forward API key enabled" : "Forward API key disabled" + ); + } catch { + NotificationsManager.fromBackend("Failed to update forward API key"); + } + } + + async function updateCustomHeaders( + id: string, + customHeaders: { key: string; value: string }[] + ) { + if (!accessToken) return; + const headersObj: Record = {}; + for (const { key, value } of customHeaders) { + if (key.trim()) headersObj[key.trim()] = value; + } + try { + await updateGuardrailCall(accessToken, id, { + litellm_params: { headers: headersObj }, + }); + setGuardrails((prev) => + prev.map((x) => + x.id === id + ? { + ...x, + customHeaders: customHeaders.filter((h) => h.key.trim()), + } + : x + ) + ); + NotificationsManager.success("Static headers updated"); + } catch { + NotificationsManager.fromBackend("Failed to update static headers"); + } + } + + async function updateExtraHeaders(id: string, extraHeaders: string[]) { + if (!accessToken) return; + try { + await updateGuardrailCall(accessToken, id, { + litellm_params: { extra_headers: extraHeaders }, + }); + setGuardrails((prev) => + prev.map((x) => (x.id === id ? { ...x, extraHeaders } : x)) + ); + NotificationsManager.success("Forward client headers updated"); + } catch { + NotificationsManager.fromBackend("Failed to update forward client headers"); + } + } + + async function handleApprove(id: string) { + if (!accessToken) return; + try { + await approveGuardrailSubmission(accessToken, id); + setConfirmAction(null); + if (selectedId === id) setSelectedId(null); + await fetchSubmissions(); + NotificationsManager.success("Guardrail approved"); + } catch { + NotificationsManager.fromBackend("Failed to approve guardrail"); + } + } + + async function handleReject(id: string) { + if (!accessToken) return; + try { + await rejectGuardrailSubmission(accessToken, id); + setConfirmAction(null); + if (selectedId === id) setSelectedId(null); + await fetchSubmissions(); + NotificationsManager.success("Guardrail rejected"); + } catch { + NotificationsManager.fromBackend("Failed to reject guardrail"); + } + } + + function toggleHeaders(id: string) { + setExpandedHeaders((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + return ( +
+
+
+ + + + +
+
+
+ + 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" + /> +
+ + +
+
+ {isLoading && ( +
+ Loading submissions… +
+ )} + {error && ( +
+ {error} +
+ )} + {!isLoading && !error && filtered.length === 0 && ( +
+ No guardrails match your filters. +
+ )} + {!isLoading && !error && filtered.map((g) => ( + setSelectedId(selectedId === g.id ? null : g.id)} + onToggleForwardKey={() => toggleForwardKey(g.id)} + onToggleHeaders={() => toggleHeaders(g.id)} + onApprove={() => setConfirmAction({ id: g.id, action: "approve" })} + onReject={() => setConfirmAction({ id: g.id, action: "reject" })} + /> + ))} +
+
+ {selected && ( + setSelectedId(null)} + onApprove={() => + setConfirmAction({ id: selected.id, action: "approve" }) + } + onReject={() => + setConfirmAction({ id: selected.id, action: "reject" }) + } + onToggleForwardKey={() => toggleForwardKey(selected.id)} + onUpdateCustomHeaders={(customHeaders) => + updateCustomHeaders(selected.id, customHeaders) + } + onUpdateExtraHeaders={(extraHeaders) => + updateExtraHeaders(selected.id, extraHeaders) + } + /> + )} + {confirmAction && ( + g.id === confirmAction.id)?.name ?? "" + } + onConfirm={() => + confirmAction.action === "approve" + ? handleApprove(confirmAction.id) + : handleReject(confirmAction.id) + } + onCancel={() => setConfirmAction(null)} + /> + )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 0df6d813d7c..f64e909ae3e 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -5484,6 +5484,131 @@ export const getGuardrailsList = async (accessToken: string) => { } }; +// Team guardrail submissions (admin) +export interface GuardrailSubmissionItem { + guardrail_id: string; + guardrail_name: string; + status: string; // "pending_review" | "active" | "rejected" + team_id?: string | null; + team_guardrail?: boolean; // true when submitted via team (team_id set) + litellm_params?: Record | null; + guardrail_info?: Record | null; + submitted_by_user_id?: string | null; + submitted_by_email?: string | null; + submitted_at?: string | null; + reviewed_at?: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +export interface GuardrailSubmissionSummary { + total: number; + pending_review: number; + active: number; + rejected: number; +} + +export interface ListGuardrailSubmissionsResponse { + submissions: GuardrailSubmissionItem[]; + summary: GuardrailSubmissionSummary; +} + +export const listGuardrailSubmissions = async ( + accessToken: string, + params?: { status?: string; team_id?: string; team_guardrail?: boolean; search?: string } +): Promise => { + const url = proxyBaseUrl ? `${proxyBaseUrl}/guardrails/submissions` : `/guardrails/submissions`; + const searchParams = new URLSearchParams(); + if (params?.status) searchParams.set("status", params.status); + if (params?.team_id) searchParams.set("team_id", params.team_id); + if (params?.team_guardrail !== undefined) searchParams.set("team_guardrail", String(params.team_guardrail)); + if (params?.search) searchParams.set("search", params.search); + const fullUrl = searchParams.toString() ? `${url}?${searchParams.toString()}` : url; + const response = await fetch(fullUrl, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + return response.json(); +}; + +export const getGuardrailSubmission = async ( + accessToken: string, + guardrailId: string +): Promise => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/guardrails/submissions/${encodeURIComponent(guardrailId)}` + : `/guardrails/submissions/${encodeURIComponent(guardrailId)}`; + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + return response.json(); +}; + +export const approveGuardrailSubmission = async ( + accessToken: string, + guardrailId: string +): Promise<{ guardrail_id: string; status: string; message: string }> => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/guardrails/submissions/${encodeURIComponent(guardrailId)}/approve` + : `/guardrails/submissions/${encodeURIComponent(guardrailId)}/approve`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + return response.json(); +}; + +export const rejectGuardrailSubmission = async ( + accessToken: string, + guardrailId: string +): Promise<{ guardrail_id: string; status: string; message: string }> => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/guardrails/submissions/${encodeURIComponent(guardrailId)}/reject` + : `/guardrails/submissions/${encodeURIComponent(guardrailId)}/reject`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + return response.json(); +}; + // Guardrails / Policies usage (dashboard) export const getGuardrailsUsageOverview = async ( accessToken: string, @@ -8364,6 +8489,7 @@ export const updateGuardrailCall = async ( guardrail_name?: string; default_on?: boolean; guardrail_info?: Record; + litellm_params?: Record; }, ) => { try {