Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 23 additions & 2 deletions litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,27 @@ def _restore_protected_messages(
]


def _build_compress_failure_detail(status_code: int, body: str) -> dict[str, object]:
"""Build error details for failed /v1/compress responses.

Adds troubleshooting hints for known deployment-related errors while
preserving the upstream status code and response body.
"""
if status_code == 404:
return {
"status_code": status_code,
"body": body,
"hint": (
"The Headroom compression endpoint returned HTTP 404. "
"Verify that the configured Headroom endpoint is correct and that "
"the compression endpoint is available. If you are using a "
"self-hosted deployment, some deployments require enabling remote "
"compression (for example, HEADROOM_COMPRESS_ALLOW_REMOTE=1)."
),
}
return {"status_code": status_code, "body": body}


def extract_hashes_from_messages(messages: list[dict[str, object]]) -> list[str]:
hashes: Final[list[str]] = []
for msg in messages:
Expand Down Expand Up @@ -417,7 +438,7 @@ async def _call_compress(
self._handle_compress_failure(
messages,
"Headroom compression service returned an error",
{"status_code": e.response.status_code, "body": e.response.text},
_build_compress_failure_detail(e.response.status_code, e.response.text),
),
False,
{},
Expand Down Expand Up @@ -449,7 +470,7 @@ async def _call_compress(
self._handle_compress_failure(
messages,
"Headroom compression service returned an error",
{"status_code": response.status_code, "body": response.text},
_build_compress_failure_detail(response.status_code, response.text),
),
False,
{},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,64 @@ async def test_apply_guardrail_http_status_error_raises():
assert exc_info.value.status_code == 502


@pytest.mark.asyncio
async def test_apply_guardrail_404_error_includes_troubleshooting_hint():
"""404 responses include a troubleshooting hint for self-hosted Headroom deployments."""
guardrail = _make_guardrail()

inputs = GenericGuardrailAPIInputs(
texts=["hello"],
structured_messages=ORIGINAL_MESSAGES,
)

with patch.object(
guardrail.async_handler,
"post",
new_callable=AsyncMock,
side_effect=_make_http_status_error(404, "Not Found"),
):
with pytest.raises(HTTPException) as exc_info:
await guardrail.apply_guardrail(
inputs=inputs,
request_data={},
input_type="request",
)

assert exc_info.value.status_code == 502
assert exc_info.value.detail["status_code"] == 404
assert exc_info.value.detail["body"] == "Not Found"
assert "hint" in exc_info.value.detail
assert "HEADROOM_COMPRESS_ALLOW_REMOTE=1" in exc_info.value.detail["hint"]


@pytest.mark.asyncio
async def test_apply_guardrail_non_404_error_omits_troubleshooting_hint():
guardrail = _make_guardrail()

inputs = GenericGuardrailAPIInputs(
texts=["hello"],
structured_messages=ORIGINAL_MESSAGES,
)

with patch.object(
guardrail.async_handler,
"post",
new_callable=AsyncMock,
side_effect=_make_http_status_error(500, "headroom internal error"),
):
with pytest.raises(HTTPException) as exc_info:
await guardrail.apply_guardrail(
inputs=inputs,
request_data={},
input_type="request",
)

assert exc_info.value.status_code == 502
assert exc_info.value.detail["status_code"] == 500
assert exc_info.value.detail["body"] == "headroom internal error"
assert "hint" not in exc_info.value.detail


@pytest.mark.asyncio
async def test_apply_guardrail_http_status_error_fail_open_forwards_uncompressed():
guardrail = _make_guardrail(unreachable_fallback="fail_open")
Expand Down
Loading