fix(logging): redact nested callback input and provider response fields (#45923)

* fix(logging): redact nested callback input and provider response fields

* fix(logging): keep shared request body for per-callback redaction and drop hidden provider fields

The deprecated per-callback message_logging=False path ran perform_redaction on
the shared model_call_details, which now also redacted the nested
proxy_server_request body. The spend log Request column and
previous_response_id replay read that body, so one opted-out callback blanked
them for everyone. That path now keeps the shared body and hidden params, and
redact_streaming_responses_for_custom_logger hands the opted-out callback its
own redacted litellm_params.

Redacted litellm_params also clear provider_specific_fields copied into
metadata.hidden_params (Anthropic thinking text), in litellm_params and in the
nested request body, and the nested body redacts system and instructions.

* fix(logging): keep replayed instructions and router usage marker under redaction

The shared request body keeps system and instructions again, so spend logs
store them and previous_response_id follow-ups replay the real instructions.
Per-callback copies still redact them.

Provider-specific fields in metadata.hidden_params are cleared in place, so
the metadata dict keeps its identity and the router's counted-usage marker
stays visible to deployment_callback_on_success (no double tpm/rpm count).

* fix(logging): never copy shared metadata dicts during redaction

The shared redaction pass now leaves litellm_params metadata dicts as they
are, so a hidden_params write from the other success handler between the
in-place clear and the copy can no longer move the router usage marker to a
stale dict. The key-presence predicate change is reverted as unneeded.

* refactor(logging): redact nested request body and provider fields with the existing message rules

Run perform_redaction on a copy of proxy_server_request.body, the same call the
spend log already makes, and redact reasoning inside provider_specific_fields the
way the message's own reasoning_content and thinking_blocks are redacted, instead
of clearing every provider field. Opaque fields such as Gemini thought_signatures
stay, and the metadata dicts and streaming objects are handled as on main.

---------

Co-authored-by: moyai-devin-berriai[bot] <336287033+moyai-devin-berriai[bot]@users.noreply.github.com>
Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
This commit is contained in:
moyai-devin-berriai[bot] 2026-10-13 12:25:56 -07:00 • committed by GitHub
parent 719c44db3f
commit 0b3fcc197d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 728 additions and 7 deletions

View file

@ -108,6 +108,7 @@ def _redact_choice_content(choice):
choice.message.reasoning_content = REDACTED_BY_LITELLM
if hasattr(choice.message, "thinking_blocks"):
choice.message.thinking_blocks = None
_redact_provider_specific_fields(choice.message)
_redact_tool_calls(getattr(choice.message, "tool_calls", None))
_redact_function_call(getattr(choice.message, "function_call", None))
elif isinstance(choice, litellm.utils.StreamingChoices):
@ -117,10 +118,39 @@ def _redact_choice_content(choice):
choice.delta.reasoning_content = REDACTED_BY_LITELLM
if hasattr(choice.delta, "thinking_blocks"):
choice.delta.thinking_blocks = None
_redact_provider_specific_fields(choice.delta)
_redact_tool_calls(getattr(choice.delta, "tool_calls", None))
_redact_function_call(getattr(choice.delta, "function_call", None))
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 _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
def _redact_provider_specific_fields_dict(choice: Mapping[str, object]) -> None:
for message in (choice.get("message"), choice.get("delta")):
if (
(readable := _as_mapping(message)) is not None
and (fields := _as_mapping(readable.get("provider_specific_fields"))) is not None
and isinstance(message, dict)
):
message["provider_specific_fields"] = _redacted_provider_specific_fields(fields)
def _redacted_provider_specific_fields(fields: Mapping[str, object]) -> Mapping[str, object]:
"""Copy of provider_specific_fields with its reasoning redacted the same way as the message's own."""
return {
**fields,
**({"reasoning_content": REDACTED_BY_LITELLM} if fields.get("reasoning_content") is not None else {}),
**{key: None for key in ("thinking_blocks", "reasoningContentBlocks", "citations") if key in fields},
}
def _redact_responses_api_output(output_items):
"""Helper to redact ResponsesAPIResponse output items."""
for output_item in output_items:
@ -228,6 +258,7 @@ def _redact_tool_calls_dict(message: Mapping[str, object]) -> None:
def _redact_model_response_dict_choices(choices, redacted_str: str):
for choice in choices:
if isinstance(choice, dict):
_redact_provider_specific_fields_dict(choice)
if "message" in choice and isinstance(choice["message"], dict):
if choice["message"].get("content") is not None:
choice["message"]["content"] = redacted_str
@ -261,8 +292,37 @@ def _redacted_baseline_metadata(metadata: Mapping[str, object]) -> Mapping[str,
return {**metadata, "_autorouter_baseline_route": replace(route, request_parameters=None)}
def redacted_litellm_params(params: Mapping[str, object]) -> dict[str, object]:
request: Final = params.get("proxy_server_request")
def _redacted_proxy_server_request(request: Mapping[str, object], *, redact_body: bool) -> Mapping[str, object]:
"""Copy of proxy_server_request; redact_body redacts its body the same way as the top-level request."""
body: Final = _as_mapping(request.get("body"))
if not redact_body or body is None:
return without_classifier_audit(request)
redacted_body: Final = {**body}
perform_redaction(redacted_body, result=None)
return {**without_classifier_audit(request), "body": redacted_body}
def _redact_hidden_provider_specific_fields(params: Mapping[str, object]) -> None:
"""Redact the provider_specific_fields copied into metadata hidden_params, keeping each metadata dict."""
for metadata in (params.get("metadata"), params.get("litellm_metadata")):
if (
(readable := _as_mapping(metadata)) is not None
and (hidden_params := _as_mapping(readable.get("hidden_params"))) is not None
and (fields := _as_mapping(hidden_params.get("provider_specific_fields"))) is not None
and isinstance(metadata, dict)
):
metadata["hidden_params"] = {
**hidden_params,
"provider_specific_fields": _redacted_provider_specific_fields(fields),
}
def redacted_litellm_params(params: Mapping[str, object], *, redact_request_body: bool = True) -> dict[str, object]:
"""
Copy of litellm_params without classifier audit fields or baseline route request parameters.
redact_request_body also redacts the nested proxy_server_request body.
"""
request: Final = _as_mapping(params.get("proxy_server_request"))
return {
**params,
**{
@ -270,7 +330,11 @@ def redacted_litellm_params(params: Mapping[str, object]) -> dict[str, object]:
for key, value in params.items()
if key in ("metadata", "litellm_metadata") and isinstance(value, Mapping)
},
**({"proxy_server_request": without_classifier_audit(request)} if isinstance(request, Mapping) else {}),
**(
{"proxy_server_request": _redacted_proxy_server_request(request, redact_body=redact_request_body)}
if request is not None
else {}
),
}
@ -280,12 +344,17 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons
redact_streaming_responses=False skips the in-place redaction of the shared streaming
response entries; per-callback redaction hands each opted-out callback its own redacted
copy via redact_streaming_responses_for_custom_logger instead.
copy via redact_streaming_responses_for_custom_logger instead. It also leaves the shared
proxy request body and hidden provider fields as they are, since the spend log reads them.
"""
# Redact model_call_details
params: Final = model_call_details.get("litellm_params")
if isinstance(params, Mapping):
model_call_details["litellm_params"] = redacted_litellm_params(params)
params: Final = _as_mapping(model_call_details.get("litellm_params"))
if params is not None:
if redact_streaming_responses:
_redact_hidden_provider_specific_fields(params)
model_call_details["litellm_params"] = redacted_litellm_params(
params, redact_request_body=redact_streaming_responses
)
model_call_details["messages"] = [{"role": "user", "content": REDACTED_BY_LITELLM}]
model_call_details["prompt"] = ""
model_call_details["input"] = ""

View file

@ -0,0 +1,47 @@
import json
import os
from collections.abc import Mapping
from datetime import datetime
from typing import Final
import httpx
from pydantic import BaseModel
from litellm.integrations.custom_logger import CustomLogger
def _payload(kwargs: Mapping[str, object], response_obj: object) -> str:
params: Final = kwargs.get("litellm_params")
request: Final = params.get("proxy_server_request") if isinstance(params, Mapping) else None
standard: Final = kwargs.get("standard_logging_object")
return json.dumps(
{
"request": request,
"response": response_obj.model_dump() if isinstance(response_obj, BaseModel) else response_obj,
"standard_response": standard.get("response") if isinstance(standard, Mapping) else None,
},
default=str,
)
class SyncRedactionProbe(CustomLogger):
def log_success_event(
self, kwargs: Mapping[str, object], response_obj: object, start_time: datetime, end_time: datetime
) -> None:
with httpx.Client(trust_env=False) as client:
response: Final = client.post(os.environ["REDACTION_PROBE_URL"], content=_payload(kwargs, response_obj))
response.raise_for_status()
class RedactionProbe(CustomLogger):
async def async_log_success_event(
self, kwargs: Mapping[str, object], response_obj: object, start_time: datetime, end_time: datetime
) -> None:
async with httpx.AsyncClient(trust_env=False) as client:
response: Final = await client.post(
os.environ["REDACTION_PROBE_URL"], content=_payload(kwargs, response_obj)
)
response.raise_for_status()
probe: Final = RedactionProbe()

View file

@ -0,0 +1,485 @@
import asyncio
import json
import os
import uuid
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Final
import pytest
import yaml
from integration._support.client import Gateway, eventually, gateway_from_environment, held
from integration._support.database import read_rows
from integration._support.process import owned_proxy
from integration._support.redaction_probe import RedactionProbe, SyncRedactionProbe
from integration._support.wire import Reply, Request, Wire, wire_server
from redis import Redis
import litellm
from litellm.constants import REDACTED_BY_LITELLM
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
INPUT: Final = "private-input-redaction-canary"
REASONING: Final = "private-reasoning-redaction-canary"
OUTPUT: Final = "private-output-redaction-canary"
SIGNATURE: Final = "opaque-thought-signature-canary"
INSTRUCTIONS: Final = "Reply in French. redaction-instructions-canary"
BRIDGED: Final = "bridged-redaction-canary"
USAGE_DEPLOYMENT: Final = f"redaction-usage-{uuid.uuid4().hex}"
REDACT: Final = {"x-litellm-enable-message-redaction": "true"}
pytestmark = pytest.mark.timeout(240)
def _provider(request: Request) -> Reply:
if request.method == "GET" and request.target == "/v1/models":
return Reply(body=b'{"object":"list","data":[]}')
body: Final = json.loads(request.body)
if request.target == "/v1/embeddings":
assert body["input"] == INPUT, body
return Reply(
body=json.dumps(
{
"object": "list",
"model": body["model"],
"data": [{"object": "embedding", "index": 0, "embedding": [0.25, 0.5]}],
"usage": {"prompt_tokens": 10, "total_tokens": 10},
}
).encode()
)
if request.target == "/v1/completions":
assert body["prompt"] == INPUT, body
return Reply(
body=json.dumps(
{
"id": "cmpl-redaction-probe",
"object": "text_completion",
"created": 1,
"model": body["model"],
"choices": [{"text": OUTPUT, "index": 0, "finish_reason": "stop", "logprobs": None}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
).encode()
)
if request.target == "/v1/responses":
assert body["input"] == INPUT, body
return Reply(
body=json.dumps(
{
"id": "resp-redaction-probe",
"object": "response",
"created_at": 1,
"model": body["model"],
"status": "completed",
"output": [
{
"id": "msg-redaction-probe",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": OUTPUT, "annotations": []}],
}
],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
}
).encode()
)
if request.target == "/v1/messages":
if BRIDGED not in request.body.decode():
assert body["messages"] == [{"role": "user", "content": INPUT}], body
return Reply(
body=json.dumps(
{
"id": "msg-redaction-probe",
"type": "message",
"role": "assistant",
"model": body["model"],
"content": [
{"type": "thinking", "thinking": REASONING, "signature": "synthetic-signature"},
{"type": "text", "text": OUTPUT},
],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 10, "output_tokens": 5},
}
).encode()
)
assert body["messages"] == [{"role": "user", "content": INPUT}], body
payload: Final = {
"id": "chatcmpl-redaction-" + body.get("user", "probe"),
"object": "chat.completion",
"created": 1,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": OUTPUT,
"provider_specific_fields": {
"reasoning_content": REASONING,
"thinking_blocks": [{"thinking": REASONING}],
"reasoningContentBlocks": [{"reasoningText": {"text": REASONING}}],
"citations": [{"cited_text": INPUT}],
"thought_signatures": [SIGNATURE],
},
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
if body.get("stream"):
choice: Final = payload["choices"][0]
chunk: Final = {
**payload,
"object": "chat.completion.chunk",
"choices": [{"index": 0, "delta": choice["message"], "finish_reason": None}],
}
finish: Final = {
**payload,
"object": "chat.completion.chunk",
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
}
return Reply(
content_type="text/event-stream",
chunks=tuple(f"data: {json.dumps(event)}\n\n".encode() for event in (chunk, finish))
+ (b"data: [DONE]\n\n",),
)
return Reply(body=json.dumps(payload).encode())
@pytest.fixture(scope="module")
def redaction_proxy(tmp_path_factory: pytest.TempPathFactory) -> Iterator[tuple[Gateway, Wire, Wire]]:
tmp_path: Final = tmp_path_factory.mktemp("custom-logger-redaction")
with (
wire_server(_provider) as provider,
wire_server(lambda request: Reply()) as sink,
gateway_from_environment() as rig,
):
config: Final = tmp_path / "redaction.yaml"
config.write_text(
yaml.safe_dump(
{
"model_list": [
{
"model_name": name,
"litellm_params": {
"model": model,
"api_base": provider.url if model.startswith("anthropic/") else provider.url + "/v1",
"api_key": "synthetic-provider-key",
},
}
for name, model in (
("redaction-probe", "openai/gpt-4o-mini"),
("redaction-text", "openai/gpt-3.5-turbo-instruct"),
("redaction-embedding", "openai/text-embedding-3-large"),
("redaction-responses", "openai/gpt-5.6-sol"),
("redaction-messages", "anthropic/claude-sonnet-5-5"),
)
]
+ [
{
"model_name": "redaction-usage",
"litellm_params": {
"model": "anthropic/claude-sonnet-5-5",
"api_base": provider.url,
"api_key": "synthetic-provider-key",
"rpm": 100000,
"tpm": 100000000,
},
"model_info": {"id": USAGE_DEPLOYMENT},
}
],
"litellm_settings": {
"callbacks": ["integration._support.redaction_probe.probe"],
"enable_redis_auth_cache": True,
"cache": True,
"cache_params": {
"type": "redis",
"host": "os.environ/REDIS_HOST",
"port": "os.environ/REDIS_PORT",
},
},
"general_settings": {
"master_key": "os.environ/LITELLM_MASTER_KEY",
"database_url": "os.environ/DATABASE_URL",
"disable_model_info_refresh": True,
"store_prompts_in_spend_logs": True,
},
"router_settings": {"redis_host": "os.environ/REDIS_HOST", "redis_port": "os.environ/REDIS_PORT"},
}
)
)
with owned_proxy(
rig, tmp_path, {"REDACTION_PROBE_URL": sink.url, "STORE_MODEL_IN_DB": "False"}, config=config, workers=2
) as proxy:
yield proxy, provider, sink
@pytest.mark.parametrize("redact", [False, True])
@pytest.mark.parametrize("stream", [False, True])
def test_custom_logger_redacts_nested_input_and_provider_reasoning(
redaction_proxy: tuple[Gateway, Wire, Wire], redact: bool, stream: bool
) -> None:
proxy, provider, sink = redaction_proxy
response: Final = proxy.request(
"POST",
"/v1/chat/completions",
{
"model": "redaction-probe",
"messages": [{"role": "user", "content": INPUT}],
"stream": stream,
"cache": {"no-cache": True},
},
headers={"x-litellm-enable-message-redaction": "true"} if redact else {},
)
assert response.status_code == 200, response.text
first: Final = (
json.loads(next(line[6:] for line in response.text.splitlines() if line.startswith("data: ")))
if stream
else response.json()
)
message: Final = first["choices"][0]["delta" if stream else "message"]
assert message["content"] == OUTPUT, message
assert REASONING in json.dumps(message["provider_specific_fields"]), message
received: Final = eventually(sink.drain, lambda values: len(values) == 1, seconds=20)
callback: Final = json.loads(received[0].body)
body: Final = callback["request"]["body"]
assert "proxy_server_request" not in body, body
assert len(tuple(request for request in provider.drain() if request.method == "POST")) == 1
logged: Final = json.dumps(callback)
assert SIGNATURE in logged, callback
if redact:
assert INPUT not in logged and REASONING not in logged and OUTPUT not in logged, callback
else:
assert INPUT in logged and REASONING in logged and OUTPUT in logged, callback
@pytest.mark.parametrize("redact", [False, True])
@pytest.mark.parametrize(
("route", "field", "model"),
[("/v1/completions", "prompt", "redaction-text"), ("/v1/embeddings", "input", "redaction-embedding")],
)
def test_custom_logger_redacts_prompt_and_embedding_input(
redaction_proxy: tuple[Gateway, Wire, Wire], route: str, field: str, model: str, redact: bool
) -> None:
proxy, provider, sink = redaction_proxy
response: Final = proxy.request(
"POST",
route,
{"model": model, field: INPUT, "cache": {"no-cache": True}},
headers={"x-litellm-enable-message-redaction": "true"} if redact else {},
)
assert response.status_code == 200, response.text
if field == "input":
assert response.json()["data"][0]["embedding"] == [0.25, 0.5]
else:
assert response.json()["choices"][0]["text"] == OUTPUT
received: Final = eventually(sink.drain, bool, seconds=20)
assert len(received) == 1, received
callback: Final = json.loads(received[0].body)
assert "proxy_server_request" not in callback["request"]["body"]
assert len(tuple(request for request in provider.drain() if request.method == "POST")) == 1
assert (INPUT in json.dumps(callback)) is not redact, callback
@pytest.mark.parametrize("redact", [False, True])
@pytest.mark.parametrize("native_messages", [False, True])
def test_native_messages_and_responses_logging_redaction(
redaction_proxy: tuple[Gateway, Wire, Wire], native_messages: bool, redact: bool
) -> None:
proxy, provider, sink = redaction_proxy
body: Final = (
{"model": "redaction-messages", "messages": [{"role": "user", "content": INPUT}], "max_tokens": 64}
if native_messages
else {"model": "redaction-responses", "input": INPUT}
)
response: Final = proxy.request(
"POST",
"/v1/messages" if native_messages else "/v1/responses",
{**body, "cache": {"no-cache": True}},
headers={"x-litellm-enable-message-redaction": "true"} if redact else {},
)
assert response.status_code == 200, response.text
if native_messages:
assert response.json()["content"][1]["text"] == OUTPUT
assert response.json()["content"][0]["thinking"] == REASONING
else:
assert response.json()["output"][0]["content"][0]["text"] == OUTPUT
received: Final = eventually(sink.drain, bool, seconds=20)
assert len(received) == 1, received
logged: Final = received[0].body.decode()
assert len(tuple(request for request in provider.drain() if request.method == "POST")) == 1
assert (INPUT in logged) is not redact, logged
assert (OUTPUT in logged) is not redact, logged
if native_messages:
assert (REASONING in logged) is not redact, logged
def test_concurrent_redacted_and_unredacted_callbacks_remain_isolated(
redaction_proxy: tuple[Gateway, Wire, Wire],
) -> None:
proxy, provider, sink = redaction_proxy
users: Final = tuple(f"redaction-chaos-{index}" for index in range(4))
def send(index: int):
return proxy.request(
"POST",
"/v1/chat/completions",
{
"model": "redaction-probe",
"messages": [{"role": "user", "content": INPUT}],
"user": users[index],
"cache": {"no-cache": True},
},
headers={"x-litellm-enable-message-redaction": "true"} if index % 2 else {},
)
with ThreadPoolExecutor(max_workers=4) as pool:
responses: Final = tuple(pool.map(send, range(4)))
for response in responses:
assert response.status_code == 200, response.text
assert response.json()["choices"][0]["message"]["content"] == OUTPUT
assert REASONING in json.dumps(response.json()["choices"][0]["message"])
batches: Final[list[Request]] = [] # mutable-ok: drain consumes the sink queue, so polls retain previous events
def observed() -> tuple[Request, ...]:
batches.extend(sink.drain())
return tuple(batches)
events: Final = eventually(observed, lambda values: len(values) == 4, seconds=20)
callbacks: Final = tuple(json.loads(event.body) for event in events)
assert {callback["request"]["body"]["user"] for callback in callbacks} == set(users)
for callback in callbacks:
index: Final = users.index(callback["request"]["body"]["user"])
logged: Final = json.dumps(callback)
assert (INPUT in logged) is (index % 2 == 0), callback
assert (REASONING in logged) is (index % 2 == 0), callback
assert (OUTPUT in logged) is (index % 2 == 0), callback
assert len(tuple(request for request in provider.drain() if request.method == "POST")) == 4
def _drained(wire: Wire, count: int) -> tuple[Request, ...]:
batches: Final[list[Request]] = [] # mutable-ok: drain consumes the queue, so polls retain previous events
def observed() -> tuple[Request, ...]:
batches.extend(wire.drain())
return tuple(batches)
return eventually(observed, lambda values: len(values) >= count, seconds=30)
def test_redacted_responses_follow_up_replays_original_instructions(
redaction_proxy: tuple[Gateway, Wire, Wire],
) -> None:
proxy, provider, sink = redaction_proxy
end_user: Final = f"redaction-replay-{uuid.uuid4().hex}"
first: Final = proxy.request(
"POST",
"/v1/responses",
{
"model": "redaction-messages",
"instructions": INSTRUCTIONS,
"input": f"{BRIDGED} turn one",
"user": end_user,
"cache": {"no-cache": True},
},
headers=REDACT,
)
assert first.status_code == 200, first.text
eventually(
lambda: read_rows('SELECT request_id FROM "LiteLLM_SpendLogs" WHERE end_user = %s', (end_user,)),
lambda rows: len(rows) == 1,
seconds=70,
)
assert len(_drained(provider, 1)) == 1
follow_up: Final = proxy.request(
"POST",
"/v1/responses",
{
"model": "redaction-messages",
"previous_response_id": first.json()["id"],
"input": f"{BRIDGED} turn two",
"user": end_user,
"cache": {"no-cache": True},
},
headers=REDACT,
)
assert follow_up.status_code == 200, follow_up.text
sent: Final = tuple(json.loads(request.body) for request in _drained(provider, 1))
assert len(_drained(sink, 2)) == 2
assert len(sent) == 1, sent
assert len(sent[0]["messages"]) == 3, sent[0]
system: Final = json.dumps(sent[0].get("system"))
assert INSTRUCTIONS in system and REDACTED_BY_LITELLM not in system, sent[0]
def test_redacted_router_usage_is_counted_once(redaction_proxy: tuple[Gateway, Wire, Wire]) -> None:
proxy, provider, sink = redaction_proxy
requests: Final = 48
def send(index: int):
return proxy.request(
"POST",
"/v1/chat/completions",
{
"model": "redaction-usage",
"messages": [{"role": "user", "content": f"{BRIDGED} usage {index}"}],
"cache": {"no-cache": True},
},
headers=REDACT,
)
with ThreadPoolExecutor(max_workers=16) as pool:
responses: Final = tuple(pool.map(send, range(requests)))
for response in responses:
assert response.status_code == 200, response.text
assert response.json()["usage"]["total_tokens"] == 15, response.json()
def counted_tokens() -> int:
with Redis(host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"])) as cache:
keys: Final = tuple(cache.scan_iter(f"global_router:{USAGE_DEPLOYMENT}:*:tpm:*"))
return sum(int(float(cache.get(key) or 0)) for key in keys)
_drained(sink, requests)
held(counted_tokens, lambda total: total == 15 * requests, holding=3, seconds=30)
assert len(_drained(provider, requests)) == requests
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("redact", [False, True])
async def test_sdk_success_callback_redaction_over_http(
monkeypatch: pytest.MonkeyPatch, asynchronous: bool, redact: bool
) -> None:
with wire_server(_provider) as provider, wire_server(lambda request: Reply()) as sink:
monkeypatch.setenv("REDACTION_PROBE_URL", sink.url)
messages: Final = [{"role": "user", "content": INPUT}]
request: Final = {"body": {"messages": messages, "model": "gpt-4o-mini"}}
callback: Final = RedactionProbe().async_log_success_event if asynchronous else SyncRedactionProbe()
parameters: Final = {
"model": "openai/gpt-4o-mini",
"messages": messages,
"api_base": provider.url + "/v1",
"api_key": "synthetic-sdk-provider-key",
"proxy_server_request": request,
"turn_off_message_logging": redact,
"success_callback": [callback],
}
response: Final = (
await litellm.acompletion(**parameters)
if asynchronous
else await asyncio.to_thread(litellm.completion, **parameters)
)
assert response.choices[0].message.content == OUTPUT
assert REASONING in json.dumps(response.choices[0].message.provider_specific_fields)
await GLOBAL_LOGGING_WORKER.flush()
delivered: Final = await asyncio.to_thread(eventually, sink.drain, bool, seconds=20)
assert len(delivered) == 1, delivered
logged: Final = delivered[0].body.decode()
assert request["body"]["messages"] == messages
if redact:
assert INPUT not in logged and REASONING not in logged and OUTPUT not in logged, logged
else:
assert INPUT in logged and REASONING in logged and OUTPUT in logged, logged

View file

@ -6,6 +6,7 @@ but litellm_params["litellm_metadata"] is None.
"""
import asyncio
import copy
import importlib
import json
import os
@ -36,12 +37,131 @@ from litellm.responses.main import mock_responses_api_response
from litellm.types.router import BaselineRouteStamp
from litellm.types.utils import (
ModelResponse,
ModelResponseStream,
ResponsesAPIResponse,
StandardLoggingPayload,
TextCompletionResponse,
)
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
SECRET: Final = "private-redaction-canary"
SIGNATURE: Final = "opaque-thought-signature"
def _provider_fields() -> dict[str, object]:
return {
"reasoning_content": SECRET,
"thinking_blocks": [{"type": "thinking", "thinking": SECRET}],
"reasoningContentBlocks": [{"reasoningText": {"text": SECRET}}],
"citations": [{"cited_text": SECRET}],
"thought_signatures": [SIGNATURE],
}
def _request_params(metadata: dict[str, object]) -> dict[str, object]:
return {
"metadata": metadata,
"proxy_server_request": {
"url": "http://localhost/v1/chat/completions",
"body": {
"model": "redaction-model",
"system": "kept-system",
"messages": [{"role": "user", "content": SECRET}],
"metadata": metadata,
},
},
}
@pytest.mark.parametrize("shape", ["object", "dict_message", "dict_delta"])
def test_redaction_reuses_message_rules_for_request_body_and_provider_fields(shape: str) -> None:
response: Final = ModelResponse(
choices=[
{
"index": 0,
"message": {"role": "assistant", "content": SECRET, "provider_specific_fields": _provider_fields()},
}
]
)
result: Final = (
response
if shape == "object"
else {"choices": [{"index": 0, "delta": {"content": SECRET, "provider_specific_fields": _provider_fields()}}]}
if shape == "dict_delta"
else response.model_dump()
)
original_result: Final = copy.deepcopy(result)
params: Final = _request_params({"session_id": "session"})
original_params: Final = copy.deepcopy(params)
details: Final[dict[str, object]] = {"litellm_params": params}
redacted: Final = perform_redaction(details, result)
assert SECRET not in str(redacted) and SECRET not in str(details), (redacted, details)
assert SIGNATURE in str(redacted)
body: Final = details["litellm_params"]["proxy_server_request"]["body"]
assert body["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
assert body["system"] == "kept-system" and body["model"] == "redaction-model"
assert body["metadata"] is params["metadata"]
assert result == original_result and params == original_params
def test_redaction_redacts_reasoning_copied_into_hidden_params_in_place() -> None:
hidden_params: Final = {"model_id": "deployment", "provider_specific_fields": _provider_fields()}
original_hidden_params: Final = copy.deepcopy(hidden_params)
metadata: Final[dict[str, object]] = {"session_id": "session", "hidden_params": hidden_params}
params: Final = _request_params(metadata)
details: Final[dict[str, object]] = {"litellm_params": params}
perform_redaction(details, None)
assert details["litellm_params"]["metadata"] is metadata
assert SECRET not in str(details), details
assert metadata["hidden_params"]["model_id"] == "deployment"
assert metadata["hidden_params"]["provider_specific_fields"]["thought_signatures"] == [SIGNATURE]
assert hidden_params == original_hidden_params
def test_per_callback_redaction_leaves_shared_request_body_and_hidden_params() -> None:
metadata: Final[dict[str, object]] = {"hidden_params": {"provider_specific_fields": _provider_fields()}}
params: Final = _request_params(metadata)
original_params: Final = copy.deepcopy(params)
details: Final[dict[str, object]] = {"litellm_params": params}
perform_redaction(details, None, redact_streaming_responses=False)
assert details["litellm_params"] == original_params
def test_logger_level_redaction_redacts_its_copy_of_the_request_body() -> None:
params: Final = _request_params({"session_id": "session"})
original_params: Final = copy.deepcopy(params)
details: Final[dict[str, object]] = {"litellm_params": params}
redacted: Final = CustomLogger(
turn_off_message_logging=True
).redact_standard_logging_payload_from_model_call_details(details)
assert SECRET not in str(redacted["litellm_params"]), redacted
assert details["litellm_params"] == original_params
@pytest.mark.parametrize("key", ["complete_streaming_response", "async_complete_streaming_response"])
def test_streaming_redaction_redacts_delta_provider_fields_without_touching_the_chunk_dict(key: str) -> None:
fields: Final = _provider_fields()
response: Final = ModelResponseStream(
choices=[
litellm.utils.StreamingChoices(delta=litellm.utils.Delta(content=SECRET, provider_specific_fields=fields))
]
)
details: Final[dict[str, object]] = {"stream": True, key: response}
perform_redaction(details, None)
assert SECRET not in str(response.choices[0].delta), response
assert response.choices[0].delta.provider_specific_fields["thought_signatures"] == [SIGNATURE]
assert fields == _provider_fields()
@pytest.fixture(autouse=True)
def _reset_global_redaction():