mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
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:
parent
30cbf6bac7
commit
09558baca6
4 changed files with 82 additions and 40 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")])
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue