feat(guardrails): add Alice guardrail (#38898)

* feat(guardrails): add Alice by ActiveFence guardrail

Adds `guardrail: alice` — policy-based guardrails for prompts and model
responses, evaluated against ActiveFence's Alice.

What makes this different from the other providers: Alice evaluates against
policies configured per *application*, and a proxy typically fronts several of
them, so the application cannot be a static config value. It is named on the
LiteLLM virtual key instead:

    curl $PROXY/key/generate -H "Authorization: Bearer $LITELLM_MASTER_KEY" \
      -d '{"key_alias": "payments-bot",
           "metadata": {"alice_app_id": "payments-bot"}}'

read via `CustomGuardrail._get_admin_metadata`, with `key_alias` as the
fallback. That helper is what makes it trustworthy: it reads whichever metadata
holder the proxy wrote the authenticated key's values into — which differs by
route — and the proxy strips caller-supplied `user_api_key_*` from both, so a
caller cannot point its own traffic at an application with laxer policies than
the one its key was issued for. A request whose key names no application is
refused rather than evaluated against a guess.

Implements `apply_guardrail` only, so pre_call, during_call, post_call and
streaming all come from UnifiedLLMGuardrails. Blocks with
GuardrailRaisedException; masks by substituting Alice's redacted text; a MASK
carrying no replacement blocks rather than passing the original through. A
verdict reporting `errors[]` is treated as a failure, not a pass — otherwise a
half-evaluated message would be allowed. `unreachable_fallback` (already on
LitellmParams) chooses fail-closed or fail-open on transport failure.

Config:

    guardrails:
      - guardrail_name: alice
        litellm_params:
          guardrail: alice
          mode: [pre_call, post_call]
          api_key: os.environ/ALICE_API_KEY

21 tests in tests/test_litellm/proxy/guardrails/guardrail_hooks/test_alice.py
cover registration, credential resolution, the app-id ladder including the
forged-metadata case, every verdict, and both unreachable policies.

No new LitellmParams field, so no schema.d.ts regeneration is needed.

* refactor(guardrails): post to Alice's LiteLLM endpoint and forward verbatim

Switches from `/v2/evaluate/message` — Alice's single-text endpoint — to
`/v2/evaluate/litellm`, which takes the hook's arguments as they arrive and
answers with a verdict.

That inverts where the work happens, and shrinks this plugin accordingly. It
now selects nothing and renames nothing: it posts `{input_type, inputs,
request_data}` and enforces `{verdict, categories, correlation_id, message,
replacements}`. 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 for every user.

The app-id resolution this plugin carried is gone with it. Alice reads the
application off the authenticated key's metadata itself, from the payload it is
handed, so the ladder here was duplicating a decision the far side already
makes. The security property is unchanged and still comes from the proxy
stripping caller-supplied `user_api_key_*` before a guardrail sees the request.

Masking is now positional — the far side chose which texts it was answering
for, so it says which by index. Only `texts` is written; a new
`structured_messages` object would make the chat translation layer skip the
`texts` write-back and silently drop the edits. A mask that lands nowhere
blocks rather than passing the original through.

`request_data` carries live Python objects (an OpenTelemetry span among them),
so `_json_safe` copies it into something serialisable by a mechanical rule
rather than a field list — a list drifts from what the far side needs, a rule
cannot. Serialising naively raises, and that error would read as "guardrail
unavailable" on every request.

26 tests, covering verbatim forwarding, each verdict, positional masking, the
`structured_messages` identity trap, both unreachable policies, and the
serialiser's handling of unserialisable values and cycles.

* fix(alice guardrail): satisfy lint and code-quality CI gates

- Bound _json_safe's recursion and register it in recursive_detector's
  ignore list (it already caps depth and dedupes cycles by id, matching
  the repo's established pattern for legitimate bounded recursion).
- Clear ruff-strict budget breaches: annotate __init__'s return type,
  raise TypeError (not ValueError) for a bad response body, type
  _json_safe's payload as object instead of Any, and file-scope-ignore
  ANN401 for **kwargs (forwarding it as object broke the call into
  CustomGuardrail.__init__, confirmed via basedpyright).
- Clear type-discipline budget breaches: suppress the construction/
  annotation checks on one-shot HTTP payloads, the module-level
  guardrail registries, and _json_safe's bounded accumulator; narrow
  AliceVerdict's list fields to tuples and _evaluate's request_data to
  Mapping[str, object] where nothing downstream mutates them.

* test(alice guardrail): assert the guardrail actually registers

The registration test called init_guardrails_v2 and asserted nothing, so it
passed whether or not the guardrail was ever registered — TQ001 in the
test-quality gate, and a fair catch: a test that cannot fail is not covering
the thing it names.

Now asserts exactly one AliceGuardrail lands in litellm.callbacks under the
configured name.

This surfaced only after the ruff-strict and type-discipline gates stopped
failing ahead of it; the lint job runs its gates in sequence, so an earlier
failure masks every later one.

* fix(alice guardrail): reach 100% patch coverage, drop the ActiveFence naming

Codecov flagged 10 uncovered lines, all of them error paths — which is where a
guardrail most needs covering, since each one decides whether traffic flows
unscreened.

Two of the ten turned out to be dead rather than untested, and are removed:

- `except GuardrailRaisedException: raise` in apply_guardrail. `_evaluate`
  raises httpx errors, Timeout and TypeError, never that — so the clause could
  never fire.
- the trailing `json.dumps` probe in `_json_safe`. Everything json.dumps
  handles natively is caught by the isinstance branches above (a dict or list
  subclass included), so anything reaching the bottom — bytes, datetime, an
  OpenTelemetry span — cannot cross the wire regardless. It now says so and
  returns None.

The rest are now tested: a timeout, 502/503/504 as unreachable, a 4xx as NOT
unreachable (a rejected credential is our misconfiguration, not an outage, and
must not fail open), a non-object response body, and a model whose model_dump
raises.

Also drops "by ActiveFence" throughout — the product is Alice — and points the
header at alice.io. `ui_friendly_name` is now "Alice", which is the key
guardrailLogoMap and the garden card look up, so all three moved together.

* fix(alice guardrail): strip caller credentials, widen unreachable detection, block partial MASK

Addresses PR review: request_data no longer forwards secret_fields.raw_headers or
the root api_key to Alice (the caller's Authorization token in the clear otherwise);
HTTP 500, malformed JSON, and a non-object body now route through the configured
unreachable_fallback instead of raising raw, so fail_open still fails open on those;
a MASK verdict with even one out-of-range replacement now blocks entirely instead of
silently letting the rest through unmasked. Also tightens request_data's type and
documents the known streaming-mask limitation on the class.

* fix(alice guardrail): strip credentials at any depth, stop filtering on texts

secret_fields/api_key/headers/provider_specific_header can appear nested
under proxy_server_request, metadata, litellm_metadata, and their
requester_metadata/body sub-paths in a real captured payload — a
top-level-only strip missed all of those. _json_safe now drops these keys
by name wherever they occur during serialization, so a new nesting path
can't reintroduce the leak.

apply_guardrail also stopped skipping the call whenever texts was empty,
even when tool_calls/images/structured_messages carried content — that
was the plugin making a selection decision Alice's design says belongs on
the far side. It now only skips when none of the selectable fields have
anything in them.

* fix(alice guardrail): route an undecodable response body through the fallback

`response.json()` raises UnicodeDecodeError when the body carries bytes that
are not valid UTF-8, and that escaped the except clause: UnicodeDecodeError is
a *sibling* of json.JSONDecodeError under ValueError, not a subclass of it, so
naming only JSONDecodeError left it uncaught. Both fallback modes surfaced a
raw decoding error instead of applying unreachable_fallback — which for a
fail_open deployment meant a hard failure where it had asked for an allow.

Named explicitly rather than widening to ValueError, so the clause still says
which three conditions it means. Tested under both policies.
This commit is contained in:
Sean Yasnogorodski 2026-09-01 22:33:39 +03:00 committed by GitHub
parent 4517e5e613
commit 8a4ba78869
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1068 additions and 0 deletions

View file

@ -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,
}

View file

@ -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

View file

@ -136,6 +136,7 @@ class SupportedGuardrailIntegrations(Enum):
HEADROOM = "headroom"
COMPRESR = "compresr"
STRAIKER = "straiker"
ALICE = "alice"
class Role(Enum):

View file

@ -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"

View file

@ -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

View file

@ -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.
]

View file

@ -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"

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="8" fill="#E686B4"/>
<path d="M12.0001 4C16.165 4.00002 19 7.1202 19 10.7146V19.1637C19 19.6256 18.6256 20 18.1637 20H16.9259C16.4641 20 16.0896 19.6256 16.0896 19.1637V11.3386C16.0896 9.31676 14.9356 6.74578 12.0001 6.74575C10.4696 6.74575 7.91038 7.76917 7.91038 11.3386V14.0344H13.801C14.2629 14.0344 14.6373 14.4088 14.6373 14.8707V15.9188C14.6373 16.3807 14.2629 16.7551 13.801 16.7551H7.91038V19.1637C7.91038 19.6256 7.53595 20 7.07406 20H5.83632C5.37443 20 5 19.6256 5 19.1637V10.7146C5 7.12018 7.83522 4 12.0001 4Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 674 B

View file

@ -312,4 +312,10 @@ export const GUARDRAIL_PRESETS: Record<string, GuardrailPreset> = {
mode: "pre_call",
defaultOn: false,
},
alice: {
provider: "Alice",
guardrailNameSuggestion: "Alice",
mode: "pre_call",
defaultOn: false,
},
};

View file

@ -27,6 +27,7 @@ const EXPECTED_PARTNER_LOGO_FILES: Record<string, string> = {
deepkeep: "deepkeep.svg",
repelloai: "repelloai.png",
straiker: "straiker.svg",
alice: "alice.svg",
};
describe("guardrail_garden_data logos", () => {

View file

@ -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];

View file

@ -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<string, string> = {
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<string, string>;
export const getGuardrailLogo = (displayName: string): string | undefined =>