feat(llm): drop opt-in raw body capture from the request log — headers and details only

This commit is contained in:
Ahmed Allam 2026-09-22 23:13:02 +00:00
parent 64058d7607
commit 45e69f0677
2 changed files with 5 additions and 191 deletions

View file

@ -13,12 +13,8 @@ the attempt travels free-form: the response headers minus credential-bearing
names, and a ``details`` object with the request parameters, the full usage
object, the SDK's hidden parameters and the error body. Content (prompts,
completions, tool arguments) and credentials are removed from both, values are
redacted and every string, list and object is bounded.
The raw request and response bodies are captured only when the deployment opts
in (``STRIX_LLM_REQUEST_LOG_CAPTURE_BODIES``); they never appear in the log
line or in :meth:`LlmRequestEvent.to_dict`, a persistence sink has to store them
on purpose.
redacted and every string, list and object is bounded. Raw request and
response bodies are never captured.
Sinks are plain callables. The built-in sink writes one log line per event;
deployments register their own (a database, a queue) with
@ -32,13 +28,12 @@ import contextlib
import dataclasses
import json
import logging
import os
import re
import time
import uuid
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
from contextvars import ContextVar, Token
from dataclasses import asdict, dataclass, field, replace
from dataclasses import asdict, dataclass, replace
from datetime import UTC, date, datetime
from typing import TYPE_CHECKING, Any, Literal, cast
from urllib.parse import urlsplit
@ -70,10 +65,6 @@ Route = Literal["litellm", "openai"]
ERROR_MESSAGE_MAX_CHARS = 2000
CAPTURE_BODIES_ENV = "STRIX_LLM_REQUEST_LOG_CAPTURE_BODIES"
BODY_MAX_BYTES_ENV = "STRIX_LLM_REQUEST_LOG_BODY_MAX_BYTES"
DEFAULT_BODY_MAX_BYTES = 1024 * 1024
@dataclass(frozen=True)
class LlmCallContext:
@ -123,25 +114,13 @@ class LlmRequestEvent:
# Free-form: request parameters, full usage object, SDK hidden params,
# error body ... with content and credentials removed and sizes bounded.
details: dict[str, Any] | None = None
# Opt-in only (STRIX_LLM_REQUEST_LOG_CAPTURE_BODIES). Redacted JSON text.
request_body: str | None = field(default=None, repr=False)
response_body: str | None = field(default=None, repr=False)
request_body_truncated: bool = False
response_body_truncated: bool = False
def to_dict(self) -> dict[str, Any]:
"""The row-shaped view: everything except the raw bodies."""
data = asdict(self)
data.pop("request_body", None)
data.pop("response_body", None)
data["started_at"] = self.started_at.isoformat()
data["finished_at"] = self.finished_at.isoformat()
return data
@property
def has_bodies(self) -> bool:
return self.request_body is not None or self.response_body is not None
LlmRequestSink = Callable[[LlmRequestEvent], None]
@ -463,17 +442,6 @@ def merge_details(*parts: tuple[str, object]) -> dict[str, Any] | None:
return sanitize_details(merged)
def bodies_enabled() -> bool:
return os.getenv(CAPTURE_BODIES_ENV, "").strip().lower() in {"1", "true", "yes", "on"}
def body_max_bytes() -> int:
raw = os.getenv(BODY_MAX_BYTES_ENV, "").strip()
if raw.isdigit() and int(raw) > 0:
return int(raw)
return DEFAULT_BODY_MAX_BYTES
def json_text(value: object) -> str | None:
"""``value`` as compact JSON text; None when it cannot be serialized."""
if value is None:
@ -490,19 +458,6 @@ def json_text(value: object) -> str | None:
return None
def body_text(value: object) -> tuple[str | None, bool]:
"""``(redacted body text, truncated)``, bounded at :func:`body_max_bytes`."""
text = json_text(value)
if text is None:
return None, False
limit = body_max_bytes()
encoded = text.encode("utf-8")
truncated = len(encoded) > limit
if truncated:
text = encoded[:limit].decode("utf-8", errors="ignore")
return redact(text), truncated
def json_size(value: object) -> int | None:
"""Byte length of ``value`` serialized as compact JSON; None when it cannot be serialized."""
text = json_text(value)
@ -592,7 +547,7 @@ def _litellm_finish_reason(response: object) -> str | None:
def _litellm_request_body(kwargs: Mapping[str, Any]) -> object:
"""The request as LiteLLM sent it.
"""The request as LiteLLM sent it, for its size only.
The provider payload LiteLLM built (``additional_args.complete_input_dict``)
when the adapter recorded it; otherwise model, messages and the provider
@ -612,14 +567,6 @@ def _litellm_request_body(kwargs: Mapping[str, Any]) -> object:
return body
def _litellm_response_body(kwargs: Mapping[str, Any], response: object) -> object:
"""The provider's own response text when LiteLLM kept it, else its normalized response."""
original = kwargs.get("original_response")
if isinstance(original, str) and original.strip():
return original
return response
def _count(value: object) -> int | None:
return len(cast("Sequence[object]", value)) if isinstance(value, Sequence | Mapping) else None
@ -745,9 +692,6 @@ def event_from_litellm(
hidden_headers
)
headers = headers_from_response(response_headers) or headers_from_response(hidden_headers)
request_body, request_truncated = (
body_text(_litellm_request_body(kwargs)) if bodies_enabled() else (None, False)
)
ttft_ms: int | None = None
if streaming:
@ -779,8 +723,6 @@ def event_from_litellm(
request_bytes=json_size(_litellm_request_body(kwargs)),
time_to_first_token_ms=ttft_ms,
response_headers=headers,
request_body=request_body,
request_body_truncated=request_truncated,
)
if outcome == "success":
return _litellm_success(base, kwargs, response, hidden, slo)
@ -834,9 +776,6 @@ def _litellm_success(
cost = _float_or_none(kwargs.get("response_cost"))
if cost is None:
cost = _float_or_none(hidden.get("response_cost"))
response_body, response_truncated = (
body_text(_litellm_response_body(kwargs, response)) if bodies_enabled() else (None, False)
)
return replace(
base,
status_code=200,
@ -853,8 +792,6 @@ def _litellm_success(
("response", _litellm_response_details(response)),
("litellm", _litellm_hidden_details(hidden, slo)),
),
response_body=response_body,
response_body_truncated=response_truncated,
)
@ -871,8 +808,6 @@ def _litellm_failure(
request_id = base.provider_request_id
headers = base.response_headers
response_bytes: int | None = None
response_body: str | None = None
response_truncated = False
if exc is not None:
status_code = _int_or_none(getattr(exc, "status_code", None))
error_type = type(exc).__name__
@ -881,8 +816,6 @@ def _litellm_failure(
request_id = request_id or request_id_from_headers(exc_headers)
headers = headers or headers_from_response(exc_headers)
response_bytes = _exception_body_size(exc)
if bodies_enabled():
response_body, response_truncated = body_text(_exception_body(exc))
if status_code is None:
code = error_info.get("error_code")
status_code = _int_or_none(code)
@ -907,8 +840,6 @@ def _litellm_failure(
("error", _exception_details(exc) or dict(error_info) or None),
("litellm", _litellm_hidden_details(hidden, slo)),
),
response_body=response_body,
response_body_truncated=response_truncated,
)
@ -1108,8 +1039,6 @@ class RequestLoggingModel(Model):
request_bytes=request.size,
time_to_first_token_ms=ttft_ms,
finish_reason=finish_reason,
request_body=request.body,
request_body_truncated=request.truncated,
)
if exc is None:
usage = _openai_usage(response) if response is not None else {}
@ -1119,9 +1048,6 @@ class RequestLoggingModel(Model):
if raw_response is not None
else (response.output if response is not None else None)
)
response_body, response_truncated = (
body_text(body_source) if bodies_enabled() else (None, False)
)
return replace(
base,
response_id=response_id,
@ -1134,13 +1060,8 @@ class RequestLoggingModel(Model):
("request", request.details),
("response", _openai_response_details(response, raw_response)),
),
response_body=response_body,
response_body_truncated=response_truncated,
)
status, request_id = _openai_error_fields(exc)
response_body, response_truncated = (
body_text(_exception_body(exc)) if bodies_enabled() else (None, False)
)
return replace(
base,
outcome="error",
@ -1154,8 +1075,6 @@ class RequestLoggingModel(Model):
("request", request.details),
("error", _exception_details(exc)),
),
response_body=response_body,
response_body_truncated=response_truncated,
)
@staticmethod
@ -1194,8 +1113,7 @@ class RequestLoggingModel(Model):
details["tool_count"] = len(tools)
details["previous_response_id"] = previous_response_id
details["conversation_id"] = conversation_id
text, truncated = body_text(body) if bodies_enabled() else (None, False)
return _OpenAiRequest(size=json_size(body), details=details, body=text, truncated=truncated)
return _OpenAiRequest(size=json_size(body), details=details)
async def get_response(
self,
@ -1353,8 +1271,6 @@ class _OpenAiRequest:
size: int | None
details: dict[str, Any]
body: str | None
truncated: bool
def _model_settings_dict(model_settings: ModelSettings) -> dict[str, Any]:
@ -1412,8 +1328,6 @@ __all__ = [
"RequestLoggingModel",
"api_host",
"bind_call_context",
"bodies_enabled",
"body_text",
"clean_error_message",
"current_call_context",
"emit",

View file

@ -614,7 +614,6 @@ def test_litellm_success_carries_sizes_finish_reason_headers_and_details() -> No
"cache_hit": False,
}
assert event.time_to_first_token_ms is None
assert event.request_body is None and event.response_body is None
assert "sk-ant-secret" not in str(event.to_dict())
assert "SECRET PROMPT" not in str(event.to_dict())
assert "SECRET COMPLETION" not in str(event.to_dict())
@ -710,83 +709,12 @@ def test_to_dict_and_log_line_include_new_fields() -> None:
"finish_reason",
"response_headers",
"details",
"request_body_truncated",
"response_body_truncated",
):
assert key in data
assert "request_body" not in data
assert "response_body" not in data
assert "rate_limit" not in data
assert data["time_to_first_token_ms"] == 100
# --------------------------------------------------------------------------- #
# opt-in raw bodies #
# --------------------------------------------------------------------------- #
def test_bodies_are_off_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(request_log.CAPTURE_BODIES_ENV, raising=False)
assert request_log.bodies_enabled() is False
for value in ("0", "false", "no", "", "maybe"):
monkeypatch.setenv(request_log.CAPTURE_BODIES_ENV, value)
assert request_log.bodies_enabled() is False
for value in ("1", "true", "YES", " on "):
monkeypatch.setenv(request_log.CAPTURE_BODIES_ENV, value)
assert request_log.bodies_enabled() is True
def test_litellm_bodies_captured_only_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
kwargs = _anthropic_kwargs(None, headers={"request-id": "req_ok"})
payload = {
"model": "claude-sonnet-4-5",
"system": "SYSTEM",
"messages": [
{"role": "user", "content": "SECRET PROMPT sk-ant-api03-abcdefghijklmnopqrstu"}
],
}
kwargs["additional_args"] = {"complete_input_dict": payload}
kwargs["original_response"] = '{"id":"msg_01abc","content":[{"text":"SECRET COMPLETION"}]}'
monkeypatch.delenv(request_log.CAPTURE_BODIES_ENV, raising=False)
off = request_log.event_from_litellm(kwargs, _FakeResponse(), None, None, outcome="success")
assert off.request_body is None and off.response_body is None
assert off.has_bodies is False
monkeypatch.setenv(request_log.CAPTURE_BODIES_ENV, "true")
on = request_log.event_from_litellm(kwargs, _FakeResponse(), None, None, outcome="success")
assert on.has_bodies is True
assert on.request_body is not None and "SECRET PROMPT" in on.request_body
assert "sk-ant-api03" not in on.request_body
assert on.response_body == kwargs["original_response"]
assert on.request_body_truncated is False and on.response_body_truncated is False
# The bodies never travel with the row-shaped view or the log line.
assert "SECRET PROMPT" not in str(on.to_dict())
assert "SECRET COMPLETION" not in str(on.to_dict())
assert "SECRET" not in repr(on)
def test_litellm_failure_body_is_the_error_body(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(request_log.CAPTURE_BODIES_ENV, "1")
exc = _anthropic_error(400, ANTHROPIC_BLOCK_BODY, {"request-id": "req_b"})
kwargs = _anthropic_kwargs(exc)
event = request_log.event_from_litellm(kwargs, None, None, None, outcome="error")
assert event.response_body is not None
assert "content filtering policy" in event.response_body
assert event.request_body is not None and "SECRET PROMPT" in event.request_body
def test_bodies_are_truncated_at_the_configured_limit(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(request_log.CAPTURE_BODIES_ENV, "1")
monkeypatch.setenv(request_log.BODY_MAX_BYTES_ENV, "64")
text, truncated = request_log.body_text({"content": "é" * 500})
assert truncated is True
assert text is not None and len(text.encode("utf-8")) <= 64
monkeypatch.setenv(request_log.BODY_MAX_BYTES_ENV, "not-a-number")
assert request_log.body_max_bytes() == request_log.DEFAULT_BODY_MAX_BYTES
assert request_log.body_text(None) == (None, False)
# --------------------------------------------------------------------------- #
# Native OpenAI route #
# --------------------------------------------------------------------------- #
@ -1031,34 +959,6 @@ async def test_openai_route_success_carries_request_and_response_sizes(
assert event.details["request"]["tool_count"] == 1
assert event.details["request"]["input_items"] == 1
assert event.details["response"]["usage"]["input_tokens"] == 10
assert event.request_body is None
assert "SYSTEM SECRET INSTRUCTIONS" not in str(event.to_dict())
@pytest.mark.asyncio
async def test_openai_route_bodies_when_enabled(
captured: list[LlmRequestEvent], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(request_log.CAPTURE_BODIES_ENV, "1")
created = ResponseCreatedEvent(
response=_completed_event("resp_body").response,
sequence_number=0,
type="response.created",
)
model = request_log.RequestLoggingModel(
_Inner(stream_events=[created, _completed_event("resp_body")]),
model_name="gpt-5",
provider="openai",
base_url=None,
)
args = list(_CALL_ARGS)
args[0] = "SYSTEM SECRET INSTRUCTIONS"
async for _ in model.stream_response(*args, **_CALL_KWARGS):
pass
event = captured[0]
assert event.request_body is not None and "SYSTEM SECRET INSTRUCTIONS" in event.request_body
assert event.response_body is not None and '"id":"resp_body"' in event.response_body
assert event.details is not None and event.details["response"]["id"] == "resp_body"
assert "SYSTEM SECRET INSTRUCTIONS" not in str(event.to_dict())