fix: route OpenAI cyber policy errors to content policy fallbacks (#45828)

* fix: route OpenAI cyber policy errors to content policy fallbacks

* test: verify cyber policy errors preserve upstream responses
This commit is contained in:
tin-berri 2026-10-10 11:34:13 -07:00 • committed by GitHub
parent 30cbf6bac7
commit 09558baca6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 82 additions and 40 deletions

View file

@ -7,6 +7,7 @@ from types import MappingProxyType
from typing import Final, Protocol, cast
import httpx
from pydantic import TypeAdapter, ValidationError
import litellm
from litellm._logging import _ENABLE_SECRET_REDACTION, redact_string, verbose_logger
@ -256,6 +257,23 @@ class _ProviderHTTPException(Protocol):
llm_provider: str
_ERROR_BODY_ADAPTER: Final = TypeAdapter(dict[str, object])
def _openai_cyber_policy_body(original_exception: _ProviderHTTPException) -> Mapping[str, object] | None:
raw_body: Final[object] = getattr(original_exception, "body", None)
try:
body: Final = (
_ERROR_BODY_ADAPTER.validate_python(raw_body)
if raw_body is not None
else _ERROR_BODY_ADAPTER.validate_json(getattr(original_exception, "message", ""))
)
error: Final = _ERROR_BODY_ADAPTER.validate_python(body.get("error", body))
except ValidationError:
return None
return error if error.get("code") == "cyber_policy" else None
def _litellm_proxy_response(
original_exception: _ProviderHTTPException, custom_llm_provider: str
) -> httpx.Response | None:
@ -284,6 +302,15 @@ def _map_openai_exception(
extra_information: str,
) -> None:
response: Final = _litellm_proxy_response(original_exception, custom_llm_provider)
if (cyber_policy_body := _openai_cyber_policy_body(original_exception)) is not None:
raise ContentPolicyViolationError(
message=f"ContentPolicyViolationError: {exception_provider} - {original_exception}",
llm_provider=custom_llm_provider,
model=model,
response=response,
litellm_debug_info=extra_information,
body=dict(cyber_policy_body),
)
# custom_llm_provider is openai, make it OpenAI
message = get_error_message(error_obj=original_exception)
if message is None:

View file

@ -216,6 +216,7 @@ _ERROR_CODE_HTTP_STATUS: Final[Mapping[str, int]] = MappingProxyType(
"invalid_request_error": 400,
"context_length_exceeded": 400,
"content_policy_violation": 400,
"cyber_policy": 400,
"model_not_found": 400,
}
)

View file

@ -1,12 +1,17 @@
import json
import traceback
from collections.abc import Awaitable, Callable
from typing import Final, Literal
from typing import Any, Final, Literal, Optional, Union
from unittest.mock import MagicMock, patch
import httpx
import openai
import pytest
from fastapi import HTTPException
from openai import AsyncOpenAI, OpenAI
import litellm
from litellm import completion
from litellm.exceptions import GuardrailRaisedException
from litellm.litellm_core_utils.exception_mapping_utils import (
ExceptionCheckers,
@ -19,13 +24,38 @@ from litellm.llms.bedrock.common_utils import BedrockError
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.openai.common_utils import OpenAIError
from litellm.types.utils import LlmProviders
import traceback
from typing import Any
from unittest.mock import MagicMock, patch
from openai import AsyncOpenAI
from litellm import completion
from openai import OpenAI
from typing import Optional, Union
@pytest.mark.parametrize("shape", ["sdk", "http"])
@pytest.mark.parametrize("code", ["cyber_policy", "invalid_prompt"])
def test_openai_cyber_policy_uses_the_structured_error_code(shape: str, code: str) -> None:
body: Final = {"code": code, "message": "Request mentions cyber_policy", "type": "invalid_request_error"}
request: Final = httpx.Request("POST", "https://provider.example/v1/responses")
response: Final = httpx.Response(400, request=request, headers={"x-request-id": "cyber-policy-request"})
original: Final = OpenAIError(
status_code=400,
message=json.dumps({"error": body}),
request=request,
response=response,
body=body if shape == "sdk" else None,
)
with pytest.raises(litellm.BadRequestError) as caught:
exception_type(
model="gpt-6-astra",
original_exception=original,
custom_llm_provider="openai",
completion_kwargs={},
extra_kwargs={},
)
assert isinstance(caught.value, litellm.ContentPolicyViolationError) == (code == "cyber_policy")
if code == "cyber_policy":
assert caught.value.code == code
assert caught.value.body == body
assert caught.value.status_code == 400
assert caught.value.response is response
assert caught.value.request is request
assert caught.value.response.headers["x-request-id"] == "cyber-policy-request"
# Test cases for is_error_str_context_window_exceeded
# Tuple format: (error_message, expected_result)
@ -1629,9 +1659,7 @@ def test_guardrail_block_raised_inside_an_llm_call_is_returned_unmapped(block: E
"failure", [ImportError("Run 'pip install boto3'."), ModuleNotFoundError(name="unrelated_dependency")]
)
def test_bedrock_import_errors_preserve_the_original_exception(provider, failure):
assert exception_type(
model="test-model", original_exception=failure, custom_llm_provider=provider
) is failure
assert exception_type(model="test-model", original_exception=failure, custom_llm_provider=provider) is failure
def test_guardrail_provider_failure_status_is_still_mapped():
@ -2000,11 +2028,7 @@ def test_exception_mapping(provider):
except Exception as e:
traceback.print_exc()
response = "{}".format(str(e))
pytest.fail(
"Did not raise expected exception. Expected={}, Return={},".format(
expected_exception, response
)
)
pytest.fail("Did not raise expected exception. Expected={}, Return={},".format(expected_exception, response))
pass
@ -2088,9 +2112,7 @@ def test_fireworks_ai_exception_mapping():
]
for error_str in rate_limit_strings:
assert ExceptionCheckers.is_error_str_rate_limit(
error_str
), f"Should detect rate limit in: {error_str}"
assert ExceptionCheckers.is_error_str_rate_limit(error_str), f"Should detect rate limit in: {error_str}"
# Test cases that should return False (not rate limit)
non_rate_limit_strings = [
@ -2104,9 +2126,7 @@ def test_fireworks_ai_exception_mapping():
]
for error_str in non_rate_limit_strings:
assert not ExceptionCheckers.is_error_str_rate_limit(
error_str
), f"Should NOT detect rate limit in: {error_str}"
assert not ExceptionCheckers.is_error_str_rate_limit(error_str), f"Should NOT detect rate limit in: {error_str}"
# Test edge cases
assert not ExceptionCheckers.is_error_str_rate_limit(None) # type: ignore
@ -2203,9 +2223,7 @@ async def test_exception_with_headers(
)
@pytest.mark.usefixtures("fake_provider_credentials")
@pytest.mark.asyncio
async def test_exception_with_headers_httpx(
sync_mode, provider, model, call_type, streaming
):
async def test_exception_with_headers_httpx(sync_mode, provider, model, call_type, streaming):
"""
User feedback: litellm says "No deployments available for selected model, Try again in 60 seconds"
but Azure says to retry in at most 9s
@ -2268,9 +2286,7 @@ async def test_exception_with_headers_httpx(
):
new_retry_after_mock_client = MagicMock(return_value=-1)
litellm.utils._get_retry_after_from_exception_header = (
new_retry_after_mock_client
)
litellm.utils._get_retry_after_from_exception_header = new_retry_after_mock_client
async def call_and_drain():
if sync_mode:
@ -2288,9 +2304,7 @@ async def test_exception_with_headers_httpx(
with pytest.raises(litellm.RateLimitError) as exc_info:
await call_and_drain()
assert (
exc_info.value.litellm_response_headers is not None
), "litellm_response_headers is None"
assert exc_info.value.litellm_response_headers is not None, "litellm_response_headers is None"
print("e.litellm_response_headers", exc_info.value.litellm_response_headers)
assert int(exc_info.value.litellm_response_headers["retry-after"]) == cooldown_time
@ -2390,9 +2404,7 @@ def _pre_call_utils_httpx(
@pytest.mark.parametrize("dependency", ["boto3", "botocore"])
def test_missing_aws_dependency_is_not_mapped_to_provider_failure(provider, dependency):
failure = ModuleNotFoundError(f"No module named '{dependency}'", name=dependency)
assert exception_type(
model="test-model", original_exception=failure, custom_llm_provider=provider
) is failure
assert exception_type(model="test-model", original_exception=failure, custom_llm_provider=provider) is failure
@pytest.mark.parametrize("failure", [ImportError("broken import"), ModuleNotFoundError(name="unrelated_dependency")])

View file

@ -25,7 +25,6 @@ from unittest.mock import Mock, patch
import pytest
import litellm
from litellm.exceptions import MidStreamFallbackError
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
@ -163,6 +162,7 @@ def test_maybe_raise_for_response_failed_event_wraps_content_policy_violation():
"error_type,error_code,expected_exception",
[
("invalid_request_error", "content_policy_violation", litellm.ContentPolicyViolationError),
("invalid_request_error", "cyber_policy", litellm.ContentPolicyViolationError),
("tokens", "rate_limit_exceeded", litellm.RateLimitError),
("invalid_request_error", "insufficient_quota", litellm.RateLimitError),
("server_error", "internal_error", litellm.InternalServerError),
@ -354,20 +354,22 @@ def test_response_failed_numeric_code_maps_to_its_http_status(code: int | str):
assert isinstance(exc_info.value.original_exception, litellm.RateLimitError)
def test_response_failed_unknown_code_keeps_upstream_code_and_message_on_mapped_exception():
@pytest.mark.parametrize("code", ["cyber_policy", "unrecognized_failure"])
def test_response_failed_keeps_upstream_code_and_uses_content_policy_fallback_for_cyber_policy(code: str):
iterator = _make_iterator()
upstream_message = "This content was flagged for possible cybersecurity risk."
mock_response_obj = Mock()
mock_response_obj.error = {"code": "cyber_policy", "message": upstream_message}
mock_response_obj.error = {"code": code, "message": upstream_message}
chunk = Mock()
chunk.type = "response.failed"
chunk.response = mock_response_obj
with pytest.raises(MidStreamFallbackError) as exc_info:
iterator._maybe_raise_for_error_event(chunk)
mapped = exc_info.value.original_exception
assert isinstance(mapped, litellm.InternalServerError)
assert mapped.code == "cyber_policy"
assert mapped.body == {"message": upstream_message, "type": None, "code": "cyber_policy"}
expected = litellm.ContentPolicyViolationError if code == "cyber_policy" else litellm.InternalServerError
assert isinstance(mapped, expected)
assert mapped.code == code
assert mapped.body == {"message": upstream_message, "type": None, "code": code}
def test_maybe_raise_for_error_event_null_error_obj():