diff --git a/litellm/proxy/guardrails/guardrail_hooks/alice/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/alice/__init__.py
new file mode 100644
index 00000000000..75ea16f7a88
--- /dev/null
+++ b/litellm/proxy/guardrails/guardrail_hooks/alice/__init__.py
@@ -0,0 +1,34 @@
+from typing import TYPE_CHECKING, Final
+
+from litellm.types.guardrails import SupportedGuardrailIntegrations
+
+from .alice import AliceGuardrail
+
+if TYPE_CHECKING:
+ from litellm.types.guardrails import Guardrail, LitellmParams
+
+
+def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"):
+ import litellm
+
+ _alice_guardrail_callback: Final = AliceGuardrail(
+ api_key=litellm_params.api_key,
+ api_base=litellm_params.api_base,
+ unreachable_fallback=getattr(litellm_params, "unreachable_fallback", "fail_closed"),
+ guardrail_name=guardrail.get("guardrail_name", ""),
+ event_hook=litellm_params.mode,
+ default_on=litellm_params.default_on,
+ )
+
+ litellm.logging_callback_manager.add_litellm_callback(_alice_guardrail_callback)
+ return _alice_guardrail_callback
+
+
+guardrail_initializer_registry: Final = { # mutable-ok: module-level registry, built once and never mutated
+ SupportedGuardrailIntegrations.ALICE.value: initialize_guardrail,
+}
+
+
+guardrail_class_registry: Final = { # mutable-ok: module-level registry, built once and never mutated
+ SupportedGuardrailIntegrations.ALICE.value: AliceGuardrail,
+}
diff --git a/litellm/proxy/guardrails/guardrail_hooks/alice/alice.py b/litellm/proxy/guardrails/guardrail_hooks/alice/alice.py
new file mode 100644
index 00000000000..27018769909
--- /dev/null
+++ b/litellm/proxy/guardrails/guardrail_hooks/alice/alice.py
@@ -0,0 +1,369 @@
+# +-------------------------------------------------------------+
+#
+# Use Alice for your LLM calls
+# https://alice.io/
+#
+# +-------------------------------------------------------------+
+
+import json
+import os
+from collections.abc import Mapping
+from typing import (
+ TYPE_CHECKING,
+ Any, # noqa: TID251 # **kwargs forwards verbatim to CustomGuardrail.__init__; see ruff-strict.toml
+ Final,
+ Literal,
+ Optional,
+)
+
+import httpx
+from typing_extensions import NotRequired, ReadOnly, TypedDict
+
+from litellm._logging import verbose_proxy_logger
+from litellm.exceptions import GuardrailRaisedException, Timeout
+from litellm.integrations.custom_guardrail import (
+ CustomGuardrail,
+ log_guardrail_information,
+)
+from litellm.llms.custom_httpx.http_handler import (
+ get_async_httpx_client,
+ httpxSpecialProvider,
+)
+from litellm.types.guardrails import GuardrailEventHooks
+from litellm.types.utils import GenericGuardrailAPIInputs
+
+if TYPE_CHECKING:
+ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+
+GUARDRAIL_NAME: Final = "alice"
+
+_DEFAULT_API_BASE: Final = "https://api.alice.io"
+_EVALUATE_PATH: Final = "/v2/evaluate/litellm"
+
+_VERDICT_ALLOW: Final = "ALLOW"
+_VERDICT_BLOCK: Final = "BLOCK"
+_VERDICT_MASK: Final = "MASK"
+_VERDICT_DETECT: Final = "DETECT"
+_KNOWN_VERDICTS: Final = frozenset({_VERDICT_ALLOW, _VERDICT_BLOCK, _VERDICT_MASK, _VERDICT_DETECT})
+
+_DEFAULT_BLOCK_MESSAGE: Final = "Blocked by your organization's content policy."
+
+# apply_guardrail selects nothing: it forwards whichever of these came populated and lets Alice
+# decide what is worth evaluating. Only skip the call when every one of them is empty — there is
+# then genuinely nothing to send.
+_SELECTABLE_INPUT_FIELDS: Final = ("texts", "images", "tools", "tool_calls", "structured_messages")
+
+# Caps on the outbound copy of request_data. A payload deeper or wider than this is malformed
+# rather than large, and serializing it would cost more than the evaluation it feeds.
+_MAX_DEPTH: Final = 12
+_MAX_ITEMS: Final = 5000
+
+# request_data carries the caller's raw credentials under these keys, at any nesting depth —
+# a real captured payload puts inbound headers at request_data["proxy_server_request"]["headers"],
+# again under ["metadata"]["headers"] / ["litellm_metadata"]["headers"], and again under
+# ["metadata"]["requester_metadata"]["headers"], any of which can carry an Authorization or
+# x-api-key value. LiteLLM's own spend-log sanitizer excludes `secret_fields` for the same reason
+# (spend_tracking_utils._SENSITIVE_REQUEST_BODY_KEYS): `secret_fields.raw_headers` holds the
+# caller's Authorization / x-api-key in the clear, and `api_key` can carry a forwarded provider
+# credential. Stripping by key name rather than by path means a new nesting path can never
+# reintroduce the leak. Posting any of these to a third-party guardrail endpoint would be worse
+# than what the proxy already refuses to persist in its own audit trail — so none of them leave
+# the process.
+_CREDENTIAL_KEYS_TO_STRIP: Final = frozenset(
+ {"secret_fields", "api_key", "raw_headers", "headers", "provider_specific_header"}
+)
+
+
+class AliceReplacement(TypedDict):
+ """A masked substitution, positional against the texts that were submitted."""
+
+ index: ReadOnly[NotRequired[int]]
+ text: ReadOnly[NotRequired[str]]
+
+
+class AliceVerdict(TypedDict):
+ """Body returned by Alice's LiteLLM evaluate endpoint."""
+
+ verdict: ReadOnly[NotRequired[str]]
+ categories: ReadOnly[NotRequired["tuple[str, ...]"]]
+ correlation_id: ReadOnly[NotRequired[str]]
+ message: ReadOnly[NotRequired[str]]
+ replacements: ReadOnly[NotRequired["tuple[AliceReplacement, ...]"]]
+
+
+class AliceGuardrailMissingSecrets(Exception):
+ """Raised when the Alice API key is not configured."""
+
+
+class AliceGuardrail(CustomGuardrail):
+ """
+ Alice — policy-based guardrails for prompts and model responses.
+
+ This forwards the hook's arguments as it received them and enforces the verdict that comes
+ back, with one deliberate exception: any key named `secret_fields`, `api_key`, `raw_headers`,
+ `headers`, or `provider_specific_header` is dropped from `request_data` at any nesting depth
+ before it is serialized, and never reaches Alice. Short of that, it selects nothing and
+ renames nothing: which parts of a conversation are worth evaluating, and how a verdict is
+ reached, are decided by Alice — so changing either is a change on their side rather than a
+ LiteLLM upgrade. A batch with nothing selectable at all (no `texts`, `images`, `tools`,
+ `tool_calls`, or `structured_messages`) still skips the call, since there would be nothing to
+ send.
+
+ Known limitation: the unified guardrail's `streaming_transform_mode` defaults to
+ `block_only`, whose streaming path discards any returned text rewrite. A MASK verdict is
+ therefore a no-op on a streamed response — the original, unmasked text still reaches the
+ caller — while BLOCK continues to function on both streamed and non-streamed responses.
+ This is `during_call`'s documented behavior generally, not specific to Alice; configure a
+ masking-aware `streaming_transform_mode` if that gap matters for your traffic.
+
+ Alice evaluates against policies configured per *application*, and one proxy typically fronts
+ several, so the application is named on the virtual key rather than in this config:
+
+ curl $PROXY/key/generate -H "Authorization: Bearer $LITELLM_MASTER_KEY" \\
+ -d '{"key_alias": "payments-bot",
+ "metadata": {"alice_app_id": "payments-bot"}}'
+
+ Alice reads that off the authenticated key. Because the proxy strips caller-supplied
+ `user_api_key_*` from the request before a guardrail sees it, a caller cannot point its own
+ traffic at an application with laxer policies than the one its key was issued for.
+
+ Configuration example (litellm config YAML):
+ guardrails:
+ - guardrail_name: alice
+ litellm_params:
+ guardrail: alice
+ mode: [pre_call, post_call]
+ api_key: os.environ/ALICE_API_KEY
+ api_base: https://api.alice.io # optional
+ unreachable_fallback: fail_closed # optional
+ """
+
+ def __init__(
+ self,
+ api_key: str | None = None,
+ api_base: str | None = None,
+ unreachable_fallback: Literal["fail_closed", "fail_open"] = "fail_closed",
+ **kwargs: Any, # kwargs-ok: forwarded verbatim to CustomGuardrail.__init__, whose param list is wide and evolving
+ ) -> None:
+ self.async_handler = get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback)
+
+ alice_api_key: Final = api_key or os.environ.get("ALICE_API_KEY")
+ if not alice_api_key:
+ raise AliceGuardrailMissingSecrets(
+ "Alice API key is required. Set the `ALICE_API_KEY` environment variable or "
+ "pass `api_key` in the guardrail config."
+ )
+ self.alice_api_key: str = alice_api_key
+
+ base: Final = (api_base or os.environ.get("ALICE_API_BASE") or _DEFAULT_API_BASE).rstrip("/")
+ self.api_base: str = f"{base}{_EVALUATE_PATH}"
+ self.unreachable_fallback: Literal["fail_closed", "fail_open"] = unreachable_fallback
+
+ if "supported_event_hooks" not in kwargs:
+ kwargs["supported_event_hooks"] = [ # mutable-ok: CustomGuardrail.__init__ requires a list here
+ GuardrailEventHooks.pre_call,
+ GuardrailEventHooks.during_call,
+ GuardrailEventHooks.post_call,
+ ]
+
+ super().__init__(**kwargs)
+
+ @log_guardrail_information
+ async def apply_guardrail(
+ self,
+ inputs: GenericGuardrailAPIInputs,
+ request_data: dict[str, object], # mutable-ok: overrides CustomGuardrail.apply_guardrail's plain-dict contract
+ input_type: Literal["request", "response"],
+ logging_obj: Optional["LiteLLMLoggingObj"] = None,
+ ) -> GenericGuardrailAPIInputs:
+ if not any(inputs.get(field) for field in _SELECTABLE_INPUT_FIELDS):
+ return inputs
+
+ try:
+ verdict: AliceVerdict = await self._evaluate(
+ inputs=inputs, request_data=request_data, input_type=input_type
+ )
+ except Timeout as e:
+ return self._on_unreachable(e, inputs)
+ except httpx.HTTPStatusError as e:
+ status_code: Final = getattr(getattr(e, "response", None), "status_code", None)
+ # Any 5xx is an outage on Alice's side, not our misconfiguration — route the whole
+ # class through the configured policy. A 4xx (rejected credential, bad request) is
+ # ours to fix and must never fail open, so it is deliberately left to propagate.
+ if isinstance(status_code, int) and 500 <= status_code < 600:
+ return self._on_unreachable(e, inputs)
+ raise
+ except httpx.RequestError as e:
+ return self._on_unreachable(e, inputs)
+ except (json.JSONDecodeError, UnicodeDecodeError, TypeError) as e:
+ # A body that cannot be decoded, cannot be parsed as JSON, or parses to something
+ # other than an object, is as unreachable as a dropped connection: this deployment's
+ # policy decides, not a raw exception. UnicodeDecodeError is named explicitly because
+ # it is a sibling of JSONDecodeError under ValueError, not a subclass of it.
+ return self._on_unreachable(e, inputs)
+
+ return self._enforce(verdict, inputs)
+
+ async def _evaluate(
+ self,
+ inputs: GenericGuardrailAPIInputs,
+ request_data: Mapping[str, object],
+ input_type: str,
+ ) -> AliceVerdict:
+ response: Final = await self.async_handler.post(
+ url=self.api_base,
+ json={ # mutable-ok: one-shot HTTP request body, never mutated after construction
+ "input_type": input_type,
+ "inputs": _json_safe(inputs),
+ "request_data": _json_safe(request_data, strip_keys=_CREDENTIAL_KEYS_TO_STRIP),
+ },
+ headers={ # mutable-ok: one-shot HTTP headers, never mutated after construction
+ "Content-Type": "application/json",
+ "af-api-key": self.alice_api_key,
+ },
+ )
+ response.raise_for_status()
+ body = response.json()
+ if not isinstance(body, dict):
+ raise TypeError("Alice returned a non-object body")
+ return body
+
+ def _enforce(self, verdict: AliceVerdict, inputs: GenericGuardrailAPIInputs) -> GenericGuardrailAPIInputs:
+ """Act on the verdict. An answer we cannot read is treated as unavailable, never as a pass."""
+ name: Final = verdict.get("verdict")
+ if name not in _KNOWN_VERDICTS:
+ return self._on_unreachable(ValueError(f"unrecognized verdict: {name!r}"), inputs)
+
+ if name == _VERDICT_BLOCK:
+ raise GuardrailRaisedException(
+ guardrail_name=GUARDRAIL_NAME,
+ message=verdict.get("message") or _DEFAULT_BLOCK_MESSAGE,
+ should_wrap_with_default_message=False,
+ blocked_content=True,
+ )
+
+ if name == _VERDICT_DETECT:
+ # Recorded by Alice and allowed through. The correlation id is what ties this request
+ # to that record; the evaluated text itself is never logged.
+ verbose_proxy_logger.warning(
+ "Alice guardrail: detection recorded, request allowed (correlation_id=%s, categories=%s)",
+ verdict.get("correlation_id"),
+ verdict.get("categories"),
+ )
+ return inputs
+
+ if name == _VERDICT_MASK:
+ self._apply_replacements(verdict, inputs)
+
+ return inputs
+
+ def _apply_replacements(self, verdict: AliceVerdict, inputs: GenericGuardrailAPIInputs) -> None:
+ """
+ Write each replacement onto the text it names.
+
+ Only `texts` is touched. The chat translation layer maps a returned `texts` list back onto
+ the request positionally, but takes a different branch entirely when `structured_messages`
+ comes back as a new object — which would drop these edits.
+
+ All-or-nothing: a single out-of-range or malformed replacement blocks the whole verdict
+ rather than being silently skipped, so content Alice meant to replace can never reach the
+ model unmasked alongside content that was replaced.
+ """
+ texts: Final = inputs.get("texts") or [] # mutable-ok: empty-list fallback, replaced wholesale below
+ replacements: Final = verdict.get("replacements") or [] # mutable-ok: empty-list fallback for iteration only
+
+ if not replacements:
+ raise self._mask_rejected(verdict)
+
+ for replacement in replacements:
+ index = replacement.get("index")
+ text = replacement.get("text")
+ if not (isinstance(index, int) and isinstance(text, str) and 0 <= index < len(texts)):
+ raise self._mask_rejected(verdict)
+ texts[index] = text # mutable-ok: item assignment into the local working copy above
+
+ inputs["texts"] = texts
+
+ def _mask_rejected(self, verdict: AliceVerdict) -> GuardrailRaisedException:
+ """A MASK verdict that cannot be applied in full is refused outright, never partially —
+ see `_apply_replacements`."""
+ return GuardrailRaisedException(
+ guardrail_name=GUARDRAIL_NAME,
+ message=verdict.get("message") or _DEFAULT_BLOCK_MESSAGE,
+ should_wrap_with_default_message=False,
+ blocked_content=True,
+ )
+
+ def _on_unreachable(self, error: Exception, inputs: GenericGuardrailAPIInputs) -> GenericGuardrailAPIInputs:
+ """Apply the configured policy when Alice cannot be reached or cannot be understood."""
+ if self.unreachable_fallback == "fail_open":
+ verbose_proxy_logger.critical(
+ "Alice guardrail unreachable, allowing request per unreachable_fallback: %s",
+ error,
+ )
+ return inputs
+ raise GuardrailRaisedException(
+ guardrail_name=GUARDRAIL_NAME,
+ message="Alice guardrail is unavailable and this request cannot be checked",
+ should_wrap_with_default_message=False,
+ ) from error
+
+ @staticmethod
+ def get_config_model() -> type | None:
+ from litellm.types.proxy.guardrails.guardrail_hooks.alice import (
+ AliceGuardrailConfigModel,
+ )
+
+ return AliceGuardrailConfigModel
+
+
+def _json_safe(
+ value: object,
+ depth: int = 0,
+ seen: frozenset[int] = frozenset(),
+ strip_keys: frozenset[str] = frozenset(),
+) -> object:
+ """
+ Copy `value` into something `json.dumps` accepts, dropping only what cannot cross.
+
+ `request_data` carries live Python objects — an OpenTelemetry span among them — so it cannot
+ be serialized as it stands. What is dropped is decided by a mechanical rule rather than a
+ field list: a list drifts from what the far side needs, a rule cannot. Serializing naively
+ raises, and that error would be read as "guardrail unavailable" on every single request.
+
+ `strip_keys` drops a dict key by name at every depth it appears, not just the root — a caller
+ passes `_CREDENTIAL_KEYS_TO_STRIP` here so a credential nested under any path is caught the
+ same way a top-level one is, without maintaining a list of paths. The source object is never
+ mutated: every branch below builds a new container.
+ """
+ if isinstance(value, (str, int, float, bool)) or value is None:
+ return value
+ if depth >= _MAX_DEPTH or id(value) in seen:
+ return None
+
+ nested: Final = seen | {id(value)} # mutable-ok: one-shot set literal, unioned into a frozenset immediately
+
+ if isinstance(value, dict):
+ out: dict[str, object] = {} # mutable-ok: bounded accumulator local to this call, never escapes as-is
+ for key, item in list(value.items())[:_MAX_ITEMS]: # mutable-ok: list() only to slice an unordered view
+ if isinstance(key, str) and key not in strip_keys:
+ out[key] = _json_safe(item, depth + 1, nested, strip_keys)
+ return out
+
+ if isinstance(value, (list, tuple, set, frozenset)):
+ return [ # mutable-ok: return value is a one-shot list, discarded by the caller after use
+ _json_safe(item, depth + 1, nested, strip_keys)
+ for item in list(value)[:_MAX_ITEMS] # mutable-ok: list() only to slice an unordered view
+ ]
+
+ dump: Final = getattr(value, "model_dump", None)
+ if callable(dump):
+ try:
+ return _json_safe(dump(mode="json"), depth + 1, nested, strip_keys)
+ except Exception: # noqa: BLE001 # a model that will not dump is one we drop
+ return None
+
+ # Everything json.dumps handles natively — str, int, float, bool, None, dict, list — is
+ # caught above, and a dict/list subclass is caught by isinstance. So whatever reaches here
+ # (bytes, datetime, an OpenTelemetry span) cannot cross the wire.
+ return None
diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py
index 4cf4fa62eff..c17103da890 100644
--- a/litellm/types/guardrails.py
+++ b/litellm/types/guardrails.py
@@ -136,6 +136,7 @@ class SupportedGuardrailIntegrations(Enum):
HEADROOM = "headroom"
COMPRESR = "compresr"
STRAIKER = "straiker"
+ ALICE = "alice"
class Role(Enum):
diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/alice.py b/litellm/types/proxy/guardrails/guardrail_hooks/alice.py
new file mode 100644
index 00000000000..73d31673dab
--- /dev/null
+++ b/litellm/types/proxy/guardrails/guardrail_hooks/alice.py
@@ -0,0 +1,21 @@
+from pydantic import Field
+
+from .base import GuardrailConfigModel
+
+
+class AliceGuardrailConfigModel(GuardrailConfigModel):
+ api_key: str | None = Field(
+ default=None,
+ description=("The API key for Alice. If not provided, the `ALICE_API_KEY` environment variable is checked."),
+ )
+ api_base: str | None = Field(
+ default=None,
+ description=(
+ "The API base URL for Alice. If not provided, the `ALICE_API_BASE` environment "
+ "variable is checked, then `https://api.alice.io`."
+ ),
+ )
+
+ @staticmethod
+ def ui_friendly_name() -> str:
+ return "Alice"
diff --git a/ruff-strict.toml b/ruff-strict.toml
index 7afc5da71ee..ae092bdde7d 100644
--- a/ruff-strict.toml
+++ b/ruff-strict.toml
@@ -26,6 +26,10 @@ external = [
# caught a real mismatch, confirming Any is correct here, not a shortcut.
"litellm/litellm_core_utils/litellm_logging.py" = ["ANN401"]
"litellm/utils.py" = ["ANN401"]
+# `**kwargs` forwards verbatim to CustomGuardrail.__init__, whose param list is wide and
+# grows over time; typing it concretely (`object`) broke that forwarding call outright —
+# basedpyright turned every named param into a reportArgumentType error. Any is correct here.
+"litellm/proxy/guardrails/guardrail_hooks/alice/alice.py" = ["ANN401"]
[lint.mccabe]
max-complexity = 15
diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py
index 37e940460f6..790956156b0 100644
--- a/tests/code_coverage_tests/recursive_detector.py
+++ b/tests/code_coverage_tests/recursive_detector.py
@@ -63,6 +63,7 @@ IGNORE_FUNCTIONS = [
"json_unrewritable_labels", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); returns the None sentinel at the cap so the caller blocks.
"_flatten_form_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible).
"_flatten_form_data_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible).
+ "_json_safe", # max depth set (_MAX_DEPTH) plus a seen-ids cycle guard for self-referential input.
]
diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_alice.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_alice.py
new file mode 100644
index 00000000000..fd2e86ccde8
--- /dev/null
+++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_alice.py
@@ -0,0 +1,614 @@
+import json
+import os
+from copy import deepcopy
+from unittest.mock import AsyncMock
+
+import httpx
+import pytest
+from httpx import Request, Response
+
+import litellm
+from litellm.exceptions import GuardrailRaisedException
+from litellm.proxy.guardrails.guardrail_hooks.alice.alice import (
+ GUARDRAIL_NAME,
+ AliceGuardrail,
+ AliceGuardrailMissingSecrets,
+ _json_safe,
+)
+from litellm.proxy.guardrails.init_guardrails import init_guardrails_v2
+
+
+def _guardrail(**overrides: object) -> AliceGuardrail:
+ params: dict[str, object] = {"api_key": "test-key", "guardrail_name": "alice", "event_hook": "pre_call"}
+ params.update(overrides)
+ return AliceGuardrail(**params)
+
+
+def _verdict(payload: dict[str, object], status_code: int = 200) -> Response:
+ return Response(
+ status_code=status_code,
+ json=payload,
+ request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"),
+ )
+
+
+def test_alice_guardrail_config(monkeypatch: pytest.MonkeyPatch):
+ """Should register through init_guardrails_v2 like any other provider."""
+ monkeypatch.setattr(litellm, "guardrail_name_config_map", {})
+ monkeypatch.setenv("ALICE_API_KEY", "test-key")
+
+ init_guardrails_v2(
+ all_guardrails=[
+ {
+ "guardrail_name": "alice",
+ "litellm_params": {"guardrail": "alice", "mode": "pre_call", "default_on": True},
+ }
+ ],
+ config_file_path="",
+ )
+
+ registered = [cb for cb in litellm.callbacks if isinstance(cb, AliceGuardrail)]
+ assert len(registered) == 1
+ assert registered[0].guardrail_name == "alice"
+
+
+class TestAliceGuardrailInitialization:
+ def setup_method(self):
+ for key in ("ALICE_API_KEY", "ALICE_API_BASE"):
+ os.environ.pop(key, None)
+
+ def test_missing_api_key_raises(self):
+ with pytest.raises(AliceGuardrailMissingSecrets, match="API key"):
+ AliceGuardrail(guardrail_name="alice", event_hook="pre_call")
+
+ def test_reads_credentials_from_environment(self, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setenv("ALICE_API_KEY", "env-key")
+ monkeypatch.setenv("ALICE_API_BASE", "https://env.alice.test")
+
+ guardrail = AliceGuardrail(guardrail_name="alice", event_hook="pre_call")
+
+ assert guardrail.alice_api_key == "env-key"
+ assert guardrail.api_base == "https://env.alice.test/v2/evaluate/litellm"
+
+ def test_defaults_the_api_base(self):
+ assert _guardrail().api_base == "https://api.alice.io/v2/evaluate/litellm"
+
+ def test_trailing_slash_does_not_double_up(self):
+ assert _guardrail(api_base="https://api.alice.io/").api_base == ("https://api.alice.io/v2/evaluate/litellm")
+
+
+class TestAliceForwarding:
+ """The hook's arguments cross the wire as they were received — nothing selected, nothing
+ renamed — except the caller's raw credentials, which are stripped before request_data is
+ serialized (see TestAliceCredentialStripping)."""
+
+ @pytest.mark.asyncio
+ async def test_forwards_the_hook_arguments_verbatim(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []}))
+ inputs = {"texts": ["hello"], "structured_messages": [{"role": "user", "content": "hello"}]}
+ request_data = {"model": "gpt-4o", "metadata": {"user_api_key_alias": "payments-bot"}}
+ # Snapshot before the call: @log_guardrail_information writes its own entry into
+ # request_data["metadata"] afterwards, so the original is no longer what was sent.
+ sent_inputs = deepcopy(inputs)
+ sent_request_data = deepcopy(request_data)
+
+ await guardrail.apply_guardrail(inputs=inputs, request_data=request_data, input_type="request")
+
+ body = guardrail.async_handler.post.call_args.kwargs["json"]
+ assert body["input_type"] == "request"
+ assert body["inputs"] == sent_inputs
+ assert body["request_data"] == sent_request_data
+
+ @pytest.mark.asyncio
+ async def test_sends_the_credential(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []}))
+
+ await guardrail.apply_guardrail(inputs={"texts": ["hi"]}, request_data={}, input_type="request")
+
+ assert guardrail.async_handler.post.call_args.kwargs["headers"]["af-api-key"] == "test-key"
+
+ @pytest.mark.asyncio
+ async def test_marks_a_completion_as_a_response(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []}))
+
+ await guardrail.apply_guardrail(inputs={"texts": ["answer"]}, request_data={}, input_type="response")
+
+ assert guardrail.async_handler.post.call_args.kwargs["json"]["input_type"] == "response"
+
+ @pytest.mark.asyncio
+ async def test_nothing_selectable_reaches_no_evaluation(self):
+ """No texts, images, tools, tool_calls, or structured_messages: genuinely nothing to send."""
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock()
+
+ result = await guardrail.apply_guardrail(inputs={"texts": []}, request_data={}, input_type="request")
+
+ assert result == {"texts": []}
+ guardrail.async_handler.post.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_tool_calls_only_still_reaches_alice(self):
+ """A batch with empty texts but populated tool_calls is still a selection decision Alice
+ should make, not the plugin — see the class docstring."""
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []}))
+ inputs = {"texts": [], "tool_calls": [{"id": "call_1", "function": {"name": "get_weather"}}]}
+
+ await guardrail.apply_guardrail(inputs=inputs, request_data={}, input_type="request")
+
+ guardrail.async_handler.post.assert_called_once()
+ assert guardrail.async_handler.post.call_args.kwargs["json"]["inputs"]["tool_calls"] == inputs["tool_calls"]
+
+ @pytest.mark.asyncio
+ async def test_images_only_still_reaches_alice(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []}))
+
+ await guardrail.apply_guardrail(
+ inputs={"texts": [], "images": ["data:image/png;base64,abc"]}, request_data={}, input_type="request"
+ )
+
+ guardrail.async_handler.post.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_structured_messages_only_still_reaches_alice(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []}))
+
+ await guardrail.apply_guardrail(
+ inputs={"texts": [], "structured_messages": [{"role": "user", "content": []}]},
+ request_data={},
+ input_type="request",
+ )
+
+ guardrail.async_handler.post.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_makes_exactly_one_attempt(self):
+ guardrail = _guardrail(unreachable_fallback="fail_open")
+ guardrail.async_handler.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
+
+ await guardrail.apply_guardrail(inputs={"texts": ["hi"]}, request_data={}, input_type="request")
+
+ assert guardrail.async_handler.post.call_count == 1
+
+
+class TestAliceCredentialStripping:
+ """request_data's raw-credential keys never leave the process."""
+
+ @pytest.mark.asyncio
+ async def test_secret_fields_and_api_key_are_stripped(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []}))
+ request_data = {
+ "model": "gpt-4o",
+ "api_key": "sk-forwarded-provider-secret",
+ "secret_fields": {"raw_headers": {"authorization": "Bearer caller-virtual-key"}},
+ "metadata": {"user_api_key_alias": "payments-bot"},
+ }
+
+ await guardrail.apply_guardrail(inputs={"texts": ["hi"]}, request_data=request_data, input_type="request")
+
+ sent_request_data = guardrail.async_handler.post.call_args.kwargs["json"]["request_data"]
+ assert "secret_fields" not in sent_request_data
+ assert "api_key" not in sent_request_data
+ assert sent_request_data == {"model": "gpt-4o", "metadata": {"user_api_key_alias": "payments-bot"}}
+
+ @pytest.mark.asyncio
+ async def test_nested_credentials_are_stripped_at_every_depth(self):
+ """Shaped after a real captured Claude Code payload: the caller's Authorization/x-api-key
+ lives under several independent nesting paths, none of which are the root."""
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []}))
+ request_data = {
+ "model": "claude-3-5-sonnet",
+ "secret_fields": {"raw_headers": {"authorization": "Bearer caller-virtual-key"}},
+ "provider_specific_header": {"extra_headers": {"authorization": "sk-ant-oat01-nested-oauth"}},
+ "proxy_server_request": {
+ "url": "/v1/messages",
+ "headers": {"authorization": "Bearer inbound-caller-secret", "x-request-id": "req-1"},
+ "body": {
+ "model": "claude-3-5-sonnet",
+ "metadata": {"headers": {"authorization": "Bearer body-metadata-secret"}},
+ },
+ },
+ "metadata": {
+ "user_api_key_alias": "payments-bot",
+ "headers": {"authorization": "Bearer metadata-secret"},
+ "requester_metadata": {"headers": {"authorization": "Bearer requester-metadata-secret"}},
+ },
+ "litellm_metadata": {"headers": {"authorization": "Bearer litellm-metadata-secret"}},
+ }
+
+ await guardrail.apply_guardrail(inputs={"texts": ["hi"]}, request_data=request_data, input_type="request")
+
+ posted_body = guardrail.async_handler.post.call_args.kwargs["json"]
+ serialized = json.dumps(posted_body)
+ assert "authorization" not in serialized.lower()
+ assert "caller-virtual-key" not in serialized
+ assert "nested-oauth" not in serialized
+ assert "inbound-caller-secret" not in serialized
+ assert "body-metadata-secret" not in serialized
+ assert "metadata-secret" not in serialized
+ assert "requester-metadata-secret" not in serialized
+ assert "litellm-metadata-secret" not in serialized
+
+ sent_request_data = posted_body["request_data"]
+ assert sent_request_data["model"] == "claude-3-5-sonnet"
+ assert sent_request_data["proxy_server_request"]["url"] == "/v1/messages"
+ assert "headers" not in sent_request_data["proxy_server_request"]
+ assert sent_request_data["proxy_server_request"]["body"]["model"] == "claude-3-5-sonnet"
+ assert "headers" not in sent_request_data["proxy_server_request"]["body"]["metadata"]
+ assert sent_request_data["metadata"]["user_api_key_alias"] == "payments-bot"
+ assert "headers" not in sent_request_data["metadata"]
+ assert "requester_metadata" in sent_request_data["metadata"]
+ assert "headers" not in sent_request_data["metadata"]["requester_metadata"]
+ assert "headers" not in sent_request_data["litellm_metadata"]
+ assert "secret_fields" not in sent_request_data
+ assert "provider_specific_header" not in sent_request_data
+
+ @pytest.mark.asyncio
+ async def test_the_original_request_data_is_not_mutated(self):
+ """Stripping must only affect the outbound copy — api_key still has to reach the
+ provider, and secret_fields still has to reach the rest of the request pipeline."""
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []}))
+ request_data = {"api_key": "sk-forwarded-provider-secret", "secret_fields": {"raw_headers": {}}}
+
+ await guardrail.apply_guardrail(inputs={"texts": ["hi"]}, request_data=request_data, input_type="request")
+
+ assert request_data["api_key"] == "sk-forwarded-provider-secret"
+ assert request_data["secret_fields"] == {"raw_headers": {}}
+
+
+class TestAliceVerdicts:
+ @pytest.mark.asyncio
+ async def test_allow_leaves_the_inputs_untouched(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "ALLOW", "categories": []}))
+
+ result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
+
+ assert result["texts"] == ["hello"]
+
+ @pytest.mark.asyncio
+ async def test_block_surfaces_the_policy_message(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(
+ return_value=_verdict(
+ {
+ "verdict": "BLOCK",
+ "categories": ["self_harm"],
+ "correlation_id": "c1",
+ "message": "Blocked by your organization's policy",
+ }
+ )
+ )
+
+ with pytest.raises(GuardrailRaisedException) as error:
+ await guardrail.apply_guardrail(inputs={"texts": ["bad"]}, request_data={}, input_type="request")
+
+ assert "Blocked by your organization's policy" in str(error.value)
+
+ @pytest.mark.asyncio
+ async def test_block_without_a_message_still_blocks(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "BLOCK", "categories": []}))
+
+ with pytest.raises(GuardrailRaisedException):
+ await guardrail.apply_guardrail(inputs={"texts": ["bad"]}, request_data={}, input_type="request")
+
+ @pytest.mark.asyncio
+ async def test_mask_substitutes_by_position(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(
+ return_value=_verdict(
+ {
+ "verdict": "MASK",
+ "categories": ["pii"],
+ "replacements": [{"index": 1, "text": "my ssn is ***"}],
+ }
+ )
+ )
+
+ result = await guardrail.apply_guardrail(
+ inputs={"texts": ["untouched", "my ssn is 123-45-6789"]},
+ request_data={},
+ input_type="request",
+ )
+
+ assert result["texts"] == ["untouched", "my ssn is ***"]
+
+ @pytest.mark.asyncio
+ async def test_mask_that_lands_nowhere_blocks(self):
+ """A mask that wrote nothing would let the text through under a verdict that said not to."""
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(
+ return_value=_verdict({"verdict": "MASK", "categories": [], "replacements": [{"index": 9, "text": "***"}]})
+ )
+
+ with pytest.raises(GuardrailRaisedException):
+ await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
+
+ @pytest.mark.asyncio
+ async def test_mask_with_no_replacements_blocks(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(return_value=_verdict({"verdict": "MASK", "categories": []}))
+
+ with pytest.raises(GuardrailRaisedException):
+ await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
+
+ @pytest.mark.asyncio
+ async def test_mask_with_one_invalid_replacement_blocks_entirely(self):
+ """A mixed valid/invalid replacement list must not let the valid half through: that
+ would leave the content named by the invalid entry unmasked while looking like success."""
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(
+ return_value=_verdict(
+ {
+ "verdict": "MASK",
+ "categories": ["pii"],
+ "replacements": [{"index": 0, "text": "***"}, {"index": 9, "text": "***"}],
+ }
+ )
+ )
+
+ with pytest.raises(GuardrailRaisedException):
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["my ssn is 123-45-6789"]}, request_data={}, input_type="request"
+ )
+
+ @pytest.mark.asyncio
+ async def test_mask_leaves_structured_messages_identical(self):
+ """A new structured_messages object makes the translation layer skip the texts write-back."""
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(
+ return_value=_verdict({"verdict": "MASK", "categories": [], "replacements": [{"index": 0, "text": "***"}]})
+ )
+ messages = [{"role": "user", "content": "secret"}]
+
+ result = await guardrail.apply_guardrail(
+ inputs={"texts": ["secret"], "structured_messages": messages},
+ request_data={},
+ input_type="request",
+ )
+
+ assert result["structured_messages"] is messages
+
+ @pytest.mark.asyncio
+ async def test_detect_allows_and_leaves_the_text_alone(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(
+ return_value=_verdict({"verdict": "DETECT", "categories": ["profanity"], "correlation_id": "c1"})
+ )
+
+ result = await guardrail.apply_guardrail(inputs={"texts": ["mild"]}, request_data={}, input_type="request")
+
+ assert result["texts"] == ["mild"]
+
+
+class TestAliceUnreachable:
+ @pytest.mark.parametrize(
+ "failure",
+ [
+ pytest.param({"side_effect": httpx.ConnectError("refused")}, id="connect-error"),
+ pytest.param({"return_value": _verdict({"verdict": "MAYBE"})}, id="unrecognized-verdict"),
+ pytest.param({"return_value": _verdict({})}, id="no-verdict"),
+ ],
+ )
+ @pytest.mark.asyncio
+ async def test_fails_closed_by_default(self, failure: dict):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(**failure)
+
+ with pytest.raises(GuardrailRaisedException, match="unavailable"):
+ await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
+
+ @pytest.mark.asyncio
+ async def test_fails_open_when_configured(self):
+ guardrail = _guardrail(unreachable_fallback="fail_open")
+ guardrail.async_handler.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
+
+ result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
+
+ assert result["texts"] == ["hello"]
+
+
+class TestAliceTransportFailures:
+ """Every path out of the HTTP call, since each decides whether traffic flows unscreened."""
+
+ @pytest.mark.asyncio
+ async def test_a_timeout_is_unreachable(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(
+ side_effect=litellm.exceptions.Timeout(message="slow", model="gpt-4o", llm_provider="openai")
+ )
+
+ with pytest.raises(GuardrailRaisedException, match="unavailable"):
+ await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
+
+ @pytest.mark.parametrize("status", [500, 502, 503, 504])
+ @pytest.mark.asyncio
+ async def test_upstream_5xx_is_unreachable(self, status: int):
+ guardrail = _guardrail(unreachable_fallback="fail_open")
+ guardrail.async_handler.post = AsyncMock(
+ side_effect=httpx.HTTPStatusError(
+ "server error",
+ request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"),
+ response=_verdict({}, status_code=status),
+ )
+ )
+
+ result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
+
+ assert result["texts"] == ["hello"]
+
+ @pytest.mark.asyncio
+ async def test_a_500_fails_closed_by_default(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(
+ side_effect=httpx.HTTPStatusError(
+ "server error",
+ request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"),
+ response=_verdict({}, status_code=500),
+ )
+ )
+
+ with pytest.raises(GuardrailRaisedException, match="unavailable"):
+ await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
+
+ @pytest.mark.asyncio
+ async def test_a_4xx_is_not_treated_as_unreachable(self):
+ """A rejected credential is our misconfiguration, not an outage — it must not fail open."""
+ guardrail = _guardrail(unreachable_fallback="fail_open")
+ guardrail.async_handler.post = AsyncMock(
+ side_effect=httpx.HTTPStatusError(
+ "unauthorized",
+ request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"),
+ response=_verdict({}, status_code=401),
+ )
+ )
+
+ with pytest.raises(httpx.HTTPStatusError):
+ await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
+
+ @pytest.mark.asyncio
+ async def test_a_non_object_body_fails_closed_by_default(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(
+ return_value=Response(
+ status_code=200,
+ json=["not", "an", "object"],
+ request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"),
+ )
+ )
+
+ with pytest.raises(GuardrailRaisedException, match="unavailable"):
+ await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
+
+ @pytest.mark.asyncio
+ async def test_a_non_object_body_fails_open_when_configured(self):
+ guardrail = _guardrail(unreachable_fallback="fail_open")
+ guardrail.async_handler.post = AsyncMock(
+ return_value=Response(
+ status_code=200,
+ json=["not", "an", "object"],
+ request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"),
+ )
+ )
+
+ result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
+
+ assert result["texts"] == ["hello"]
+
+ @pytest.mark.asyncio
+ async def test_malformed_json_fails_closed_by_default(self):
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(
+ return_value=Response(
+ status_code=200,
+ content=b"not json",
+ request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"),
+ )
+ )
+
+ with pytest.raises(GuardrailRaisedException, match="unavailable"):
+ await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
+
+ @pytest.mark.asyncio
+ async def test_malformed_json_fails_open_when_configured(self):
+ guardrail = _guardrail(unreachable_fallback="fail_open")
+ guardrail.async_handler.post = AsyncMock(
+ return_value=Response(
+ status_code=200,
+ content=b"not json",
+ request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"),
+ )
+ )
+
+ result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
+
+ assert result["texts"] == ["hello"]
+
+ @pytest.mark.asyncio
+ async def test_an_undecodable_body_fails_closed_by_default(self):
+ """UnicodeDecodeError is a sibling of JSONDecodeError under ValueError, not a subclass."""
+ guardrail = _guardrail()
+ guardrail.async_handler.post = AsyncMock(
+ return_value=Response(
+ status_code=200,
+ content=b"\xff\xfe not utf-8",
+ request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"),
+ )
+ )
+
+ with pytest.raises(GuardrailRaisedException, match="unavailable"):
+ await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
+
+ @pytest.mark.asyncio
+ async def test_an_undecodable_body_fails_open_when_configured(self):
+ guardrail = _guardrail(unreachable_fallback="fail_open")
+ guardrail.async_handler.post = AsyncMock(
+ return_value=Response(
+ status_code=200,
+ content=b"\xff\xfe not utf-8",
+ request=Request("POST", "https://api.alice.io/v2/evaluate/litellm"),
+ )
+ )
+
+ result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
+
+ assert result["texts"] == ["hello"]
+
+
+class TestAliceSerialization:
+ """`request_data` carries live objects, so it cannot be posted as it stands."""
+
+ def test_drops_what_cannot_serialize_and_keeps_the_rest(self):
+ class Span:
+ pass
+
+ result = _json_safe({"model": "x", "metadata": {"span": Span(), "user": "u1"}, "n": 1})
+
+ assert result == {"model": "x", "metadata": {"span": None, "user": "u1"}, "n": 1}
+
+ def test_survives_a_cycle(self):
+ data: dict = {"a": 1}
+ data["self"] = data
+
+ assert _json_safe(data) == {"a": 1, "self": None}
+
+ def test_drops_a_model_that_will_not_dump(self):
+ class Stubborn:
+ def model_dump(self, mode: str = "python") -> dict:
+ raise RuntimeError("cannot serialise")
+
+ assert _json_safe({"m": Stubborn()}) == {"m": None}
+
+ def test_drops_a_bare_unserialisable_value(self):
+ class Span:
+ pass
+
+ assert _json_safe(Span()) is None
+
+ def test_dumps_pydantic_models(self):
+ from pydantic import BaseModel
+
+ class Model(BaseModel):
+ name: str
+
+ assert _json_safe({"m": Model(name="x")}) == {"m": {"name": "x"}}
+
+
+def test_config_model_is_exposed_for_the_ui():
+ config_model = AliceGuardrail.get_config_model()
+
+ assert config_model is not None
+ assert config_model.ui_friendly_name() == "Alice"
+
+
+def test_guardrail_name_constant():
+ assert GUARDRAIL_NAME == "alice"
diff --git a/ui/litellm-dashboard/public/assets/logos/alice.svg b/ui/litellm-dashboard/public/assets/logos/alice.svg
new file mode 100644
index 00000000000..f18f887b98c
--- /dev/null
+++ b/ui/litellm-dashboard/public/assets/logos/alice.svg
@@ -0,0 +1,4 @@
+
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_configs.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_configs.ts
index 03cfeed42ff..7785a8e44ab 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_configs.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_configs.ts
@@ -312,4 +312,10 @@ export const GUARDRAIL_PRESETS: Record = {
mode: "pre_call",
defaultOn: false,
},
+ alice: {
+ provider: "Alice",
+ guardrailNameSuggestion: "Alice",
+ mode: "pre_call",
+ defaultOn: false,
+ },
};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts
index 13909e48185..1e486639840 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts
@@ -27,6 +27,7 @@ const EXPECTED_PARTNER_LOGO_FILES: Record = {
deepkeep: "deepkeep.svg",
repelloai: "repelloai.png",
straiker: "straiker.svg",
+ alice: "alice.svg",
};
describe("guardrail_garden_data logos", () => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts
index 744af89a357..931b3a111d8 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts
@@ -464,6 +464,16 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
tags: ["Agentic", "Prompt Injection", "Tool Misuse", "MCP", "Skills"],
providerKey: "Straiker",
},
+ {
+ id: "alice",
+ name: "Alice",
+ description:
+ "Policy-based guardrails for prompts and model responses, evaluated per application so one proxy can enforce a different policy set per team or product.",
+ category: "partner",
+ logo: guardrailLogoMap["Alice"],
+ tags: ["Content Moderation", "Prompt Injection", "PII", "Policy"],
+ providerKey: "Alice",
+ },
];
export const ALL_CARDS = [...LITELLM_CONTENT_FILTER_CARDS, ...PARTNER_GUARDRAIL_CARDS];
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx
index 83038b8e0e7..c1f2ddcf51c 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx
@@ -1,5 +1,6 @@
import aimSecurityLogo from "../../../../../public/assets/logos/aim_security.jpeg";
import aktoLogo from "../../../../../public/assets/logos/akto.svg";
+import aliceLogo from "../../../../../public/assets/logos/alice.svg";
import aporiaLogo from "../../../../../public/assets/logos/aporia.png";
import bedrockLogo from "../../../../../public/assets/logos/bedrock.svg";
import catoNetworksLogo from "../../../../../public/assets/logos/cato_networks.svg";
@@ -83,6 +84,7 @@ export const guardrail_provider_map: Record = {
Deepkeep: "deepkeep",
QostodianNexus: "qostodian_nexus",
Repelloai: "repelloai",
+ Alice: "alice",
};
// Function to populate provider map from API response - updates the original map
@@ -204,6 +206,7 @@ export const guardrailLogoMap = {
"Qostodian Nexus": qohashLogo.src,
"RepelloAI Argus": repelloAiLogo.src,
Straiker: straikerLogo.src,
+ Alice: aliceLogo.src,
} satisfies Record;
export const getGuardrailLogo = (displayName: string): string | undefined =>