Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
4f401a8
test(integration): reproduce passthrough upstream error body missing …
yucheng-berri Sep 23, 2026
e0c70f1
fix(passthrough): log upstream 4xx/5xx error bodies and carry them in…
yucheng-berri Sep 23, 2026
566060a
fix(error_normalization): let the passthrough prefix win over upstrea…
yucheng-berri Sep 23, 2026
2ab1a2e
fix(passthrough): honor message redaction for upstream error bodies
yucheng-berri Sep 23, 2026
23c8a9c
fix(passthrough): bound the upstream error body read and sanitize it …
yucheng-berri Sep 23, 2026
4b44ff4
refactor(passthrough): use the Sequence import directly in the allowe…
yucheng-berri Sep 23, 2026
a947ba7
fix(passthrough): rechunk the upstream error stream so the preview re…
yucheng-berri Sep 23, 2026
24c5ace
test(integration): audit matrix for passthrough upstream error visibi…
yucheng-berri Sep 23, 2026
7723d5b
Merge remote-tracking branch 'origin/main' into litellm_passthrough_u…
yucheng-berri Sep 23, 2026
ddcef2e
fix(passthrough): drop the restating docstring on the upstream failur…
yucheng-berri Sep 23, 2026
229938c
merge: origin/main into litellm_passthrough_upstream_error_body_logging
yucheng-berri Sep 23, 2026
23a7284
test(integration): drop the retired covers markers from the passthrou…
yucheng-berri Sep 23, 2026
8618621
fix(passthrough): keep the upstream status when the error body peek f…
yucheng-berri Sep 23, 2026
5d57d25
test(passthrough): cover the relay aclose in the mid-read failure test
yucheng-berri Sep 23, 2026
c3c5676
fix(passthrough): relay decoded partial body on mid-read failure
yucheng-berri Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions litellm/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -1518,6 +1518,7 @@
CLOUDZERO_EXPORT_INTERVAL_MINUTES: Final = int(os.getenv("CLOUDZERO_EXPORT_INTERVAL_MINUTES", 60))
MCP_TOOL_NAME_PREFIX: Final = "mcp_tool"
MAXIMUM_TRACEBACK_LINES_TO_LOG: Final = int(os.getenv("MAXIMUM_TRACEBACK_LINES_TO_LOG", 100))
PASSTHROUGH_UPSTREAM_ERROR_BODY_MAX_LOG_CHARS: Final = 4096

# Headers to control callbacks
X_LITELLM_DISABLE_CALLBACKS: Final = "x-litellm-disable-callbacks"
Expand Down
2 changes: 1 addition & 1 deletion litellm/litellm_core_utils/error_normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,13 @@ class _HasProxyErrorType(Protocol):


_MESSAGE_PATTERNS: Final[tuple[tuple[re.Pattern[str], str], ...]] = (
(re.compile(r"upstream passthrough request failed", re.IGNORECASE), UPSTREAM_PASSTHROUGH),
(
re.compile(r"budget has been exceeded|max budget|exceeded.*budget|crossed budget", re.IGNORECASE),
BUDGET_EXCEEDED,
),
(re.compile(r"no healthy deployments?|no deployments available", re.IGNORECASE), NO_HEALTHY_DEPLOYMENTS),
(re.compile(r"not allowed to access model due to tags configuration", re.IGNORECASE), MODEL_ACCESS_DENIED),
(re.compile(r"upstream passthrough request failed", re.IGNORECASE), UPSTREAM_PASSTHROUGH),
(re.compile(r"is not supported for provider|not implemented", re.IGNORECASE), UNSUPPORTED_OPERATION),
(
re.compile(r"context window|context length|(prompt|input) is too long|tokens? ?> ?\d+ ?maximum", re.IGNORECASE),
Expand Down
144 changes: 117 additions & 27 deletions litellm/proxy/pass_through_endpoints/pass_through_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import posixpath
import traceback
from base64 import b64encode
from collections.abc import AsyncGenerator, Callable, Iterable, Mapping, Sequence
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterable, Mapping, Sequence
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
from dataclasses import dataclass
from datetime import datetime
from itertools import count, groupby
Expand Down Expand Up @@ -41,6 +41,8 @@
from litellm._uuid import uuid
from litellm.constants import (
MAXIMUM_TRACEBACK_LINES_TO_LOG,
PASSTHROUGH_UPSTREAM_ERROR_BODY_MAX_LOG_CHARS,
REDACTED_BY_LITELLM,
SESSION_ID_OMITTED_METADATA_KEY,
WEBSOCKET_CLOSE_REASON_MAX_BYTES,
)
Expand All @@ -56,6 +58,7 @@
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.litellm_logging import _get_masked_values
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.litellm_core_utils.redact_messages import should_redact_message_logging
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.llms.base_llm.managed_resources.utils import (
resolve_passthrough_managed_id_provider,
Expand Down Expand Up @@ -851,23 +854,106 @@ def _resolve_team_callback_wiring(
)


def _truncate_upstream_error_body(body: str) -> str:
if len(body) <= PASSTHROUGH_UPSTREAM_ERROR_BODY_MAX_LOG_CHARS:
return body
return (
f"{body[:PASSTHROUGH_UPSTREAM_ERROR_BODY_MAX_LOG_CHARS]}... "
f"(truncated at {PASSTHROUGH_UPSTREAM_ERROR_BODY_MAX_LOG_CHARS} chars)"
)


def _sanitize_upstream_error_body(body: str) -> str:
return " ".join("".join(char if char.isprintable() else " " for char in body).split())


class _PrefixReplayStream(httpx.AsyncByteStream):
def __init__(self, prefix: bytes, rest: AsyncIterator[bytes], upstream: httpx.Response) -> None:
self._prefix: Final = prefix
self._rest: Final = rest
self._upstream: Final = upstream

async def __aiter__(self) -> AsyncIterator[bytes]:
if self._prefix:
yield self._prefix
async for chunk in self._rest:
yield chunk

async def aclose(self) -> None:
await self._upstream.aclose()


async def _no_more_chunks() -> AsyncIterator[bytes]:
return
yield b""


async def _read_error_body_preview(
stream: AsyncIterator[bytes],
) -> tuple[bytes, AsyncIterator[bytes]]:
collected: Final[list[bytes]] = [] # mutable-ok: accumulated until the preview byte budget, then joined once
total = 0 # rebind-ok: running byte count against the preview budget
try:
async for chunk in stream:
collected.append(chunk)
total += len(chunk)
if total > PASSTHROUGH_UPSTREAM_ERROR_BODY_MAX_LOG_CHARS:
break
except httpx.HTTPError as err:
partial: Final = b"".join(collected)
verbose_proxy_logger.warning(
"pass_through_endpoint: upstream error body read failed after %d bytes: %s",
len(partial),
type(err).__name__,
)
return partial, _no_more_chunks()
return b"".join(collected), stream


def _headers_without_body_framing(headers: httpx.Headers) -> httpx.Headers:
return httpx.Headers(
[(name, value) for name, value in headers.raw if name.lower() not in (b"content-encoding", b"content-length")]
)


async def _error_body_preview_and_relay(response: httpx.Response) -> tuple[str, httpx.Response]:
if response.is_stream_consumed:
return response.text, response
body_iter: Final = response.aiter_bytes()
prefix, rest = await _read_error_body_preview(body_iter)
preview_text: Final = prefix.decode(response.encoding or "utf-8", errors="replace")
return preview_text, httpx.Response(
status_code=response.status_code,
headers=_headers_without_body_framing(response.headers),
stream=_PrefixReplayStream(prefix=prefix, rest=rest, upstream=response),
request=response.request,
extensions=response.extensions,
)


async def _log_passthrough_upstream_failure(
response: httpx.Response,
user_api_key_dict: UserAPIKeyAuth,
request_payload: dict,
) -> None:
"""Fire LiteLLM-side failure hooks (spend tracking, alerting callbacks) for
an upstream 4xx/5xx passthrough response.

Passthrough must return the upstream status/body/headers to the client
unchanged, so this never raises or transforms the response - it only
mirrors the monitoring side effect that ``post_call_failure_hook`` would
have received had the error originated inside LiteLLM.
"""
logging_obj: LiteLLMLoggingObj,
) -> httpx.Response:
if response.status_code < 400:
return
return response
from litellm.proxy.proxy_server import proxy_logging_obj

preview_text, relay_response = await _error_body_preview_and_relay(response)
Comment thread
cursor[bot] marked this conversation as resolved.
upstream_error_body: Final = (
REDACTED_BY_LITELLM
if should_redact_message_logging(logging_obj.model_call_details)
else _truncate_upstream_error_body(_sanitize_upstream_error_body(preview_text))
)
verbose_proxy_logger.warning(
"pass_through_endpoint: upstream %s %s returned %s: %s",
response.request.method,
response.url.copy_with(query=None, fragment=None),
response.status_code,
upstream_error_body,
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
)
try:
response.raise_for_status()
except httpx.HTTPStatusError:
Expand All @@ -880,7 +966,7 @@ async def _log_passthrough_upstream_failure(
# rate-limit errors already are.
synthetic_exception: Final = HTTPException(
status_code=response.status_code,
detail=f"Upstream passthrough request failed with status {response.status_code}",
detail=f"Upstream passthrough request failed with status {response.status_code}: {upstream_error_body}",
)
try:
await proxy_logging_obj.post_call_failure_hook(
Expand All @@ -894,6 +980,7 @@ async def _log_passthrough_upstream_failure(
"pass_through_endpoint: post_call_failure_hook raised for upstream error",
exc_info=True,
)
return relay_response


async def _relay_reporting_failures(
Expand Down Expand Up @@ -1323,7 +1410,7 @@ async def pass_through_request(
headers=response.headers,
)

await _log_passthrough_upstream_failure(
relay_response: Final = await _log_passthrough_upstream_failure(
response=response,
user_api_key_dict=user_api_key_dict,
request_payload=_build_passthrough_failure_request_payload(
Expand All @@ -1333,17 +1420,18 @@ async def pass_through_request(
custom_llm_provider=custom_llm_provider,
upstream_usage=upstream_usage,
),
logging_obj=logging_obj,
)

# Call response headers hook for streaming pass-through
_response_headers = HttpPassThroughEndpointHelpers.get_response_headers(
headers=response.headers,
headers=relay_response.headers,
litellm_call_id=litellm_call_id,
)
callback_headers = await proxy_logging_obj.post_call_response_headers_hook(
data=_parsed_body or {},
user_api_key_dict=user_api_key_dict,
response=response,
response=relay_response,
request_headers=dict(request.headers),
)
if callback_headers:
Expand All @@ -1354,15 +1442,15 @@ async def pass_through_request(
stream=_own_streamed_managed_ids(
stream=_relay_reporting_failures(
stream=PassThroughStreamingHandler.chunk_processor(
response=response,
response=relay_response,
request_body=_parsed_body,
litellm_logging_obj=logging_obj,
endpoint_type=endpoint_type,
start_time=start_time,
passthrough_success_handler_obj=pass_through_endpoint_logging,
url_route=str(url),
),
upstream_status=response.status_code,
upstream_status=relay_response.status_code,
user_api_key_dict=user_api_key_dict,
request_payload=_build_passthrough_failure_request_payload(
parsed_body=_parsed_body,
Expand All @@ -1376,10 +1464,10 @@ async def pass_through_request(
user_api_key_dict=user_api_key_dict,
),
ping_interval_seconds=litellm.sse_keepalive_ping_interval_seconds,
upstream_headers=response.headers,
upstream_headers=relay_response.headers,
),
headers=_response_headers,
status_code=response.status_code,
status_code=relay_response.status_code,
)

if state_raw_body is not None:
Expand Down Expand Up @@ -1414,7 +1502,7 @@ async def pass_through_request(
logging_obj.stream = True
logging_obj.model_call_details["stream"] = True

await _log_passthrough_upstream_failure(
detected_relay_response: Final = await _log_passthrough_upstream_failure(
response=response,
user_api_key_dict=user_api_key_dict,
request_payload=_build_passthrough_failure_request_payload(
Expand All @@ -1424,17 +1512,18 @@ async def pass_through_request(
custom_llm_provider=custom_llm_provider,
upstream_usage=upstream_usage,
),
logging_obj=logging_obj,
)

# Call response headers hook for detected streaming pass-through
_response_headers = HttpPassThroughEndpointHelpers.get_response_headers(
headers=response.headers,
headers=detected_relay_response.headers,
litellm_call_id=litellm_call_id,
)
callback_headers = await proxy_logging_obj.post_call_response_headers_hook(
data=_parsed_body or {},
user_api_key_dict=user_api_key_dict,
response=response,
response=detected_relay_response,
request_headers=dict(request.headers),
)
if callback_headers:
Expand All @@ -1445,15 +1534,15 @@ async def pass_through_request(
stream=_own_streamed_managed_ids(
stream=_relay_reporting_failures(
stream=PassThroughStreamingHandler.chunk_processor(
response=response,
response=detected_relay_response,
request_body=_parsed_body,
litellm_logging_obj=logging_obj,
endpoint_type=endpoint_type,
start_time=start_time,
passthrough_success_handler_obj=pass_through_endpoint_logging,
url_route=str(url),
),
upstream_status=response.status_code,
upstream_status=detected_relay_response.status_code,
user_api_key_dict=user_api_key_dict,
request_payload=_build_passthrough_failure_request_payload(
parsed_body=_parsed_body,
Expand All @@ -1467,10 +1556,10 @@ async def pass_through_request(
user_api_key_dict=user_api_key_dict,
),
ping_interval_seconds=litellm.sse_keepalive_ping_interval_seconds,
upstream_headers=response.headers,
upstream_headers=detected_relay_response.headers,
),
headers=_response_headers,
status_code=response.status_code,
status_code=detected_relay_response.status_code,
)

if not _should_buffer_passthrough_response(response):
Expand Down Expand Up @@ -1528,6 +1617,7 @@ async def pass_through_request(
response=response,
user_api_key_dict=user_api_key_dict,
request_payload=failure_request_payload,
logging_obj=logging_obj,
)

if response.status_code < 400 and response_body is not None and guardrails_to_run:
Expand Down Expand Up @@ -3437,7 +3527,7 @@ async def _filter_endpoints_by_team_allowed_routes(
for endpoint in pass_through_endpoints
if endpoint.path
in cast( # cast-ok: guarded above; team metadata stores this key as a list of route paths
"Sequence[str]", team_metadata.get("allowed_passthrough_routes")
Sequence[str], team_metadata.get("allowed_passthrough_routes")
)
]

Expand Down
6 changes: 4 additions & 2 deletions tests/integration/_support/wire.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ def drain(self) -> tuple[Request, ...]:


@contextmanager
def wire_server(respond: Callable[[Request], Reply], tls: ssl.SSLContext | None = None) -> Generator[Wire, None, None]:
def wire_server(
respond: Callable[[Request], Reply], tls: ssl.SSLContext | None = None, port: int = 0
) -> Generator[Wire, None, None]:
"""Owned TCP peer; requests traverse the real HTTP client and serialization."""
received: Final[SimpleQueue[Request]] = SimpleQueue()
errors: Final[SimpleQueue[Exception]] = SimpleQueue()
Expand Down Expand Up @@ -114,7 +116,7 @@ def server_bind(self) -> None:
if tls is not None:
self.socket = tls.wrap_socket(self.socket, server_side=True)

with OwnedHTTPServer(("127.0.0.1", 0), Handler) as server:
with OwnedHTTPServer(("127.0.0.1", port), Handler) as server:
thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05})
thread.start()
try:
Expand Down
Loading
Loading