mirror of
https://github.com/usestrix/strix.git
synced 2026-10-15 04:18:10 +00:00
feat(llm): drop opt-in raw body capture from the request log — headers and details only
This commit is contained in:
parent
64058d7607
commit
45e69f0677
2 changed files with 5 additions and 191 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue