diff --git a/strix/llm/request_log.py b/strix/llm/request_log.py index 705e3f0a..c128c1b4 100644 --- a/strix/llm/request_log.py +++ b/strix/llm/request_log.py @@ -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", diff --git a/tests/test_llm_request_log.py b/tests/test_llm_request_log.py index 72fb865c..5ae56432 100644 --- a/tests/test_llm_request_log.py +++ b/tests/test_llm_request_log.py @@ -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())