refactor(guardrails): split the privaite hook into typed modules

The hook predates the lint gates and added around 400 violations on its own, mostly Any, missing Final and in-place list building. It now follows the llm_shield_proxy layout with payload, request_walk, engine and restore modules, and reaches the privaite package through runtime-checkable protocols since that package is absent from the type-check env

Behavior is unchanged. The existing tests pass with two adjustments, tuples where the hook used to hold lists, and the initializer tests building a real LitellmParams instead of a SimpleNamespace
This commit is contained in:
crp4222 2026-10-21 12:59:55 +02:00
parent 5939dda0aa
commit e9ea4b6021
9 changed files with 935 additions and 799 deletions

View file

@ -1,4 +1,6 @@
from typing import TYPE_CHECKING
from __future__ import annotations
from typing import TYPE_CHECKING, Final
from litellm.types.guardrails import SupportedGuardrailIntegrations
@ -8,26 +10,26 @@ if TYPE_CHECKING:
from litellm.types.guardrails import Guardrail, LitellmParams
def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"):
def initialize_guardrail(litellm_params: LitellmParams, guardrail: Guardrail) -> PrivaiteGuardrail:
import litellm
_privaite_callback = PrivaiteGuardrail(
guardrail_name=guardrail.get("guardrail_name", ""),
_privaite_callback: Final = PrivaiteGuardrail(
guardrail_name=guardrail["guardrail_name"],
event_hook=litellm_params.mode,
default_on=litellm_params.default_on,
preset=getattr(litellm_params, "preset", None),
languages=getattr(litellm_params, "languages", None),
deanonymize=getattr(litellm_params, "deanonymize", True),
block_entities=getattr(litellm_params, "block_entities", None),
preset=litellm_params.preset,
languages=litellm_params.languages,
deanonymize=litellm_params.deanonymize,
block_entities=litellm_params.block_entities,
)
litellm.logging_callback_manager.add_litellm_callback(_privaite_callback)
return _privaite_callback
guardrail_initializer_registry = {
guardrail_initializer_registry: Final = {
SupportedGuardrailIntegrations.PRIVAITE.value: initialize_guardrail,
}
guardrail_class_registry = {
guardrail_class_registry: Final = {
SupportedGuardrailIntegrations.PRIVAITE.value: PrivaiteGuardrail,
}

View file

@ -0,0 +1,157 @@
"""The typed boundary to the optional `privaite` package.
`privaite` is not a LiteLLM dependency, so it is imported lazily and nothing it returns
has a type here. Each object it hands back is checked once against the protocol below
that names what this guardrail calls on it.
"""
import asyncio
import importlib
from collections.abc import Callable, Iterable, Mapping, Sequence
from types import MappingProxyType, ModuleType
from typing import Final, Protocol, TypeVar, runtime_checkable
INSTALL_HINT: Final = (
"The PrivAiTe guardrail requires the 'privaite' package. Install it with: pip install 'privaite>=0.7.2'"
)
SPACY_MODELS: Final = MappingProxyType(
{
"en": "en_core_web_lg",
"fr": "fr_core_news_md",
"de": "de_core_news_md",
"es": "es_core_news_md",
"it": "it_core_news_md",
"pt": "pt_core_news_md",
"nl": "nl_core_news_md",
}
)
_Narrowed = TypeVar("_Narrowed") # rebind-ok: a TypeVar cannot be declared Final
@runtime_checkable
class RestoreMapping(Protocol):
@property
def is_empty(self) -> bool: ...
def add(self, original: str, fake: str, entity_type: str) -> None: ...
def get_all_fakes(self) -> Mapping[str, str]: ...
@runtime_checkable
class PrivaiteEngine(Protocol):
async def initialize(self) -> None: ...
async def process_request(self, messages: Sequence[object]) -> tuple[Sequence[object], RestoreMapping]: ...
async def process_response(self, text: str, mapping: RestoreMapping) -> str: ...
@runtime_checkable
class RestoreBuffer(Protocol):
def feed(self, text: str) -> str: ...
def flush(self) -> str: ...
def _module(name: str) -> ModuleType:
try:
return importlib.import_module(name)
except ImportError as exc:
raise RuntimeError(INSTALL_HINT) from exc
def _factory(module: ModuleType, name: str) -> Callable[..., object]:
value: Final = getattr(module, name, None)
if not callable(value):
raise TypeError(f"{module.__name__}.{name} is missing. {INSTALL_HINT}")
return value
def _narrow(value: object, kind: type[_Narrowed]) -> _Narrowed:
if not isinstance(value, kind):
raise TypeError(f"privaite returned {type(value).__name__} where {kind.__name__} was expected")
return value
def _declares_field(model: object, name: str) -> bool:
fields: Final = getattr(model, "model_fields", None)
return isinstance(fields, Mapping) and name in fields
def _is_missing_spacy_model(exc: OSError) -> bool:
"""spaCy's E050. Permission, disk and network errors must not trigger a download."""
message: Final = str(exc)
return "E050" in message or "Can't find model" in message
async def _download_spacy_models(languages: Sequence[str]) -> None:
"""Pulls hundreds of MB through pip's synchronous machinery, so it runs off the event loop."""
download: Final = _factory(importlib.import_module("spacy.cli"), "download")
for language in languages:
model = SPACY_MODELS.get(language)
if model:
await asyncio.to_thread(download, model)
async def build_engine(
preset: str, languages: Sequence[str], deanonymize: bool, block_entities: Sequence[str]
) -> PrivaiteEngine:
schema: Final = _module("privaite.config.schema")
make_engine: Final = _factory(_module("privaite.pii.engine"), "PIIEngine")
pii_config: Final = _factory(schema, "PIIConfig")
if block_entities and not _declares_field(pii_config, "block_entities"):
# PIIConfig allows extra fields, so an older privaite would accept block_entities,
# ignore it and forward the PII. Refusing is the only safe answer.
raise RuntimeError(
"block_entities is set but the installed privaite does not support it; "
"upgrade privaite to a version that enforces pii.block_entities."
)
presidio: Final = _factory(schema, "PresidioDetectorConfig")(enabled=True, languages=list(languages))
config: Final = pii_config(
enabled=True,
preset=preset,
detectors=_factory(schema, "DetectorsConfig")(presidio=presidio),
anonymization=_factory(schema, "AnonymizationConfig")(method="placeholder"),
deanonymization=_factory(schema, "DeanonymizationConfig")(enabled=deanonymize),
block_entities=list(block_entities),
)
engine: Final = _narrow(make_engine(config), PrivaiteEngine)
try:
await engine.initialize()
except OSError as exc:
if not _is_missing_spacy_model(exc):
raise
await _download_spacy_models(languages)
retried: Final = _narrow(make_engine(config), PrivaiteEngine)
await retried.initialize()
return retried
return engine
def blocked_error() -> type[Exception]:
"""The exception privaite raises when a request carries a type listed in block_entities."""
error: Final = getattr(_module("privaite.pii.engine"), "PIIBlockedError", None)
if not isinstance(error, type) or not issubclass(error, Exception):
raise TypeError(f"privaite.pii.engine.PIIBlockedError is missing. {INSTALL_HINT}")
return error
def restore_mapping(pairs: Iterable[tuple[str, str]]) -> RestoreMapping:
"""A privaite mapping holding each (fake, original) pair."""
mapping: Final = _narrow(_factory(_module("privaite.pii.mapping"), "PIIMapping")(), RestoreMapping)
for fake, original in pairs:
mapping.add(original, fake, "PII")
return mapping
def restore_buffer_factory() -> Callable[[RestoreMapping], RestoreBuffer]:
"""privaite's streaming de-anonymizer, which holds back a placeholder split across chunks."""
make_buffer: Final = _factory(_module("privaite.streaming.buffer"), "StreamingDeAnonymizer")
def _buffer(mapping: RestoreMapping) -> RestoreBuffer:
return _narrow(make_buffer(mapping), RestoreBuffer)
return _buffer

View file

@ -0,0 +1,61 @@
import functools
from collections.abc import Callable, Sequence
from typing import Final, TypeAlias
JsonObject: TypeAlias = dict[str, object]
JsonArray: TypeAlias = list[object]
Write: TypeAlias = Callable[[str], None]
Slot: TypeAlias = tuple[str, Write]
def as_object(value: object) -> JsonObject | None:
"""`value` as a JSON object, or None. A JSON object's keys are strings, which isinstance cannot state."""
return value if isinstance(value, dict) else None # pyright: ignore[reportUnknownVariableType] # JSON keys are str
def as_array(value: object) -> JsonArray | None:
"""`value` as a JSON array, or None. See `as_object`."""
return value if isinstance(value, list) else None # pyright: ignore[reportUnknownVariableType] # see as_object
def read_field(holder: object, name: str) -> object:
"""Reads one field from a dict or from an object.
LiteLLM hands the hooks Pydantic models on some paths and plain dicts on others,
so every walk here reads both shapes.
"""
fields: Final = as_object(holder)
if fields is not None:
return fields.get(name)
return getattr(holder, name, None)
def read_list(holder: object, name: str) -> Sequence[object]:
"""Reads a list field from a dict or an object. Anything else reads as empty."""
return as_array(read_field(holder, name)) or ()
def write_field(holder: object, name: str, value: object) -> None:
"""Writes one field back into a dict or an object. Pairs with read_field."""
fields: Final = as_object(holder)
if fields is not None:
fields[name] = value
else:
setattr(holder, name, value)
def field_writer(fields: JsonObject, key: str) -> Write:
return functools.partial(fields.__setitem__, key)
def item_writer(items: JsonArray, index: int) -> Write:
return functools.partial(items.__setitem__, index)
def index_of(holder: object) -> int:
"""A streamed choice or tool call is matched across chunks by its index, 0 when absent."""
index: Final = read_field(holder, "index")
return index if isinstance(index, int) else 0

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,237 @@
import functools
from collections.abc import Iterator, Sequence
from types import MappingProxyType
from typing import Final
from .payload import (
JsonArray,
JsonObject,
Slot,
Write,
as_array,
as_object,
field_writer,
item_writer,
)
RESPONSES_OPAQUE_TYPES: Final = frozenset(
{
"reasoning",
"compaction",
"compaction_trigger",
"computer_call_output",
"image_generation_call",
"item_reference",
"mcp_list_tools",
"tool_search_call",
"tool_search_output",
"additional_tools",
}
)
"""Responses `input` items relayed whole: an opaque or binary payload, or a tool or
pointer definition with no user text. Parity with the PrivAiTe gateway scrubber."""
RESPONSES_DATA_FIELDS: Final = MappingProxyType(
{
"computer_call": ("action", "actions"),
"local_shell_call": ("action",),
"shell_call": ("action",),
"web_search_call": ("action",),
"apply_patch_call": ("operation",),
"file_search_call": ("queries", "results"),
"code_interpreter_call": ("code", "outputs"),
"program": ("code",),
"program_output": ("result",),
}
)
"""Responses items whose user data sits in known structured fields, walked leaf by leaf."""
BINARY_PART_TYPES: Final = frozenset(
{
"input_image",
"input_file",
"input_audio",
"image",
"output_image",
"computer_screenshot",
}
)
"""Parts whose payload is base64 or a file id. Scrubbing one would corrupt it without
removing anything a text detector can find."""
GENERIC_TEXT_FIELDS: Final = ("output", "arguments", "input", "text", "reason")
"""Text-bearing fields of an unknown Responses item. Every one is scanned, since an
mcp_call carries both `arguments` and `output`."""
def leaf(text: object, write: Write) -> Iterator[Slot]:
if isinstance(text, str) and text:
yield text, write
def content_slots(content: object, write: Write) -> Iterator[Slot]:
"""A role message's `content`: a bare string, or parts whose `text` and `refusal` carry user text."""
if isinstance(content, str):
yield from leaf(content, write)
return
parts: Final = as_array(content)
if parts is None:
return
for index, part in enumerate(parts):
if isinstance(part, str):
yield from leaf(part, item_writer(parts, index))
continue
fields = as_object(part)
if fields is None or fields.get("type") in BINARY_PART_TYPES:
continue
for name in ("text", "refusal"):
yield from leaf(fields.get(name), field_writer(fields, name))
def data_slots(value: object, write: Write) -> Iterator[Slot]:
"""Every string leaf of a tool payload, binary parts relayed whole and object keys never scanned.
The payload is caller controlled, so it is walked with an explicit stack rather than
by recursion. Children are pushed reversed, so leaves come out depth first.
"""
pending: Final[list[tuple[object, Write]]] = [(value, write)] # mutable-ok: local walk stack, never escapes
while pending:
node, write_back = pending.pop()
if isinstance(node, str):
yield from leaf(node, write_back)
continue
items = as_array(node)
if items is not None:
pending.extend((items[index], item_writer(items, index)) for index in range(len(items) - 1, -1, -1))
continue
fields = as_object(node)
if fields is not None and fields.get("type") not in BINARY_PART_TYPES:
pending.extend((child, field_writer(fields, key)) for key, child in reversed(fields.items()))
def _named_data_slots(fields: JsonObject, names: Sequence[str]) -> Iterator[Slot]:
for name in names:
if fields.get(name) is not None:
yield from data_slots(fields[name], field_writer(fields, name))
def _typed_item_slots(fields: JsonObject, item_type: str) -> Iterator[Slot] | None:
"""The text of an item whose user data sits in a type-specific field, or None for an unknown shape."""
if item_type == "function_call" and isinstance(fields.get("arguments"), str):
return leaf(fields["arguments"], field_writer(fields, "arguments"))
if item_type == "custom_tool_call" and isinstance(fields.get("input"), str):
return leaf(fields["input"], field_writer(fields, "input"))
data_fields: Final = RESPONSES_DATA_FIELDS.get(item_type)
if data_fields is not None:
return _named_data_slots(fields, data_fields)
if item_type.endswith("_output") and "output" in fields:
return data_slots(fields["output"], field_writer(fields, "output"))
return None
def _generic_item_slots(fields: JsonObject) -> Iterator[Slot]:
for name in GENERIC_TEXT_FIELDS:
yield from leaf(fields.get(name), field_writer(fields, name))
content: Final = fields.get("content")
if isinstance(content, str) or as_array(content) is not None:
yield from content_slots(content, field_writer(fields, "content"))
def _responses_item_slots(items: JsonArray, index: int) -> Iterator[Slot]:
item: Final = items[index]
if isinstance(item, str):
yield from leaf(item, item_writer(items, index))
return
fields: Final = as_object(item)
if fields is None:
return
if "role" in fields and "content" in fields:
yield from content_slots(fields["content"], field_writer(fields, "content"))
return
declared_type: Final = fields.get("type")
item_type: Final = declared_type if isinstance(declared_type, str) else ""
if item_type in RESPONSES_OPAQUE_TYPES:
return
typed: Final = _typed_item_slots(fields, item_type)
yield from typed if typed is not None else _generic_item_slots(fields)
def _write_top_level(data: JsonObject, key: str, value: str) -> None:
"""Writes a top-level string field, and the copy of it in the proxy's request-body snapshot.
The proxy snapshots the body by shallow copy before the hooks run. A nested list or
dict is shared with that snapshot, but a top-level string is not, so without the
second write the original would stay in proxy_server_request.body.
"""
data[key] = value # rebind-ok: the pre-call hook contract rewrites the request in place
request: Final = as_object(data.get("proxy_server_request"))
body: Final = as_object(request.get("body")) if request is not None else None
if body is not None and key in body:
body[key] = value
def top_level_writer(data: JsonObject, key: str) -> Write:
return functools.partial(_write_top_level, data, key)
def responses_input_slots(data: JsonObject) -> Iterator[Slot]:
request_input: Final = data.get("input")
if isinstance(request_input, str):
yield from leaf(request_input, top_level_writer(data, "input"))
return
items: Final = as_array(request_input)
if items is None:
return
for index in range(len(items)):
yield from _responses_item_slots(items, index)
def prompt_variables(data: JsonObject) -> JsonObject | None:
"""Responses prompt-template variables, which carry user data. A string `prompt` is /v1/completions."""
prompt: Final = as_object(data.get("prompt"))
return as_object(prompt.get("variables")) if prompt is not None else None
def prompt_variable_slots(data: JsonObject) -> Iterator[Slot]:
variables: Final = prompt_variables(data)
if variables is None:
return
for key, value in variables.items():
yield from data_slots(value, field_writer(variables, key))
def has_aux_fields(data: JsonObject) -> bool:
"""Whether the request carries a field scanned by `aux_field_slots`."""
prediction: Final = as_object(data.get("prediction"))
if prediction is not None and "content" in prediction:
return True
web_search: Final = as_object(data.get("web_search_options"))
if web_search is not None and "user_location" in web_search:
return True
suffix: Final = data.get("suffix")
if isinstance(suffix, str) and suffix:
return True
prompt: Final = data.get("prompt")
return (isinstance(prompt, str) and bool(prompt)) or bool(as_array(prompt))
def aux_field_slots(data: JsonObject) -> Iterator[Slot]:
"""Request text outside messages and input that LiteLLM forwards verbatim: chat
`prediction.content`, `web_search_options.user_location`, and the completions `suffix`
and `prompt`. A tokenized (integer array) prompt passes through unscanned."""
prediction: Final = as_object(data.get("prediction"))
if prediction is not None and prediction.get("content") is not None:
yield from data_slots(prediction["content"], field_writer(prediction, "content"))
web_search: Final = as_object(data.get("web_search_options"))
if web_search is not None and web_search.get("user_location") is not None:
yield from data_slots(web_search["user_location"], field_writer(web_search, "user_location"))
yield from leaf(data.get("suffix"), top_level_writer(data, "suffix"))
prompt: Final = data.get("prompt")
if isinstance(prompt, str):
yield from leaf(prompt, top_level_writer(data, "prompt"))
return
parts: Final = as_array(prompt)
if parts is None:
return
for index, part in enumerate(parts):
yield from leaf(part, item_writer(parts, index))

View file

@ -0,0 +1,258 @@
from __future__ import annotations
import copy
import json
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final, TypeAlias
from .engine import PrivaiteEngine, RestoreBuffer, RestoreMapping
from .payload import as_array, as_object, index_of, read_field, read_list, write_field
if TYPE_CHECKING:
from litellm.types.utils import ModelResponseStream
TEXT_FIELDS: Final = ("reasoning_content", "reasoning", "refusal")
"""Text fields restored besides `content`. A refusal can quote the request, so it carries placeholders too."""
DELTA_CHANNELS: Final = ("content", *TEXT_FIELDS, "audio", "tool_calls", "function_call")
JSON_CHANNELS: Final = frozenset({"tool", "function_call"})
"""Channels that stream JSON source text, where a restored original must arrive JSON-escaped."""
def json_escape(value: str) -> str:
"""The JSON string-literal encoding of value without its quotes, as privaite.streaming.buffer does."""
return json.dumps(value, ensure_ascii=False)[1:-1]
async def _restore_json_tree(engine: PrivaiteEngine, value: object, mapping: RestoreMapping) -> object:
"""Restores every string leaf of a parsed JSON document. Keys are left as they are, as in the core engine."""
if isinstance(value, str):
return await engine.process_response(value, mapping)
fields: Final = as_object(value)
if fields is not None:
return {key: await _restore_json_tree(engine, item, mapping) for key, item in fields.items()}
items: Final = as_array(value)
if items is not None:
return [await _restore_json_tree(engine, item, mapping) for item in items]
return value
async def restore_arguments(engine: PrivaiteEngine, arguments: str, mapping: RestoreMapping) -> str:
"""Restores a tool call's `arguments`, a JSON document inside a string.
Plain substitution would splice a raw quote, backslash or newline from the original
into a JSON string literal, and the client's parse of the arguments would fail. So
the restore runs on the parsed tree and re-encodes it, as PIIEngine does. When nothing
changes, or the string is not JSON, the plain restore is returned as is.
"""
plain: Final = await engine.process_response(arguments, mapping)
if plain == arguments:
return arguments
try:
parsed: Final[object] = json.loads(arguments) # pyright: ignore[reportAny] # json.loads is untyped
except ValueError:
return plain
return json.dumps(await _restore_json_tree(engine, parsed, mapping), ensure_ascii=False)
async def _restore_text(holder: object, name: str, engine: PrivaiteEngine, mapping: RestoreMapping) -> None:
value: Final = read_field(holder, name)
if isinstance(value, str) and value:
write_field(holder, name, await engine.process_response(value, mapping))
async def _restore_call_arguments(holder: object, engine: PrivaiteEngine, mapping: RestoreMapping) -> None:
arguments: Final = read_field(holder, "arguments")
if isinstance(arguments, str) and arguments:
write_field(holder, "arguments", await restore_arguments(engine, arguments, mapping))
async def restore_message(message: object, engine: PrivaiteEngine, mapping: RestoreMapping) -> None:
"""Restores one response message: content, the text fields, the audio transcript, and tool-call arguments."""
for name in ("content", *TEXT_FIELDS):
await _restore_text(message, name, engine, mapping)
await _restore_text(read_field(message, "audio"), "transcript", engine, mapping)
for tool_call in read_list(message, "tool_calls"):
await _restore_call_arguments(read_field(tool_call, "function"), engine, mapping)
await _restore_call_arguments(read_field(message, "function_call"), engine, mapping)
async def restore_responses_output(response: object, engine: PrivaiteEngine, mapping: RestoreMapping) -> None:
"""Restores a Responses API result: output_text blocks and function_call arguments."""
for item in read_list(response, "output"):
for block in read_list(item, "content"):
await _restore_text(block, "text", engine, mapping)
await _restore_call_arguments(item, engine, mapping)
@dataclass(frozen=True, slots=True)
class Channel:
"""One streamed segment of one choice. Each keeps its own restore buffer."""
kind: str
choice: int
tool_call: int = 0
Restore: TypeAlias = Callable[[Channel, str, bool], str]
def _append_text(holder: object, name: str, tail: str) -> None:
existing: Final = read_field(holder, name)
write_field(holder, name, (existing if isinstance(existing, str) else "") + tail)
def _append_nested(delta: object, carrier: str, name: str, tail: str) -> None:
"""Appends to delta.<carrier>.<name>, creating the carrier when a bare finish delta has none."""
holder: Final = read_field(delta, carrier)
if holder is None:
write_field(delta, carrier, {name: tail})
return
_append_text(holder, name, tail)
def _append_tool_tail(delta: object, tool_call: int, tail: str) -> None:
"""Appends an argument tail under its tool_call index, since the client reassembles arguments per index."""
calls: Final = as_array(read_field(delta, "tool_calls"))
added: Final = {"index": tool_call, "function": {"arguments": tail}}
if calls is None:
write_field(delta, "tool_calls", [added])
return
existing: Final = next((call for call in calls if index_of(call) == tool_call), None)
if existing is None:
calls.append(added)
return
_append_nested(existing, "function", "arguments", tail)
def append_tail(delta: object, channel: Channel, tail: str) -> None:
"""Appends a restore buffer's held tail onto a streamed delta."""
match channel.kind:
case "tool":
_append_tool_tail(delta, channel.tool_call, tail)
case "function_call":
_append_nested(delta, "function_call", "arguments", tail)
case "audio":
_append_nested(delta, "audio", "transcript", tail)
case _:
_append_text(delta, channel.kind, tail)
def _restore_delta_audio(delta: object, choice: int, finished: bool, restore: Restore) -> None:
"""Restores a streamed audio transcript, creating the carrier on a finish delta so a held tail is kept."""
audio: Final = read_field(delta, "audio")
transcript: Final = read_field(audio, "transcript")
fragment: Final = transcript if isinstance(transcript, str) else ""
if not fragment and not finished:
return
restored: Final = restore(Channel("audio", choice), fragment, finished)
if audio is not None:
write_field(audio, "transcript", restored)
elif restored:
write_field(delta, "audio", {"transcript": restored})
def restore_delta(delta: object, choice: int, finished: bool, restore: Restore) -> None:
"""Restores one streamed delta: content, the text fields, the audio transcript and argument fragments."""
content: Final = read_field(delta, "content")
text: Final = content if isinstance(content, str) else ""
restored: Final = restore(Channel("content", choice), text, finished)
# A fragment held back whole must not stay visible raw, but a terminal None with
# nothing held back keeps its None rather than becoming "".
if text or restored:
write_field(delta, "content", restored)
for name in TEXT_FIELDS:
value = read_field(delta, name)
if isinstance(value, str) and (value or finished):
write_field(delta, name, restore(Channel(name, choice), value, finished))
_restore_delta_audio(delta, choice, finished, restore)
for tool_call in read_list(delta, "tool_calls"):
function = read_field(tool_call, "function")
arguments = read_field(function, "arguments")
if isinstance(arguments, str) and arguments:
channel = Channel("tool", choice, index_of(tool_call))
write_field(function, "arguments", restore(channel, arguments, finished))
function_call: Final = read_field(delta, "function_call")
call_arguments: Final = read_field(function_call, "arguments")
if isinstance(call_arguments, str) and call_arguments:
write_field(function_call, "arguments", restore(Channel("function_call", choice), call_arguments, finished))
def drain_chunk(template: ModelResponseStream, channel: Channel, tail: str) -> ModelResponseStream:
"""A trailing chunk carrying one held tail, for a stream the provider closed without a finish_reason.
It is a copy of the last chunk of that choice, so id, model and object match the rest
of the stream, reduced to that one choice with its delta cleared and no usage.
"""
chunk: Final = copy.deepcopy(template)
choice: Final = next(item for item in read_list(chunk, "choices") if index_of(item) == channel.choice)
write_field(chunk, "choices", [choice])
if read_field(chunk, "usage") is not None:
write_field(chunk, "usage", None)
write_field(choice, "finish_reason", None)
delta: Final = read_field(choice, "delta")
for name in DELTA_CHANNELS:
if read_field(delta, name) is not None:
write_field(delta, name, None)
append_tail(delta, channel, tail)
return chunk
class StreamRestorer:
"""Restore state for one streamed response.
With n>1 the provider interleaves chunks of different choices, and tool-call arguments
stream as fragments per tool_call index, so every channel keeps its own buffer and a
placeholder split across chunks reassembles without mixing channels. A held tail is
flushed onto the chunk carrying finish_reason, or, when the provider closes the stream
without one, drained as trailing chunks. It is never dropped.
"""
def __init__(
self,
mapping: RestoreMapping,
json_mapping: RestoreMapping,
make_buffer: Callable[[RestoreMapping], RestoreBuffer],
) -> None:
self._mapping: Final = mapping
self._json_mapping: Final = json_mapping
self._make_buffer: Final = make_buffer
self._buffers: Final[dict[Channel, RestoreBuffer]] = {} # mutable-ok: per-stream restore state
self._last_chunk: Final[dict[int, ModelResponseStream]] = {} # mutable-ok: per-stream restore state
def restore(self, channel: Channel, text: str, finished: bool) -> str:
if channel not in self._buffers:
mapping: Final = self._json_mapping if channel.kind in JSON_CHANNELS else self._mapping
self._buffers[channel] = self._make_buffer(mapping)
restored: Final = self._buffers[channel].feed(text) if text else ""
if not finished:
return restored
return restored + self._buffers.pop(channel).flush()
def _close(self, choice: int | None) -> tuple[tuple[Channel, str], ...]:
"""Flushes and drops every buffer still open for a choice, or for all of them; returns the non-empty tails."""
closing: Final = tuple(channel for channel in self._buffers if choice is None or channel.choice == choice)
flushed: Final = tuple((channel, self._buffers.pop(channel).flush()) for channel in closing)
return tuple((channel, tail) for channel, tail in flushed if tail)
def restore_chunk(self, chunk: ModelResponseStream) -> None:
"""Restores every choice of a chunk, and appends the tails of the channels a finish delta does not carry."""
for choice in read_list(chunk, "choices"):
delta = read_field(choice, "delta")
if delta is None:
continue
index = index_of(choice)
self._last_chunk[index] = chunk
finished = read_field(choice, "finish_reason") is not None
restore_delta(delta, index, finished, self.restore)
if finished:
for channel, tail in self._close(index):
append_tail(delta, channel, tail)
def drain(self) -> Iterator[ModelResponseStream]:
for channel, tail in self._close(None):
template = self._last_chunk.get(channel.choice)
if template is not None:
yield drain_chunk(template, channel, tail)

View file

@ -50,6 +50,9 @@ from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter impor
from litellm.types.proxy.guardrails.guardrail_hooks.ovalix import (
OvalixGuardrailConfigModel,
)
from litellm.types.proxy.guardrails.guardrail_hooks.privaite import (
PrivaiteGuardrailConfigModel,
)
from litellm.types.proxy.guardrails.guardrail_hooks.promptguard import (
PromptGuardConfigModel,
)
@ -77,9 +80,6 @@ from litellm.types.proxy.guardrails.guardrail_hooks.vigil_guard import (
from litellm.types.proxy.guardrails.guardrail_hooks.wingback import (
WingbackGuardrailConfigModel,
)
from litellm.types.proxy.guardrails.guardrail_hooks.privaite import (
PrivaiteGuardrailConfigModel,
)
from litellm.types.proxy.guardrails.guardrail_hooks.xecguard import (
XecGuardConfigModel,
)

View file

@ -1,24 +1,22 @@
from typing import Optional
from pydantic import Field
from .base import GuardrailConfigModel
class PrivaiteGuardrailConfigModel(GuardrailConfigModel):
preset: Optional[str] = Field(
class PrivaiteGuardrailConfigModel(GuardrailConfigModel): # pyright: ignore[reportMissingTypeArgument] # parameterizing adds an optional_params field to the UI form
preset: str | None = Field(
default="onnx",
description=("Detection preset: 'onnx' (full suite, also detects secrets) or 'light' (faster, Presidio only)."),
)
languages: Optional[str] = Field(
languages: str | None = Field(
default="en,fr",
description="Comma-separated spaCy languages used for detection.",
)
deanonymize: Optional[bool] = Field(
deanonymize: bool | None = Field(
default=True,
description="Restore the original PII values in the response.",
)
block_entities: str | list[str] | None = Field(
block_entities: str | tuple[str, ...] | None = Field(
default=None,
description=(
"PII types to reject outright instead of masking: a list, or a "

View file

@ -241,6 +241,7 @@ def test_initialize_guardrail_registers_callback(monkeypatch):
PrivaiteGuardrail,
initialize_guardrail,
)
from litellm.types.guardrails import LitellmParams
added = []
monkeypatch.setattr(
@ -249,7 +250,8 @@ def test_initialize_guardrail_registers_callback(monkeypatch):
lambda cb: added.append(cb),
)
litellm_params = types.SimpleNamespace(
litellm_params = LitellmParams(
guardrail="privaite",
mode="pre_call",
default_on=False,
preset="light",
@ -268,17 +270,17 @@ def test_init_normalizes_params():
gr = _make_guardrail(preset="bogus", languages="en, fr ,", deanonymize="false")
assert gr.preset == "onnx"
assert gr.deanonymize is False
assert gr._languages() == ["en", "fr"]
assert gr._languages() == ("en", "fr")
# an explicit valid preset and bool are kept; empty languages fall back to
# the __init__ default of "en,fr".
gr2 = _make_guardrail(preset="light", languages="", deanonymize=True)
assert gr2.preset == "light"
assert gr2.deanonymize is True
assert gr2._languages() == ["en", "fr"]
assert gr2._languages() == ("en", "fr")
# a non-empty but content-free languages string falls back to ["en"].
assert _make_guardrail(languages=", ,")._languages() == ["en"]
assert _make_guardrail(languages=", ,")._languages() == ("en",)
def test_event_hook_always_includes_pre_and_post():
@ -808,18 +810,17 @@ def test_block_entities_config_model_field():
def test_block_entities_parsed_from_string_and_list():
assert _make_guardrail(block_entities="US_SSN, CREDIT_CARD").block_entities == [
"US_SSN",
"CREDIT_CARD",
]
assert _make_guardrail(block_entities=["EMAIL_ADDRESS"]).block_entities == ["EMAIL_ADDRESS"]
assert _make_guardrail().block_entities == []
assert _make_guardrail(block_entities="US_SSN, CREDIT_CARD").block_entities == ("US_SSN", "CREDIT_CARD")
assert _make_guardrail(block_entities=["EMAIL_ADDRESS"]).block_entities == ("EMAIL_ADDRESS",)
assert _make_guardrail().block_entities == ()
def test_initialize_guardrail_passes_block_entities():
from litellm.proxy.guardrails.guardrail_hooks.privaite import initialize_guardrail
from litellm.types.guardrails import LitellmParams
litellm_params = types.SimpleNamespace(
litellm_params = LitellmParams(
guardrail="privaite",
mode="pre_call",
default_on=False,
preset="light",
@ -828,7 +829,7 @@ def test_initialize_guardrail_passes_block_entities():
block_entities=["US_SSN"],
)
callback = initialize_guardrail(litellm_params, {"guardrail_name": "privaite"})
assert callback.block_entities == ["US_SSN"]
assert callback.block_entities == ("US_SSN",)
@pytest.mark.asyncio