mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge 85067355f5 into 7f1b0a4633
This commit is contained in:
commit
cc7c089aaa
3 changed files with 1009 additions and 5 deletions
|
|
@ -8,13 +8,19 @@
|
|||
# Thank you users! We ❤️ you! - Krrish & Ishaan
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import copy
|
||||
import functools
|
||||
import inspect
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
import itertools
|
||||
import json
|
||||
import re
|
||||
import traceback
|
||||
from collections.abc import Collection, Iterable, Iterator, Mapping, Sequence
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from pydantic import JsonValue, TypeAdapter, ValidationError
|
||||
from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm.constants import REDACTED_BY_LITELLM
|
||||
|
|
@ -23,6 +29,10 @@ from litellm.litellm_core_utils.classifier_logging import without_classifier_aud
|
|||
from litellm.litellm_core_utils.core_helpers import (
|
||||
get_metadata_variable_name_from_kwargs,
|
||||
)
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
initialize_standard_callback_dynamic_params,
|
||||
iter_client_callback_metadata_dicts,
|
||||
)
|
||||
from litellm.litellm_core_utils.served_output_texts import SERVED_OUTPUT_TEXTS_KEY
|
||||
from litellm.llms.base_llm.guardrail_translation.utils import stream_item_field, stream_item_items
|
||||
from litellm.llms.vertex_ai.common_utils import (
|
||||
|
|
@ -31,7 +41,7 @@ from litellm.llms.vertex_ai.common_utils import (
|
|||
)
|
||||
from litellm.secret_managers.main import str_to_bool
|
||||
from litellm.types.router import BaselineRouteStamp
|
||||
from litellm.types.utils import StandardCallbackDynamicParams
|
||||
from litellm.types.utils import StandardCallbackDynamicParams, StandardLoggingPayloadErrorInformation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
|
|
@ -239,6 +249,10 @@ def _as_mapping(value: object) -> Mapping[str, object] | None:
|
|||
return value if isinstance(value, Mapping) else None # pyright: ignore[reportUnknownVariableType] # keys are read one by one
|
||||
|
||||
|
||||
def _as_items(value: object) -> tuple[object, ...] | None:
|
||||
return tuple(value) if isinstance(value, (list, tuple)) else None # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] # items are walked one by one
|
||||
|
||||
|
||||
def _redact_provider_specific_fields(message: object) -> None:
|
||||
if (fields := _as_mapping(getattr(message, "provider_specific_fields", None))) is not None:
|
||||
setattr(message, "provider_specific_fields", _redacted_provider_specific_fields(fields)) # noqa: B010 # typed as object
|
||||
|
|
@ -413,6 +427,329 @@ def _redacted_responses_api_response(
|
|||
return _with_redacted_instructions(redacted) if redact_instructions else redacted
|
||||
|
||||
|
||||
_REQUEST_TEXT_KEYS: Final = ("messages", "input", "prompt", "system", "instructions")
|
||||
_STRUCTURAL_KEYS: Final = frozenset({"role", "type", "id", "tool_call_id", "name"})
|
||||
_MIN_UNQUOTED_TEXT_LENGTH: Final = 4
|
||||
_ESCAPED_CHARACTERS: Final = ('"', "\\")
|
||||
_INPUT_VALUE_LABEL: Final = "input_value="
|
||||
_INPUT_TYPE_LABEL: Final = ", input_type="
|
||||
_MAX_SCRUB_COMPARISONS: Final = 10_000_000
|
||||
_MAX_ECHO_OCCURRENCES: Final = 10_000
|
||||
_QUOTES: Final = ("'", '"')
|
||||
_ESCAPE_TAIL: Final = re.compile(r"\\(?:[abfnrtv]|x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8})\Z")
|
||||
_DIRECT_CAUSE_LINE: Final = "\nThe above exception was the direct cause of the following exception:\n\n"
|
||||
_CONTEXT_LINE: Final = "\nDuring handling of the above exception, another exception occurred:\n\n"
|
||||
|
||||
|
||||
def _child_values(value: object) -> tuple[object, ...]:
|
||||
if (mapping := _as_mapping(value)) is not None:
|
||||
return tuple(item for key, item in mapping.items() if key not in _STRUCTURAL_KEYS)
|
||||
if (items := _as_items(value)) is not None:
|
||||
return items
|
||||
if isinstance(value, BaseModel):
|
||||
return (value.model_dump(),)
|
||||
return ()
|
||||
|
||||
|
||||
def _next_level(level: tuple[object, ...], _: None) -> tuple[object, ...]:
|
||||
return tuple(itertools.chain.from_iterable(map(_child_values, level)))
|
||||
|
||||
|
||||
def request_texts(*values: object) -> frozenset[str]:
|
||||
"""
|
||||
The non-empty strings a request carries under ``values``, skipping structural fields such as ``role``
|
||||
and ``type``. A value passed more than once is walked once.
|
||||
"""
|
||||
distinct_values: Final = tuple({id(value): value for value in values}.values())
|
||||
levels: Final = itertools.takewhile(
|
||||
bool, itertools.accumulate(itertools.repeat(None), _next_level, initial=distinct_values)
|
||||
)
|
||||
return frozenset(value for value in itertools.chain.from_iterable(levels) if isinstance(value, str) and value)
|
||||
|
||||
|
||||
def failure_request_texts(model_call_details: Mapping[str, object], *originals: object) -> frozenset[str]:
|
||||
"""The request text a failure on this call can quote: its messages, input, prompt, system prompt and instructions."""
|
||||
optional_params: Final = _as_mapping(model_call_details.get("optional_params")) or {}
|
||||
return request_texts(
|
||||
*originals,
|
||||
*(model_call_details.get(key) for key in _REQUEST_TEXT_KEYS),
|
||||
*(optional_params.get(key) for key in ("system", "instructions")),
|
||||
)
|
||||
|
||||
|
||||
def _echo_forms(text: str) -> frozenset[str]:
|
||||
"""How an error can quote ``text``: verbatim, or JSON or repr escaped. Printable ASCII without a double quote
|
||||
or a backslash escapes to itself."""
|
||||
if text.isascii() and text.isprintable() and not any(character in text for character in _ESCAPED_CHARACTERS):
|
||||
return frozenset((text,))
|
||||
return frozenset({text, json.dumps(text)[1:-1], json.dumps(text, ensure_ascii=False)[1:-1], repr(text)[1:-1]})
|
||||
|
||||
|
||||
def scrub_request_text(text: str, texts: Collection[str]) -> str:
|
||||
"""
|
||||
``text`` with every quote of the request's own ``texts`` and every pydantic ``input_value`` replaced by
|
||||
``REDACTED_BY_LITELLM``, so an error that holds neither keeps its wording. A search too large for a
|
||||
failure path replaces the whole text.
|
||||
"""
|
||||
if not text or not texts:
|
||||
return text
|
||||
if len(text) * len(texts) > _MAX_SCRUB_COMPARISONS:
|
||||
return REDACTED_BY_LITELLM
|
||||
echoes: Final = sorted(
|
||||
frozenset(itertools.chain.from_iterable(_quotes_in(text, each) for each in texts)), key=len, reverse=True
|
||||
)
|
||||
label_quotes: Final = tuple(form for form in echoes if _INPUT_VALUE_LABEL in form)
|
||||
other_quotes: Final = tuple(form for form in echoes if _INPUT_VALUE_LABEL not in form)
|
||||
with_label_quotes_replaced: Final = _with_echoes_replaced(text, label_quotes)
|
||||
scrubbed: Final = (
|
||||
None
|
||||
if with_label_quotes_replaced is None
|
||||
else _with_echoes_replaced(_with_input_values_replaced(with_label_quotes_replaced), other_quotes)
|
||||
)
|
||||
return REDACTED_BY_LITELLM if scrubbed is None else scrubbed
|
||||
|
||||
|
||||
def quoted_request_texts(text: str, texts: Collection[str]) -> frozenset[str]:
|
||||
"""
|
||||
The request ``texts`` that ``scrub_request_text`` needs to scrub ``text`` as it would with all of them: those
|
||||
quoted anywhere in it, even inside a word, or all of them when ``text`` holds a pydantic ``input_value`` or is
|
||||
too large to search.
|
||||
"""
|
||||
if _INPUT_VALUE_LABEL in text or len(text) * len(texts) > _MAX_SCRUB_COMPARISONS:
|
||||
return frozenset(texts)
|
||||
return frozenset(each for each in texts if _quotes_in(text, each))
|
||||
|
||||
|
||||
def _quotes_in(text: str, quoted: str) -> tuple[str, ...]:
|
||||
"""The forms of ``quoted`` that turn up in ``text``, each without a trailing ``input_value=``."""
|
||||
if len(quoted) > len(text):
|
||||
return ()
|
||||
return tuple(
|
||||
form
|
||||
for form in (each.removesuffix(_INPUT_VALUE_LABEL) for each in _echo_forms(quoted))
|
||||
if form and form in text
|
||||
)
|
||||
|
||||
|
||||
def _with_echoes_replaced(text: str, echoes: Collection[str]) -> str | None:
|
||||
"""
|
||||
``text`` with each quote of ``echoes`` replaced by ``REDACTED_BY_LITELLM`` in one left-to-right pass, the
|
||||
longest where several start at one place. An echo that is a single word only counts where it stands as its own
|
||||
word, so a prompt such as ``Error`` leaves ``RateLimitError`` alone. ``None`` when ``echoes`` turn up more often
|
||||
than a failure path should look through.
|
||||
"""
|
||||
found: Final = tuple(
|
||||
itertools.islice(
|
||||
itertools.chain.from_iterable(_quote_spans(text, form) for form in echoes), _MAX_ECHO_OCCURRENCES + 1
|
||||
)
|
||||
)
|
||||
if len(found) > _MAX_ECHO_OCCURRENCES:
|
||||
return None
|
||||
echo_starts: Final = frozenset(start for start, _, _ in found)
|
||||
echo_ends: Final = frozenset(end for _, end, _ in found)
|
||||
counted: Final = (
|
||||
(start, end)
|
||||
for start, end, single_word in found
|
||||
if not single_word
|
||||
or (
|
||||
(start in echo_ends or _is_break_before(text, start)) and (end in echo_starts or _is_break_after(text, end))
|
||||
)
|
||||
)
|
||||
ends: Final = dict(sorted(counted, key=lambda span: span[1]))
|
||||
starts: Final = sorted(ends)
|
||||
frontiers: Final = itertools.accumulate(starts, functools.partial(_frontier_after, ends), initial=0)
|
||||
chosen: Final = tuple(start for start, frontier in zip(starts, frontiers) if start >= frontier)
|
||||
resumes: Final = (0, *(ends[start] for start in chosen))
|
||||
kept_and_replaced: Final = itertools.chain.from_iterable(
|
||||
(text[resume:start], REDACTED_BY_LITELLM) for resume, start in zip(resumes, chosen)
|
||||
)
|
||||
return "".join((*kept_and_replaced, text[resumes[-1] :]))
|
||||
|
||||
|
||||
def _frontier_after(ends: Mapping[int, int], frontier: int, start: int) -> int:
|
||||
"""Where the next replacement may start once the quote at ``start`` is passed, replacing it if it starts at or
|
||||
past ``frontier``."""
|
||||
return ends[start] if start >= frontier else frontier
|
||||
|
||||
|
||||
def _quote_spans(text: str, form: str) -> Iterator[tuple[int, int, bool]]:
|
||||
"""Where each quote of ``form`` in ``text`` starts and ends, overlaps included, and whether ``form`` is a single
|
||||
word. A form short enough to occur in ordinary wording only counts between quotes."""
|
||||
if len(form) >= _MIN_UNQUOTED_TEXT_LENGTH:
|
||||
single_word: Final = form.isascii() and form.replace("_", "").isalnum()
|
||||
return ((start, start + len(form), single_word) for start in _found(text, form))
|
||||
quoted_starts: Final = itertools.chain.from_iterable(
|
||||
_found(text, f"{opening}{form}{closing}") for opening, closing in itertools.product(_QUOTES, repeat=2)
|
||||
)
|
||||
return ((start + 1, start + 1 + len(form), False) for start in quoted_starts)
|
||||
|
||||
|
||||
def _found(text: str, piece: str) -> Iterator[int]:
|
||||
"""Every index ``piece`` starts at in ``text``, overlaps included."""
|
||||
return itertools.takewhile(
|
||||
lambda start: start >= 0,
|
||||
itertools.accumulate(
|
||||
itertools.repeat(None), lambda start, _: text.find(piece, start + 1), initial=text.find(piece)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _is_break_before(text: str, index: int) -> bool:
|
||||
"""
|
||||
Whether a word starts at ``index`` of ``text``. An escape such as ``\\n`` before it and a ``REDACTED_BY_LITELLM``
|
||||
an earlier pass put there count as breaks.
|
||||
"""
|
||||
return (
|
||||
index == 0
|
||||
or not _is_word_character(text[index - 1])
|
||||
or text.endswith(REDACTED_BY_LITELLM, 0, index)
|
||||
or _ESCAPE_TAIL.search(text, max(0, index - 10), index) is not None
|
||||
)
|
||||
|
||||
|
||||
def _is_break_after(text: str, index: int) -> bool:
|
||||
"""Whether a word ends at ``index`` of ``text``. A pydantic ``input_value=`` or a ``REDACTED_BY_LITELLM`` right
|
||||
after it counts as a break."""
|
||||
return (
|
||||
index == len(text)
|
||||
or not _is_word_character(text[index])
|
||||
or text.startswith((_INPUT_VALUE_LABEL, REDACTED_BY_LITELLM), index)
|
||||
)
|
||||
|
||||
|
||||
def _is_word_character(character: str) -> bool:
|
||||
return character.isascii() and (character.isalnum() or character == "_")
|
||||
|
||||
|
||||
def _with_input_values_replaced(text: str) -> str:
|
||||
"""``text`` with the pydantic ``input_value`` on each of its lines replaced by ``REDACTED_BY_LITELLM``."""
|
||||
if _INPUT_VALUE_LABEL not in text:
|
||||
return text
|
||||
return "\n".join(_line_with_input_value_replaced(line) for line in text.split("\n"))
|
||||
|
||||
|
||||
def _line_with_input_value_replaced(line: str) -> str:
|
||||
"""``line`` from its first ``input_value=`` up to its last ``, input_type=``, or to its end without one, replaced."""
|
||||
start: Final = line.find(_INPUT_VALUE_LABEL)
|
||||
if start < 0:
|
||||
return line
|
||||
value_start: Final = start + len(_INPUT_VALUE_LABEL)
|
||||
type_start: Final = line.rfind(_INPUT_TYPE_LABEL, value_start)
|
||||
return f"{line[:value_start]}{REDACTED_BY_LITELLM}{line[type_start:] if type_start >= 0 else ''}"
|
||||
|
||||
|
||||
def scrubbed_error_information(
|
||||
error_information: StandardLoggingPayloadErrorInformation, texts: Collection[str]
|
||||
) -> StandardLoggingPayloadErrorInformation:
|
||||
"""``error_information`` with the request's ``texts`` scrubbed out of its message and traceback. The type,
|
||||
status code and every other field carry no prompt and are left as they are."""
|
||||
error_message: Final = error_information.get("error_message")
|
||||
traceback_text: Final = error_information.get("traceback")
|
||||
return {
|
||||
**error_information,
|
||||
"error_message": error_message if error_message is None else scrub_request_text(error_message, texts),
|
||||
"traceback": traceback_text if traceback_text is None else scrub_request_text(traceback_text, texts),
|
||||
}
|
||||
|
||||
|
||||
def _with_scrubbed_text(
|
||||
mapping: Mapping[str, object], fields: tuple[str, ...], texts: Collection[str]
|
||||
) -> Mapping[str, object]:
|
||||
return {
|
||||
key: scrub_request_text(value, texts) if key in fields and isinstance(value, str) else value
|
||||
for key, value in mapping.items()
|
||||
}
|
||||
|
||||
|
||||
def scrubbed_error_fields(payload: Mapping[str, object], texts: Collection[str]) -> Mapping[str, object]:
|
||||
"""The ``error_str`` and ``error_information`` entries of a ``StandardLoggingPayload`` with the request's
|
||||
``texts`` scrubbed out, to merge over a copy of the payload. Absent entries are left out."""
|
||||
error_str: Final = payload.get("error_str")
|
||||
error_information: Final = _as_mapping(payload.get("error_information"))
|
||||
return {
|
||||
**({"error_str": scrub_request_text(error_str, texts)} if isinstance(error_str, str) else {}),
|
||||
**(
|
||||
{"error_information": _with_scrubbed_text(error_information, ("error_message", "traceback"), texts)}
|
||||
if error_information is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def scrubbed_exception_text(model_call_details: Mapping[str, object]) -> tuple[str, str] | None:
|
||||
"""
|
||||
The error message and traceback a failure logged when redaction scrubbed the request's text out of
|
||||
either, for callbacks that would otherwise record the raw exception. ``None`` when nothing was scrubbed.
|
||||
Without a logged error message, the message is the exception text that ends the traceback.
|
||||
"""
|
||||
payload: Final = _as_mapping(model_call_details.get("standard_logging_object")) or {}
|
||||
error_information: Final = _as_mapping(payload.get("error_information")) or {}
|
||||
message: Final = error_information.get("error_message")
|
||||
logged_traceback: Final = model_call_details.get("traceback_exception")
|
||||
traceback_text: Final = (
|
||||
logged_traceback if isinstance(logged_traceback, str) else error_information.get("traceback")
|
||||
)
|
||||
if not any(isinstance(text, str) and REDACTED_BY_LITELLM in text for text in (message, traceback_text)):
|
||||
return None
|
||||
logged_traceback_text: Final = traceback_text if isinstance(traceback_text, str) else ""
|
||||
return (message if isinstance(message, str) else _exception_text(logged_traceback_text), logged_traceback_text)
|
||||
|
||||
|
||||
def scrubbed_exception_record(model_call_details: Mapping[str, object]) -> tuple[str, str] | None:
|
||||
"""
|
||||
The message and stack trace to record for the failure's exception when redaction scrubbed the request's text
|
||||
out of the logged error, as ``scrubbed_exception_text`` reads them. A failure that logged no traceback gets the
|
||||
exception's own frames, with ``REDACTED_BY_LITELLM`` for the text of each exception it chains.
|
||||
"""
|
||||
scrubbed: Final = scrubbed_exception_text(model_call_details)
|
||||
exception: Final = model_call_details.get("exception")
|
||||
if scrubbed is None or scrubbed[1] or not isinstance(exception, BaseException):
|
||||
return scrubbed
|
||||
chain: Final = _exception_chain(exception)
|
||||
earlier: Final = "".join(
|
||||
_exception_frames(older, REDACTED_BY_LITELLM)
|
||||
+ (_DIRECT_CAUSE_LINE if newer.__cause__ is not None else _CONTEXT_LINE)
|
||||
for newer, older in reversed(tuple(itertools.pairwise(chain)))
|
||||
)
|
||||
return scrubbed[0], earlier + _exception_frames(exception, scrubbed[0])
|
||||
|
||||
|
||||
def _exception_chain(exception: BaseException) -> tuple[BaseException, ...]:
|
||||
"""``exception`` and each exception it chains as Python prints them, newest first, up to one already in it."""
|
||||
walk: Final = itertools.accumulate(itertools.repeat(None), _with_older_exception, initial=(exception,))
|
||||
return next(chain for chain, longer in itertools.pairwise(walk) if longer is chain)
|
||||
|
||||
|
||||
def _with_older_exception(chain: tuple[BaseException, ...], _: None) -> tuple[BaseException, ...]:
|
||||
"""``chain`` with the exception its oldest one chains added, unless there is none or ``chain`` holds it."""
|
||||
oldest: Final = chain[-1]
|
||||
older: Final = (
|
||||
oldest.__cause__
|
||||
if oldest.__cause__ is not None
|
||||
else None
|
||||
if oldest.__suppress_context__
|
||||
else oldest.__context__
|
||||
)
|
||||
return chain if older is None or any(older is each for each in chain) else (*chain, older)
|
||||
|
||||
|
||||
def _exception_frames(exception: BaseException, message: str) -> str:
|
||||
"""``exception``'s own frames and last line as Python prints them, with ``message`` for its text."""
|
||||
frames: Final = "".join(traceback.format_tb(exception.__traceback__))
|
||||
header: Final = "Traceback (most recent call last):\n" if frames else ""
|
||||
kind: Final = type(exception)
|
||||
name: Final = (
|
||||
kind.__qualname__ if kind.__module__ in ("__main__", "builtins") else f"{kind.__module__}.{kind.__qualname__}"
|
||||
)
|
||||
return f"{header}{frames}{name}: {message}\n" if message else f"{header}{frames}{name}\n"
|
||||
|
||||
|
||||
def _exception_text(traceback_text: str) -> str:
|
||||
"""The lines after the last indented frame line of ``traceback_text``."""
|
||||
lines: Final = reversed(traceback_text.rstrip().splitlines())
|
||||
return "\n".join(reversed(tuple(itertools.takewhile(lambda line: not line.startswith(" "), lines))))
|
||||
|
||||
|
||||
def redacted_standard_logging_payload(payload: Mapping[str, object]) -> Mapping[str, object]:
|
||||
"""
|
||||
Return a copy of a ``StandardLoggingPayload`` with its messages and response redacted.
|
||||
|
|
@ -688,6 +1025,69 @@ def should_redact_message_logging(model_call_details: dict) -> bool:
|
|||
return litellm.turn_off_message_logging is True
|
||||
|
||||
|
||||
def should_redact_failure_logging(model_call_details: Mapping[str, object]) -> bool:
|
||||
"""
|
||||
``should_redact_message_logging`` for the failure path, which must log the failure whatever the
|
||||
request carried. Headers that are not a mapping carry no redaction header, so the request's own
|
||||
setting or the global one decides.
|
||||
"""
|
||||
try:
|
||||
return should_redact_message_logging(dict(model_call_details))
|
||||
except AttributeError:
|
||||
dynamic_turn_off: Final = _get_turn_off_message_logging_from_dynamic_params(dict(model_call_details))
|
||||
return dynamic_turn_off if dynamic_turn_off is not None else litellm.turn_off_message_logging is True
|
||||
|
||||
|
||||
def should_redact_failed_request(request_data: Mapping[str, object]) -> bool:
|
||||
"""
|
||||
The redaction decision for proxy failure hooks, which get the request body but not its logging
|
||||
object: the same metadata, headers and dynamic params the logging object would read. When the
|
||||
logging object would reject another of the request's callback params, the request's own
|
||||
``turn_off_message_logging`` still counts, and when it would reject that too the global setting decides.
|
||||
"""
|
||||
body: Final = dict(request_data)
|
||||
dynamic_params: Final = _request_callback_params(body)
|
||||
if dynamic_params is None:
|
||||
return litellm.turn_off_message_logging is True
|
||||
return should_redact_failure_logging(
|
||||
{
|
||||
"litellm_params": {key: body[key] for key in ("metadata", "litellm_metadata") if key in body},
|
||||
"standard_callback_dynamic_params": dynamic_params,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _request_callback_params(body: Mapping[str, object]) -> StandardCallbackDynamicParams | None:
|
||||
"""
|
||||
The callback params the logging object reads from ``body``. When it would reject one of them, only the
|
||||
request's own ``turn_off_message_logging``, and ``None`` when it would reject that too.
|
||||
"""
|
||||
with contextlib.suppress(ValueError):
|
||||
return initialize_standard_callback_dynamic_params(dict(body))
|
||||
sources: Final[tuple[Mapping[str, object], ...]] = (
|
||||
body,
|
||||
*(metadata for _, metadata in iter_client_callback_metadata_dicts(body)),
|
||||
)
|
||||
redaction_choice: Final = next(
|
||||
(source["turn_off_message_logging"] for source in sources if "turn_off_message_logging" in source), None
|
||||
)
|
||||
with contextlib.suppress(ValueError):
|
||||
return initialize_standard_callback_dynamic_params(
|
||||
{} if redaction_choice is None else {"turn_off_message_logging": redaction_choice}
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def scrubbed_error_information_for_request(
|
||||
error_information: StandardLoggingPayloadErrorInformation, request_data: Mapping[str, object]
|
||||
) -> StandardLoggingPayloadErrorInformation:
|
||||
"""``error_information`` built from a proxy failure hook's exception, with the request's own text
|
||||
scrubbed out when the request's message logging is redacted."""
|
||||
if not should_redact_failed_request(request_data):
|
||||
return error_information
|
||||
return scrubbed_error_information(error_information, failure_request_texts(request_data))
|
||||
|
||||
|
||||
def redact_message_input_output_from_logging(model_call_details: dict, result, input: object | None = None) -> Any:
|
||||
"""
|
||||
Removes messages, prompts, input, response from logging. This modifies the data in-place
|
||||
|
|
|
|||
22
tests/benchmarks/test_redact_messages_benchmarks.py
Normal file
22
tests/benchmarks/test_redact_messages_benchmarks.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.litellm_core_utils.redact_messages import request_texts, scrub_request_text
|
||||
|
||||
REQUEST_TEXTS: Final = request_texts("hi there")
|
||||
|
||||
|
||||
@pytest.mark.benchmark
|
||||
@pytest.mark.parametrize(
|
||||
("error", "scrubbed"),
|
||||
[
|
||||
("." * 100_000, "." * 100_000),
|
||||
(" ...'x" * 20_000, " ...'x" * 20_000),
|
||||
("input_value=" * 10_000, "input_value=redacted-by-litellm"),
|
||||
("input_value=x, input_type=y\n" * 4_000, "input_value=redacted-by-litellm, input_type=y\n" * 4_000),
|
||||
],
|
||||
ids=["dots", "quoted_cuts", "input_value_labels_on_one_line", "input_values_on_many_lines"],
|
||||
)
|
||||
def test_scrubbing_a_large_error(error: str, scrubbed: str) -> None:
|
||||
assert scrub_request_text(error, REQUEST_TEXTS) == scrubbed
|
||||
|
|
@ -8,10 +8,14 @@ but litellm_params["litellm_metadata"] is None.
|
|||
import asyncio
|
||||
import copy
|
||||
import importlib
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
import traceback
|
||||
import tracemalloc
|
||||
from collections.abc import AsyncIterator, Callable, Mapping
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType, SimpleNamespace
|
||||
from typing import Final, Optional, Union
|
||||
|
|
@ -20,7 +24,7 @@ from unittest.mock import patch
|
|||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from pydantic import JsonValue
|
||||
from pydantic import JsonValue, TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm.constants import LOGGING_WORKER_MAX_TIME_PER_COROUTINE
|
||||
|
|
@ -30,9 +34,16 @@ from litellm.litellm_core_utils.redact_messages import (
|
|||
_redact_responses_api_output,
|
||||
_redacted_responses_api_response,
|
||||
perform_redaction,
|
||||
quoted_request_texts,
|
||||
redacted_model_call_details_for_custom_logger,
|
||||
redacted_response_obj_for_custom_logger,
|
||||
redacted_standard_logging_payload,
|
||||
request_texts,
|
||||
scrub_request_text,
|
||||
scrubbed_error_information_for_request,
|
||||
scrubbed_exception_record,
|
||||
scrubbed_exception_text,
|
||||
should_redact_failed_request,
|
||||
should_redact_message_logging,
|
||||
)
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
|
|
@ -43,6 +54,7 @@ from litellm.types.utils import (
|
|||
ModelResponseStream,
|
||||
ResponsesAPIResponse,
|
||||
StandardLoggingPayload,
|
||||
StandardLoggingPayloadErrorInformation,
|
||||
TextCompletionResponse,
|
||||
)
|
||||
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
|
||||
|
|
@ -2220,3 +2232,573 @@ def test_perform_redaction_strips_the_matched_text_from_the_bedrock_guardrail_tr
|
|||
assert "US_SSN" in redacted.model_dump_json(), "the finding stays, only its matched text goes"
|
||||
assert "123-45-6789" not in str(redacted_dict)
|
||||
assert "123-45-6789" in response.model_dump_json(), "the caller's own response is not redacted"
|
||||
|
||||
|
||||
PROMPT: Final = "my ssn is 123-45-6789\nand my pin is 4321, keep both safe"
|
||||
LONG_PROMPT: Final = f"my ssn is 123-45-6789 'keep it' and \"safe\" {'and more ' * 200}until the end"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"quoted",
|
||||
[PROMPT, json.dumps(PROMPT)[1:-1], repr(PROMPT)[1:-1]],
|
||||
ids=["verbatim", "json", "repr"],
|
||||
)
|
||||
def test_scrub_request_text_replaces_each_way_an_error_quotes_the_prompt(quoted: str) -> None:
|
||||
scrubbed: Final = scrub_request_text(f"Invalid content: <{quoted}> rejected", request_texts(PROMPT))
|
||||
|
||||
assert "123-45-6789" not in scrubbed and "4321" not in scrubbed
|
||||
assert "redacted-by-litellm" in scrubbed
|
||||
assert scrubbed.startswith("Invalid content: <") and scrubbed.endswith("> rejected")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"prompt",
|
||||
['say "hi" to Bob', "C:\\Users\\bob", "café for Bob", "tab\tfor Bob"],
|
||||
ids=["double_quote", "backslash", "non_ascii", "control_character"],
|
||||
)
|
||||
@pytest.mark.parametrize("escaped", [json.dumps, repr], ids=["json", "repr"])
|
||||
def test_scrub_request_text_replaces_an_escaped_echo_of_a_prompt_that_escaping_changes(
|
||||
prompt: str, escaped: Callable[[str], str]
|
||||
) -> None:
|
||||
error: Final = f"Invalid content: <{escaped(prompt)[1:-1]}> rejected"
|
||||
|
||||
assert scrub_request_text(error, request_texts(prompt)) == "Invalid content: <redacted-by-litellm> rejected"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[PROMPT, [PROMPT], {"content": PROMPT}, [{"content": PROMPT}], [{"content": LONG_PROMPT}], [LONG_PROMPT]],
|
||||
ids=[
|
||||
"str",
|
||||
"list",
|
||||
"dict",
|
||||
"nested_under_12_characters",
|
||||
"text_longer_than_the_error",
|
||||
"text_longer_than_the_error_cut_inside_an_escaped_quote",
|
||||
],
|
||||
)
|
||||
def test_scrub_request_text_replaces_a_pydantic_cut_input_value(value: object) -> None:
|
||||
with pytest.raises(ValidationError, match="input_value=") as raised:
|
||||
TypeAdapter(int).validate_python(value)
|
||||
|
||||
scrubbed: Final = scrub_request_text(str(raised.value), request_texts(value))
|
||||
|
||||
assert "my ssn" not in scrubbed and "4321" not in scrubbed
|
||||
assert "input_value=redacted-by-litellm, input_type=" in scrubbed
|
||||
|
||||
|
||||
SINGLE_QUOTED_PROMPT: Final = "account 'secret' " + "x" * 1_000 + " end 'private'"
|
||||
BOTH_QUOTED_PROMPT: Final = 'said "secret" it\'s ' + "x" * 1_000 + ' it\'s "private"'
|
||||
DOTTED_PROMPT: Final = "account secret is there." + "x" * 1_000 + ".private secret is here"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
SINGLE_QUOTED_PROMPT,
|
||||
[SINGLE_QUOTED_PROMPT],
|
||||
{"content": SINGLE_QUOTED_PROMPT},
|
||||
BOTH_QUOTED_PROMPT,
|
||||
[BOTH_QUOTED_PROMPT],
|
||||
{"content": BOTH_QUOTED_PROMPT},
|
||||
DOTTED_PROMPT,
|
||||
],
|
||||
ids=[
|
||||
"single_quotes_str",
|
||||
"single_quotes_list",
|
||||
"single_quotes_dict",
|
||||
"both_quotes_str",
|
||||
"both_quotes_list",
|
||||
"both_quotes_dict",
|
||||
"dots_beside_both_cuts",
|
||||
],
|
||||
)
|
||||
def test_scrub_request_text_replaces_a_pydantic_input_value_that_holds_the_prompts_own_quotes(value: object) -> None:
|
||||
with pytest.raises(ValidationError, match="input_value=") as raised:
|
||||
TypeAdapter(int).validate_python(value)
|
||||
|
||||
scrubbed: Final = scrub_request_text(str(raised.value), request_texts(value))
|
||||
|
||||
assert not any(word in scrubbed for word in ("account", "said", "secret", "private"))
|
||||
assert "input_value=redacted-by-litellm, input_type=" in scrubbed
|
||||
|
||||
|
||||
def test_scrub_request_text_replaces_every_line_of_a_pydantic_error_and_keeps_its_wording() -> None:
|
||||
prompt: Final = "account secret " + "x" * 200 + " private"
|
||||
with pytest.raises(ValidationError) as raised:
|
||||
TypeAdapter(tuple[int, int]).validate_python([prompt, prompt])
|
||||
|
||||
scrubbed: Final = scrub_request_text(str(raised.value), request_texts(prompt))
|
||||
|
||||
assert not any(word in scrubbed for word in ("account", "secret", "private"))
|
||||
assert scrubbed.count("input_value=redacted-by-litellm, input_type=str]") == 2
|
||||
assert "Input should be a valid integer" in scrubbed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"wrapped",
|
||||
[repr, lambda error: json.dumps({"detail": error}), lambda error: str({"error": {"message": error}})],
|
||||
ids=["repr", "json", "dict_str"],
|
||||
)
|
||||
def test_scrub_request_text_replaces_the_input_values_of_a_pydantic_error_wrapped_onto_one_line(
|
||||
wrapped: Callable[[str], str],
|
||||
) -> None:
|
||||
prompt: Final = "account 'secret' " + "x" * 200 + ' end "private"'
|
||||
with pytest.raises(ValidationError) as raised:
|
||||
TypeAdapter(tuple[int, int]).validate_python([prompt, prompt])
|
||||
|
||||
scrubbed: Final = scrub_request_text(wrapped(str(raised.value)), request_texts(prompt))
|
||||
|
||||
assert not any(word in scrubbed for word in ("account", "secret", "private"))
|
||||
assert "input_value=redacted-by-litellm" in scrubbed
|
||||
|
||||
|
||||
def test_scrub_request_text_replaces_an_input_value_that_holds_the_input_type_label() -> None:
|
||||
prompt: Final = "account, input_type=str] secret input_value='a' " + "x" * 200 + " private"
|
||||
with pytest.raises(ValidationError) as raised:
|
||||
TypeAdapter(int).validate_python(prompt)
|
||||
|
||||
scrubbed: Final = scrub_request_text(str(raised.value), request_texts(prompt))
|
||||
|
||||
assert not any(word in scrubbed for word in ("account", "secret", "private"))
|
||||
assert "input_value=redacted-by-litellm, input_type=str]" in scrubbed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"other_message",
|
||||
[
|
||||
"value",
|
||||
"input",
|
||||
"input_value",
|
||||
"input_value=",
|
||||
", input_value=",
|
||||
"int_parsing, input_value=",
|
||||
"_value=",
|
||||
"type",
|
||||
"input_type",
|
||||
", input_type=",
|
||||
],
|
||||
)
|
||||
def test_scrub_request_text_replaces_an_input_value_when_another_message_matches_its_label(other_message: str) -> None:
|
||||
prompt: Final = "account secret " + "x" * 200 + " private"
|
||||
with pytest.raises(ValidationError) as raised:
|
||||
TypeAdapter(int).validate_python(prompt)
|
||||
|
||||
scrubbed: Final = scrub_request_text(str(raised.value), request_texts([other_message, prompt]))
|
||||
|
||||
assert not any(word in scrubbed for word in ("account", "secret", "private"))
|
||||
|
||||
|
||||
def test_scrub_request_text_leaves_empty_quotes_when_a_message_is_only_the_input_value_label() -> None:
|
||||
error: Final = "empty value '' rejected"
|
||||
|
||||
assert scrub_request_text(error, request_texts("input_value=")) == error
|
||||
|
||||
|
||||
def test_scrub_request_text_replaces_to_the_end_of_the_line_without_an_input_type() -> None:
|
||||
error: Final = "bad value [type=int_parsing, input_value='account sec\nnext line"
|
||||
|
||||
assert scrub_request_text(error, request_texts("hi there")) == (
|
||||
"bad value [type=int_parsing, input_value=redacted-by-litellm\nnext line"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"prompt",
|
||||
["account secret input_value=1 private", "input_value=account secret private"],
|
||||
ids=["middle", "start"],
|
||||
)
|
||||
def test_scrub_request_text_replaces_the_whole_of_a_quoted_prompt_that_holds_the_input_value_label(prompt: str) -> None:
|
||||
assert scrub_request_text(f"Invalid content: <{prompt}> rejected", request_texts(prompt)) == (
|
||||
"Invalid content: <redacted-by-litellm> rejected"
|
||||
)
|
||||
|
||||
|
||||
def test_scrub_request_text_replaces_a_quoted_prompt_that_ends_in_the_input_value_label() -> None:
|
||||
prompt: Final = "account secret private input_value="
|
||||
|
||||
scrubbed: Final = scrub_request_text(f"Invalid content: <{prompt}> rejected", request_texts(prompt))
|
||||
|
||||
assert scrubbed.startswith("Invalid content: <redacted-by-litellm")
|
||||
assert not any(word in scrubbed for word in ("account", "secret", "private"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, "critic"], ids=["none", "str_that_is_not_request_text"])
|
||||
def test_scrub_request_text_replaces_an_input_value_that_is_not_request_text(value: object) -> None:
|
||||
with pytest.raises(ValidationError) as raised:
|
||||
TypeAdapter(int).validate_python(value)
|
||||
|
||||
assert "input_value=redacted-by-litellm, input_type=" in scrub_request_text(
|
||||
str(raised.value), request_texts("hi there")
|
||||
)
|
||||
|
||||
|
||||
def test_scrub_request_text_keeps_an_input_value_when_the_request_has_no_text() -> None:
|
||||
error: Final = "bad value [type=int_type, input_value=None, input_type=NoneType]"
|
||||
|
||||
assert scrub_request_text(error, request_texts(None)) == error
|
||||
|
||||
|
||||
def test_scrub_request_text_leaves_an_error_that_does_not_quote_the_prompt() -> None:
|
||||
assert scrub_request_text("Incorrect API key provided", request_texts(PROMPT, "hi")) == "Incorrect API key provided"
|
||||
|
||||
|
||||
def test_scrub_request_text_replaces_a_short_text_only_between_quotes() -> None:
|
||||
scrubbed: Final = scrub_request_text("Invalid value '432' and \"432\"; 432 is too short", request_texts("432"))
|
||||
|
||||
assert scrubbed == "Invalid value 'redacted-by-litellm' and \"redacted-by-litellm\"; 432 is too short"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("prompt", "error"),
|
||||
[
|
||||
("Error", "litellm.RateLimitError: Rate limit reached"),
|
||||
("test", "The model `gpt-4-latest` does not exist"),
|
||||
("text", "litellm.ContextWindowExceededError: too long"),
|
||||
("test", "No deployment named test_model"),
|
||||
],
|
||||
ids=["word_end", "word_start", "word_middle", "before_underscore"],
|
||||
)
|
||||
def test_scrub_request_text_leaves_a_prompt_that_only_occurs_inside_a_longer_word(prompt: str, error: str) -> None:
|
||||
assert scrub_request_text(error, request_texts(prompt)) == error
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "scrubbed"),
|
||||
[
|
||||
("Error", "redacted-by-litellm"),
|
||||
("Invalid content: Error.", "Invalid content: redacted-by-litellm."),
|
||||
('content: "first\\nError"', 'content: "first\\nredacted-by-litellm"'),
|
||||
('content: "\\u00e9Error"', 'content: "\\u00e9redacted-by-litellm"'),
|
||||
("content: 'caf\\xe9Error'", "content: 'caf\\xe9redacted-by-litellm'"),
|
||||
("内容错误Error请重试", "内容错误redacted-by-litellm请重试"),
|
||||
],
|
||||
ids=["whole_text", "whole_word", "after_newline_escape", "after_unicode_escape", "after_hex_escape", "non_ascii"],
|
||||
)
|
||||
def test_scrub_request_text_replaces_a_prompt_that_stands_as_its_own_word(error: str, scrubbed: str) -> None:
|
||||
assert scrub_request_text(error, request_texts("Error")) == scrubbed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("texts", "error", "scrubbed"),
|
||||
[
|
||||
(
|
||||
("my ssn is 1234",),
|
||||
"litellm.BadRequestErrormy ssn is 1234Error",
|
||||
"litellm.BadRequestErrorredacted-by-litellmError",
|
||||
),
|
||||
(("Hello", "world"), "content: Helloworld!", "content: redacted-by-litellmredacted-by-litellm!"),
|
||||
(
|
||||
("secretinput_value=",),
|
||||
"bad: secretinput_value=x",
|
||||
"bad: redacted-by-litellminput_value=redacted-by-litellm",
|
||||
),
|
||||
(("Firstinput_value=1", "Second"), "x: Firstinput_value=1Second", "x: redacted-by-litellmredacted-by-litellm"),
|
||||
(("Firstinput_value=1", "Second"), "x: SecondFirstinput_value=1", "x: redacted-by-litellmredacted-by-litellm"),
|
||||
],
|
||||
ids=[
|
||||
"several_words_inside_a_word",
|
||||
"next_to_another_text",
|
||||
"before_its_own_input_value_label",
|
||||
"after_an_earlier_replacement",
|
||||
"before_an_earlier_replacement",
|
||||
],
|
||||
)
|
||||
def test_scrub_request_text_replaces_a_prompt_glued_to_other_text_when_it_is_not_one_word(
|
||||
texts: tuple[str, ...], error: str, scrubbed: str
|
||||
) -> None:
|
||||
assert scrub_request_text(error, request_texts(*texts)) == scrubbed
|
||||
|
||||
|
||||
def test_scrub_request_text_replaces_the_longest_prompt_that_starts_at_one_place() -> None:
|
||||
scrubbed: Final = scrub_request_text("bad: secret code list", request_texts("secret", "secret code", "code list"))
|
||||
|
||||
assert scrubbed == "bad: redacted-by-litellm list"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("echoes", "scrubbed"),
|
||||
[(10_000, "bad: redacted-by-litellm " * 10_000), (10_001, "redacted-by-litellm")],
|
||||
ids=["at_the_limit", "past_the_limit"],
|
||||
)
|
||||
def test_scrub_request_text_replaces_the_whole_text_when_the_prompt_repeats_past_the_limit(
|
||||
echoes: int, scrubbed: str
|
||||
) -> None:
|
||||
assert scrub_request_text("bad: secret " * echoes, request_texts("secret")) == scrubbed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "quoted"),
|
||||
[
|
||||
("Invalid content: 'secret one' and \"tab\\tafter\"", {"secret one", "tab\tafter"}),
|
||||
("Connection timed out", set()),
|
||||
("litellm.RateLimitError: Rate limit reached", {"Error"}),
|
||||
("Invalid content: Helloworld!", {"Hello", "world"}),
|
||||
("1 validation error\n Input should be a valid string [input_value='cut...', input_type=str]", None),
|
||||
("x" * 5_000, None),
|
||||
],
|
||||
ids=[
|
||||
"quoted_verbatim_or_escaped",
|
||||
"quotes_none",
|
||||
"quoted_inside_a_word",
|
||||
"message_parts_joined_into_one_word",
|
||||
"pydantic_input_value",
|
||||
"too_large_to_search",
|
||||
],
|
||||
)
|
||||
def test_quoted_request_texts_names_the_texts_a_scrub_of_the_error_needs(error: str, quoted: set[str] | None) -> None:
|
||||
texts: Final = request_texts(
|
||||
"secret one", "tab\tafter", "Error", "Hello", "world", [f"message number {index}" for index in range(2_000)]
|
||||
)
|
||||
named: Final = quoted_request_texts(error, texts)
|
||||
|
||||
assert named == (texts if quoted is None else quoted)
|
||||
assert scrub_request_text(error, named) == scrub_request_text(error, texts)
|
||||
|
||||
|
||||
def test_scrub_request_text_keeps_no_copy_of_the_prompt_after_it_returns() -> None:
|
||||
prompt: Final = "".join(f"secret {index} " for index in range(20_000))
|
||||
error: Final = f"Invalid content: '{prompt}'"
|
||||
already_tracing: Final = tracemalloc.is_tracing()
|
||||
if not already_tracing:
|
||||
tracemalloc.start()
|
||||
baseline, _ = tracemalloc.get_traced_memory()
|
||||
try:
|
||||
scrubbed: Final = scrub_request_text(error, request_texts(prompt))
|
||||
kept, _ = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
if not already_tracing:
|
||||
tracemalloc.stop()
|
||||
|
||||
assert scrubbed == "Invalid content: 'redacted-by-litellm'"
|
||||
assert kept - baseline < len(prompt) // 2, f"scrubbing kept {kept - baseline} bytes alive after returning"
|
||||
|
||||
|
||||
def test_scrub_request_text_replaces_the_whole_text_when_the_search_is_too_large() -> None:
|
||||
texts: Final = request_texts([f"message number {index}" for index in range(5_000)])
|
||||
|
||||
assert scrub_request_text("x" * 5_000, texts) == "redacted-by-litellm"
|
||||
|
||||
|
||||
def test_scrub_request_text_never_copies_a_request_text_longer_than_the_error() -> None:
|
||||
error: Final = "litellm.RateLimitError: rate limit reached for gpt-4o"
|
||||
image_urls: Final = request_texts([f"data:image/png;base64,{index}{'A' * 1_000_000}" for index in range(4)])
|
||||
already_tracing: Final = tracemalloc.is_tracing()
|
||||
if not already_tracing:
|
||||
tracemalloc.start()
|
||||
tracemalloc.reset_peak()
|
||||
baseline, _ = tracemalloc.get_traced_memory()
|
||||
try:
|
||||
scrubbed: Final = scrub_request_text(error, image_urls)
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
if not already_tracing:
|
||||
tracemalloc.stop()
|
||||
|
||||
assert scrubbed == error
|
||||
assert peak - baseline < 1_000_000, f"scrubbing a short error allocated {peak - baseline} bytes, copying the images"
|
||||
|
||||
|
||||
def test_request_texts_reads_content_and_skips_structural_fields() -> None:
|
||||
texts: Final = request_texts(
|
||||
[
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "part one text"}]},
|
||||
{"role": "tool", "tool_call_id": "call_123456", "name": "lookup", "content": "tool output"},
|
||||
litellm.Message(role="assistant", content="from a model object"),
|
||||
]
|
||||
)
|
||||
|
||||
assert texts == {"part one text", "tool output", "from a model object"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"request_data",
|
||||
[
|
||||
{"turn_off_message_logging": True},
|
||||
{"metadata": {"turn_off_message_logging": True}},
|
||||
{"metadata": {"headers": {"x-litellm-enable-message-redaction": "true"}}},
|
||||
],
|
||||
ids=["body_flag", "metadata_flag", "enable_header"],
|
||||
)
|
||||
def test_should_redact_failed_request_follows_the_request_opt_ins(request_data: dict) -> None:
|
||||
assert should_redact_failed_request(request_data) is True
|
||||
assert should_redact_failed_request({}) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"request_data",
|
||||
[{"metadata": {"headers": "x"}}, {"turn_off_message_logging": "os.environ/REDACT"}],
|
||||
ids=["non_mapping_headers", "env_reference"],
|
||||
)
|
||||
@pytest.mark.parametrize("global_redaction", [True, False], ids=["global_on", "global_off"])
|
||||
def test_should_redact_failed_request_falls_back_to_the_global_setting_for_an_undecidable_request(
|
||||
monkeypatch: pytest.MonkeyPatch, request_data: dict, global_redaction: bool
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "turn_off_message_logging", global_redaction)
|
||||
|
||||
assert should_redact_failed_request(request_data) is global_redaction
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"request_data",
|
||||
[
|
||||
{"turn_off_message_logging": True, "langfuse_host": "os.environ/LANGFUSE_HOST"},
|
||||
{"metadata": {"turn_off_message_logging": True, "langfuse_host": "os.environ/LANGFUSE_HOST"}},
|
||||
],
|
||||
ids=["body_flag", "metadata_flag"],
|
||||
)
|
||||
def test_should_redact_failed_request_keeps_the_request_opt_in_past_a_rejected_callback_param(
|
||||
monkeypatch: pytest.MonkeyPatch, request_data: dict
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "turn_off_message_logging", False)
|
||||
|
||||
assert should_redact_failed_request(request_data) is True
|
||||
|
||||
|
||||
def test_should_redact_failed_request_reads_the_request_setting_past_non_mapping_headers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "turn_off_message_logging", False)
|
||||
|
||||
assert should_redact_failed_request({"metadata": {"headers": "x"}, "turn_off_message_logging": True}) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error_information",
|
||||
[
|
||||
{"error_message": "bad: redacted-by-litellm", "traceback": "tb"},
|
||||
{"error_message": "bad input", "traceback": "tb redacted-by-litellm"},
|
||||
],
|
||||
ids=["message", "traceback_only"],
|
||||
)
|
||||
def test_scrubbed_exception_text_reports_a_scrub_of_either_field(error_information: dict) -> None:
|
||||
logged: Final = scrubbed_exception_text({"standard_logging_object": {"error_information": error_information}})
|
||||
|
||||
assert logged == (error_information["error_message"], error_information["traceback"])
|
||||
assert scrubbed_exception_text({"standard_logging_object": {"error_information": {"error_message": "bad"}}}) is None
|
||||
|
||||
|
||||
def test_scrubbed_exception_text_reports_a_scrub_of_the_logged_traceback() -> None:
|
||||
model_call_details: Final = {
|
||||
"traceback_exception": "ValueError: bad: redacted-by-litellm",
|
||||
"standard_logging_object": {"error_information": {"error_message": "outer", "traceback": "frames"}},
|
||||
}
|
||||
|
||||
assert scrubbed_exception_text(model_call_details) == ("outer", "ValueError: bad: redacted-by-litellm")
|
||||
|
||||
|
||||
def test_scrubbed_exception_text_reads_the_message_off_the_traceback_without_a_logged_message() -> None:
|
||||
traceback_text: Final = (
|
||||
"Traceback (most recent call last):\n"
|
||||
' File "main.py", line 1, in completion\n'
|
||||
" raise BadRequestError(message)\n"
|
||||
"BadRequestError: Invalid content: 'redacted-by-litellm'\n"
|
||||
"Received Messages=['redacted-by-litellm']\n"
|
||||
)
|
||||
|
||||
assert scrubbed_exception_text({"traceback_exception": traceback_text}) == (
|
||||
"BadRequestError: Invalid content: 'redacted-by-litellm'\nReceived Messages=['redacted-by-litellm']",
|
||||
traceback_text,
|
||||
)
|
||||
|
||||
|
||||
class _ProviderError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _raise_chained(chain: tuple[BaseException, ...]) -> None:
|
||||
"""Raise the last of ``chain`` while handling the one before it, and so on down to the first."""
|
||||
if len(chain) == 1:
|
||||
raise chain[0]
|
||||
try:
|
||||
_raise_chained(chain[:-1])
|
||||
except BaseException as earlier:
|
||||
raise chain[-1] from earlier
|
||||
|
||||
|
||||
def _raised(chain: tuple[BaseException, ...]) -> BaseException:
|
||||
with pytest.raises(type(chain[-1])) as raised:
|
||||
_raise_chained(chain)
|
||||
return raised.value
|
||||
|
||||
|
||||
def _failure(exception: BaseException, error_information: Mapping[str, str]) -> dict[str, object]:
|
||||
return {"exception": exception, "standard_logging_object": {"error_information": error_information}}
|
||||
|
||||
|
||||
SCRUBBED_ERROR: Final = {"error_message": "bad: redacted-by-litellm", "traceback": ""}
|
||||
|
||||
|
||||
def test_scrubbed_exception_record_prints_the_exceptions_own_frames_without_a_logged_traceback() -> None:
|
||||
exception: Final = _raised(
|
||||
(ValueError("redacted-by-litellm"), KeyError("redacted-by-litellm"), _ProviderError("bad: redacted-by-litellm"))
|
||||
)
|
||||
printed: Final = "".join(traceback.format_exception(exception))
|
||||
scrubbed: Final = scrubbed_exception_record(_failure(exception, SCRUBBED_ERROR))
|
||||
|
||||
assert scrubbed == (
|
||||
"bad: redacted-by-litellm",
|
||||
printed.replace("KeyError: 'redacted-by-litellm'", "KeyError: redacted-by-litellm"),
|
||||
)
|
||||
assert scrubbed is not None and "in _raise_chained" in scrubbed[1]
|
||||
|
||||
|
||||
def test_scrubbed_exception_record_replaces_the_text_of_each_exception_the_failure_chains() -> None:
|
||||
exception: Final = _raised((ValueError(PROMPT), _ProviderError("bad: redacted-by-litellm")))
|
||||
scrubbed: Final = scrubbed_exception_record(_failure(exception, SCRUBBED_ERROR))
|
||||
|
||||
assert scrubbed is not None and "123-45-6789" not in scrubbed[1]
|
||||
assert "ValueError: redacted-by-litellm\n\nThe above exception was the direct cause" in scrubbed[1]
|
||||
assert scrubbed[1].endswith("_ProviderError: bad: redacted-by-litellm\n")
|
||||
|
||||
|
||||
def test_scrubbed_exception_record_stops_at_an_exception_chained_back_to_itself() -> None:
|
||||
first: Final = ValueError("redacted-by-litellm")
|
||||
second: Final = _ProviderError("bad: redacted-by-litellm")
|
||||
first.__context__ = second
|
||||
second.__context__ = first
|
||||
|
||||
assert scrubbed_exception_record(_failure(second, SCRUBBED_ERROR)) == (
|
||||
"bad: redacted-by-litellm",
|
||||
"".join(traceback.format_exception(second)),
|
||||
)
|
||||
|
||||
|
||||
def test_scrubbed_exception_record_prints_a_chain_longer_than_the_recursion_limit() -> None:
|
||||
chain: Final = (
|
||||
_ProviderError("bad: redacted-by-litellm"),
|
||||
*(ValueError("redacted-by-litellm") for _ in range(sys.getrecursionlimit())),
|
||||
)
|
||||
for newer, older in itertools.pairwise(chain):
|
||||
newer.__cause__ = older
|
||||
|
||||
assert scrubbed_exception_record(_failure(chain[0], SCRUBBED_ERROR)) == (
|
||||
"bad: redacted-by-litellm",
|
||||
"".join(traceback.format_exception(chain[0])),
|
||||
)
|
||||
|
||||
|
||||
def test_scrubbed_exception_record_keeps_a_logged_traceback_and_skips_an_unscrubbed_error() -> None:
|
||||
exception: Final = _raised((_ProviderError(f"bad: {PROMPT}"),))
|
||||
logged: Final = {"error_message": "bad: redacted-by-litellm", "traceback": "tb redacted-by-litellm"}
|
||||
|
||||
assert scrubbed_exception_record(_failure(exception, logged)) == (logged["error_message"], logged["traceback"])
|
||||
assert scrubbed_exception_record(_failure(exception, {"error_message": "bad", "traceback": ""})) is None
|
||||
|
||||
|
||||
def test_scrubbed_error_information_for_request_scrubs_only_when_the_request_redacts() -> None:
|
||||
error_information: Final = StandardLoggingPayloadErrorInformation(
|
||||
error_code="400", error_class="BadRequestError", error_message=f"bad: {PROMPT}", traceback=f"tb {PROMPT}"
|
||||
)
|
||||
messages: Final = [{"role": "user", "content": PROMPT}]
|
||||
|
||||
scrubbed: Final = scrubbed_error_information_for_request(
|
||||
error_information, {"messages": messages, "turn_off_message_logging": True}
|
||||
)
|
||||
|
||||
assert scrubbed == {
|
||||
**error_information,
|
||||
"error_message": "bad: redacted-by-litellm",
|
||||
"traceback": "tb redacted-by-litellm",
|
||||
}
|
||||
assert scrubbed_error_information_for_request(error_information, {"messages": messages}) == error_information
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue