fix(completion)!: answer 400 for a chat message without a role (#51124)

* fix(completion): answer 400 for a chat message without a role

A message with no role was filled with role assistant and sent upstream, so the caller's text reached the
model as its own earlier reply and the request returned 200. Now a missing, null, or empty role answers 400
with OpenAI's wording, naming the message index. A role-less turn that carries tool_calls is still sent as
assistant, since only an assistant writes tool calls.

* fix(completion): treat a role-less message as the caller's error

The 400 now raises MalformedRequestFieldError with OpenAI's param and code and the provider name, so it no longer counts toward the deployment's failure rate, cools it down, or fires an llm_exceptions alert

* fix(completion): keep the assistant fill for a role-less function_call turn

* refactor(completion): type the role-less call turn check and the request kwargs

Read role, tool_calls and function_call off the cleaned message copy, and validate completion's kwargs into a typed mapping before naming the provider on the 400

* fix(completion): keep the alert and failure-count exclusions to the role error

Raise MissingMessageRoleError, a MalformedRequestFieldError subclass, so a malformed tools 400 alerts and counts as on main, and fill assistant whenever a role-less turn has a tool_calls or function_call key

* fix(completion): keep assistant-only role-less turns and skip SDK and PagerDuty alerts

A role-less turn carrying any assistant-only key, null values included, is still
filled as assistant, so a dumped assistant message or a reasoning turn keeps working.
The Router's alerting_config alert and the PagerDuty failure count skip the role error,
like the proxy alert already does.

* fix(utils): drop the string code from the missing-role 400 so the Errors tab counts it as 400

The spend log stored code missing_required_parameter as the error code, which
the Usage page Errors tab cannot parse as a status, so every role-less request
landed under no status. The malformed tools 400 leaves code out the same way.

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
zoveki 2026-11-11 12:59:19 -08:00 • committed by GitHub
parent f52f26d9c6
commit a1f3dcd01b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 264 additions and 19 deletions

View file

@ -17,6 +17,7 @@ from typing import List, Optional, Union
from litellm._logging import verbose_logger
from litellm.caching import DualCache
from litellm.exceptions import MissingMessageRoleError
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
@ -86,6 +87,8 @@ class PagerDutyAlerting(SlackAlerting):
Record a failure event. Only send an alert to PagerDuty if the
configured *failure* threshold is exceeded in the specified window.
"""
if isinstance(kwargs.get("exception"), MissingMessageRoleError):
return
now = datetime.now(timezone.utc)
standard_logging_payload: Optional[StandardLoggingPayload] = kwargs.get(
"standard_logging_object"

View file

@ -1457,6 +1457,7 @@ from .exceptions import (
BadRequestError,
ImageFetchError,
MalformedRequestFieldError as MalformedRequestFieldError,
MissingMessageRoleError as MissingMessageRoleError,
VectorStoreSearchError,
NotFoundError,
PermissionDeniedError,

View file

@ -369,6 +369,10 @@ class MalformedRequestFieldError(BadRequestError):
pass
class MissingMessageRoleError(MalformedRequestFieldError):
pass
class UnsupportedEndpointError(BadRequestError):
pass

View file

@ -5660,7 +5660,9 @@ def completion(
if model is None:
raise ValueError("model param not passed in.")
# validate messages
messages = validate_and_fix_openai_messages(messages=messages)
messages = validate_and_fix_openai_messages(
messages=messages, model=model, request_kwargs=_REQUEST_KWARGS_ADAPTER.validate_python(kwargs)
)
tools = validate_and_fix_openai_tools(tools=tools)
# validate tool_choice
requested_tool_choice: Final = cast( # cast-ok: widening an untyped dict to object is always sound

View file

@ -151,6 +151,7 @@ from litellm.caching.dual_cache import LimitedSizeOrderedDict
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.exceptions import (
GuardrailRaisedException,
MissingMessageRoleError,
RejectedRequestError,
SensitiveDataRouteException,
)
@ -671,6 +672,8 @@ class _BudgetAlertEmailer(Protocol):
def _is_client_error_exception(exc: Exception) -> bool:
if isinstance(exc, MissingMessageRoleError):
return True
if isinstance(exc, HTTPException):
return exc.status_code < 500
if isinstance(exc, ProxyException):

View file

@ -24,7 +24,7 @@ from litellm.constants import (
INTERNAL_CALL_ORIGIN_METADATA_KEY,
SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD,
)
from litellm.exceptions import MidStreamFallbackError
from litellm.exceptions import MidStreamFallbackError, MissingMessageRoleError
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.litellm_core_utils.litellm_logging import mask_api_base_credentials
from litellm.router_deprecated import renamed_router_param
@ -83,8 +83,10 @@ def mark_deployment_selection_failure(exception: BaseException) -> None:
def is_failure_outside_selected_deployment(exception: BaseException | None) -> bool:
return is_advisor_orchestration_failure(exception) or bool(
getattr(exception, _DEPLOYMENT_SELECTION_FAILURE_ATTR, False)
return (
is_advisor_orchestration_failure(exception)
or isinstance(exception, MissingMessageRoleError)
or bool(getattr(exception, _DEPLOYMENT_SELECTION_FAILURE_ATTR, False))
)

View file

@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, Final
from litellm._logging import redact_secrets, verbose_router_logger
from litellm.constants import MAX_EXCEPTION_MESSAGE_LENGTH
from litellm.exceptions import MissingMessageRoleError
from litellm.router_deprecated import renamed_router_param
from litellm.router_utils.cooldown_handlers import (
async_get_cooldown_deployments_with_debug_info,
@ -46,6 +47,9 @@ async def send_llm_exception_alert(
if slack_alerting_logger is None:
return
if isinstance(original_exception, MissingMessageRoleError):
return
if "proxy_server_request" in request_kwargs:
# Do not send any alert if it's a request from litellm proxy server request
# the proxy is already instrumented to send LLM API call failures

View file

@ -534,6 +534,7 @@ from .exceptions import (
ContentPolicyViolationError,
ContextWindowExceededError,
MalformedRequestFieldError,
MissingMessageRoleError,
ModelNotMappedError,
NotFoundError,
OpenAIError,
@ -9496,16 +9497,22 @@ def convert_list_message_to_dict(
return [dict(cleanup_none_field_in_message(message=_as_message_value(message))) for message in messages]
def validate_and_fix_openai_messages(messages: list):
_ASSISTANT_ONLY_MESSAGE_KEYS: Final = frozenset(
{"tool_calls", "function_call", "reasoning_content", "reasoning_items", "thinking_blocks", "refusal", "audio"}
)
def validate_and_fix_openai_messages(
messages: list, model: str = "", request_kwargs: Mapping[str, object] | None = None
):
"""
Ensures all messages are valid OpenAI chat completion messages.
Handles missing role for assistant messages.
Only an assistant turn carries the keys in _ASSISTANT_ONLY_MESSAGE_KEYS, so one sent without a role but with
any of them, even set to null, gets "assistant" (issue #8661).
"""
new_messages: Final = []
for message in messages:
if not message.get("role"):
message["role"] = "assistant"
if message.get("tool_calls"):
message["tool_calls"] = jsonify_tools(tools=message["tool_calls"])
@ -9516,8 +9523,10 @@ def validate_and_fix_openai_messages(messages: list):
),
)
cleaned_message = cleanup_none_field_in_message(message=convert_msg_to_dict)
if not cleaned_message.get("role") and not _ASSISTANT_ONLY_MESSAGE_KEYS.isdisjoint(convert_msg_to_dict):
cleaned_message["role"] = "assistant"
new_messages.append(cleaned_message)
return validate_chat_completion_user_messages(messages=new_messages)
return validate_chat_completion_user_messages(messages=new_messages, model=model, request_kwargs=request_kwargs)
def validate_and_fix_openai_tools(tools: list | None) -> list[dict] | None:
@ -9570,21 +9579,36 @@ def cleanup_none_field_in_message(message: AllMessageValues):
return {k: v for k, v in new_message.items() if v is not None}
def validate_chat_completion_user_messages(messages: list[AllMessageValues]):
def validate_chat_completion_user_messages(
messages: list[AllMessageValues], model: str = "", request_kwargs: Mapping[str, object] | None = None
):
"""
Ensures all user messages are valid OpenAI chat completion messages.
Ensures every message has a role and all user messages are valid OpenAI chat completion messages.
Args:
messages: List of message dictionaries
model: Model named on the error raised for a message without a role
request_kwargs: The request's provider settings, read to name the provider on that error
message_content_type: Type to validate content against
Returns:
List[dict]: The validated messages
Raises:
MissingMessageRoleError: If a message has no role
ValueError: If any message is invalid
"""
for idx, m in enumerate(messages):
if "role" not in m or not m["role"]:
raise MissingMessageRoleError(
message=f"Missing required parameter: 'messages[{idx}].role'.",
model=model,
llm_provider=_deployment_provider({**(request_kwargs or {}), "model": model}),
body={
"type": "invalid_request_error",
"param": f"messages[{idx}].role",
},
)
try:
if m["role"] == "user":
user_content = m.get("content")
@ -9597,10 +9621,6 @@ def validate_chat_completion_user_messages(messages: list[AllMessageValues]):
if item.get("type") not in ValidUserMessageContentTypes:
raise Exception(f"invalid content type={item.get('type')}")
except Exception as e:
if isinstance(e, KeyError):
raise Exception(
f"Invalid message at index {idx}. Please ensure all messages are valid OpenAI chat completion messages."
)
if "invalid content type" in str(e):
raise Exception(
f"Invalid user message at index {idx}. Please ensure all user messages are valid OpenAI chat completion messages."

View file

@ -74,6 +74,51 @@ async def test_failure_alerts_follow_the_configured_threshold(
assert recent_errors[0]["error_llm_provider"] == "openai"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("exception", "request_count"),
[
(
litellm.MissingMessageRoleError(
message="Missing required parameter: 'messages[0].role'.", model="gpt-5.6", llm_provider="openai"
),
0,
),
(
litellm.MalformedRequestFieldError(
message="tools: Input should be a valid list", model="gpt-5.6", llm_provider="openai"
),
1,
),
],
)
async def test_only_a_role_less_request_stays_out_of_the_failure_count(
monkeypatch: pytest.MonkeyPatch,
respx_mock: respx.MockRouter,
exception: Exception,
request_count: int,
) -> None:
route: Final = respx_mock.post(PAGERDUTY_URL).mock(return_value=httpx.Response(202))
logger: Final = _pagerduty_logger(monkeypatch)
payload: Final = cast(
StandardLoggingPayload,
{
"error_information": {
"error_class": type(exception).__name__,
"error_code": "400",
"llm_provider": "openai",
},
"metadata": {},
},
)
await logger.async_log_failure_event(
{"standard_logging_object": payload, "exception": exception}, None, None, None
)
assert route.call_count == request_count
@pytest.mark.asyncio
async def test_hanging_requests_alert_at_the_default_threshold(
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter

View file

@ -2007,6 +2007,19 @@ class TestPostCallFailureHookLLMExceptionAlerting:
async def test_http_exception_does_not_alert(self):
assert (await self._alerted(HTTPException(status_code=400, detail="blocked"))).called is False
@pytest.mark.asyncio
async def test_role_less_message_does_not_alert(self):
with pytest.raises(litellm.MalformedRequestFieldError) as exc_info:
litellm.completion(model="openai/gpt-5.6", messages=[{"content": "hi"}], mock_response="ok")
assert (await self._alerted(exc_info.value)).called is False
@pytest.mark.asyncio
async def test_malformed_tools_field_still_alerts(self):
exc = litellm.MalformedRequestFieldError(
message="tools: Input should be a valid list", model="openai/gpt-5.6", llm_provider="openai"
)
assert (await self._alerted(exc)).called is True
@pytest.mark.asyncio
async def test_genuine_llm_api_error_still_alerts(self):
assert (await self._alerted(Exception("upstream 503"))).called is True

View file

@ -3,6 +3,7 @@ import copy
import datetime
import importlib
import itertools
import json
import random
import time
import uuid
@ -37,6 +38,7 @@ from litellm.router_utils.cooldown_handlers import (
async_get_cooldown_deployments,
cast_exception_status_to_int,
get_cooldown_deployments,
is_failure_outside_selected_deployment,
is_response_lookup_not_found,
mark_advisor_orchestration_failure,
set_cooldown_deployments,
@ -1939,6 +1941,48 @@ async def test_should_cooldown_deployment(testing_litellm_router):
)
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
async def test_role_less_requests_do_not_count_toward_the_deployments_failure_rate():
"""A caller's role-less message is not the deployment failing, so it must not push a healthy one into cooldown."""
litellm_params: Final = {"model": "house-model", "custom_llm_provider": "openai", "mock_response": "ok"}
router: Final = Router(
model_list=[
{"model_name": "house", "litellm_params": litellm_params, "model_info": {"id": "d1"}},
{"model_name": "house", "litellm_params": litellm_params, "model_info": {"id": "d2"}},
]
)
for _ in range(6):
with pytest.raises(litellm.MalformedRequestFieldError):
await router.acompletion(model="d1", messages=json.loads('[{"content": "hi"}]'))
await asyncio.sleep(1)
assert _should_cooldown_deployment(router, "d1", 500, Exception("Test")) is False
@pytest.mark.parametrize(
("exception", "outside_deployment"),
[
(
litellm.MissingMessageRoleError(
message="Missing required parameter: 'messages[0].role'.", model="m", llm_provider="openai"
),
True,
),
(
litellm.MalformedRequestFieldError(
message="tools: Input should be a valid list", model="m", llm_provider="openai"
),
False,
),
],
)
def test_only_a_role_less_request_is_a_failure_outside_the_deployment(
exception: Exception, outside_deployment: bool
) -> None:
assert is_failure_outside_selected_deployment(exception) is outside_deployment
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.asyncio
async def test_should_cooldown_deployment_allowed_fails_set_on_router():

View file

@ -2503,6 +2503,25 @@ def test_is_bad_request_without_fallback_only_stops_on_a_real_400_when_the_flag_
assert is_bad_request_without_fallback(exception, fallback_on_unsupported_params=True) is False
@pytest.mark.asyncio
async def test_a_role_less_request_is_not_retried_on_the_fallback_group() -> None:
router: Final = Router(
model_list=[
{
"model_name": "house",
"litellm_params": {"model": "house-model", "custom_llm_provider": "openai", "mock_response": "ok"},
},
{"model_name": "backup", "litellm_params": {"model": "openai/gpt-5.6", "mock_response": "ok"}},
],
fallbacks=[{"house": ["backup"]}],
fallback_on_unsupported_params=False,
)
with pytest.raises(litellm.MalformedRequestFieldError, match="Missing required parameter") as exc_info:
await router.acompletion(model="house", messages=json.loads('[{"content": "hi"}]'))
assert "Fallback to backup" not in str(exc_info.value)
@pytest.mark.asyncio
async def test_run_async_fallback_skips_a_target_the_rate_limit_check_rejects() -> None:
checked: Final = [] # mutable-ok: records the targets the check saw

View file

@ -1,6 +1,6 @@
import asyncio
import importlib
from typing import List
from typing import Final, List
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -40,6 +40,37 @@ async def test_send_llm_exception_alert_success():
slack_alerting_logger.send_alert.assert_called_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("exception", "alerts_sent"),
[
(
litellm.MissingMessageRoleError(
message="Missing required parameter: 'messages[0].role'.", model="gpt-5.6", llm_provider="openai"
),
0,
),
(
litellm.MalformedRequestFieldError(
message="tools: Input should be a valid list", model="gpt-5.6", llm_provider="openai"
),
1,
),
],
)
async def test_send_llm_exception_alert_skips_only_a_role_less_request(exception: Exception, alerts_sent: int):
slack_alerting_logger: Final = AsyncMock()
ctx: Final = MagicMock()
ctx.observers.return_value = RoutingObservers(slack_alerting_logger=slack_alerting_logger)
request_kwargs: Final = {"model": "gpt-5.6", "messages": [{"content": "Hello"}]}
from litellm.router_utils.handle_error import send_llm_exception_alert
await send_llm_exception_alert(ctx, request_kwargs, "Traceback", exception)
assert slack_alerting_logger.send_alert.call_count == alerts_sent
@pytest.mark.asyncio
async def test_send_llm_exception_alert_no_logger():
"""

View file

@ -57,6 +57,8 @@ from litellm.litellm_core_utils.thread_pool_executor import executor as logging_
from litellm.llms.base_llm.base_model_iterator import MockResponseIterator
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, headers
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
from litellm.proxy.db.request_error_tracking import status_code_from_metadata
from litellm.proxy.utils import is_valid_api_key
from litellm.responses.main import aresponses, responses
from litellm.types.caching import CachingSupportedCallTypes
@ -119,6 +121,7 @@ from litellm.utils import (
trim_messages,
validate_environment,
validate_chat_completion_tool_choice,
validate_and_fix_openai_messages,
)
from litellm.litellm_core_utils.model_cost_table import publish
@ -8939,6 +8942,57 @@ def test_validate_tool_choice_without_model_is_still_a_400():
assert exc_info.value.model == ""
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.parametrize(
("messages", "index"),
[
([{"content": "Say hi in two words."}], 0),
([{"role": "user", "content": "hi"}, {"role": None, "content": "hello"}], 1),
([{"role": "user", "content": "hi"}, {"role": "", "content": "hello"}], 1),
],
)
def test_completion_message_without_role_is_a_400(messages: list[dict[str, str | None]], index: int):
"""A message with no role is the caller's mistake, rejected before any provider sees it, never sent as assistant."""
with pytest.raises(
litellm.MissingMessageRoleError, match=re.escape(f"Missing required parameter: 'messages[{index}].role'.")
) as exc_info:
litellm.completion(model=MODEL, messages=messages, mock_response="Hello there!")
assert exc_info.value.status_code == 400
assert exc_info.value.model == MODEL
assert exc_info.value.llm_provider == "anthropic"
assert exc_info.value.param == f"messages[{index}].role"
error_information: Final = StandardLoggingPayloadSetup.get_error_information(exc_info.value)
assert status_code_from_metadata({"error_information": error_information}) == 400
@pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown")
@pytest.mark.parametrize(
"call_turn",
[
{
"content": "",
"tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{}"}}
],
},
{"content": None, "function_call": {"name": "get_weather", "arguments": "{}"}},
{"content": "ok", "tool_calls": []},
{"content": "ok", "tool_calls": None},
{"content": "ok", "function_call": None},
{"content": "Sunny.", "reasoning_content": "The user wants the weather."},
{"content": "Sunny.", "reasoning_items": []},
{"content": "Sunny.", "thinking_blocks": [{"type": "thinking", "thinking": "Weather.", "signature": "sig"}]},
{"content": None, "refusal": "I can't help with that."},
{"content": None, "audio": {"id": "audio_1"}},
],
)
def test_completion_role_less_assistant_turn_is_sent_as_assistant(call_turn: dict[str, object]):
"""Only an assistant turn carries these keys, so one replayed without a role still goes upstream as assistant."""
messages: Final = [{"role": "user", "content": "Weather in Paris?"}, call_turn]
assert validate_and_fix_openai_messages(messages)[1]["role"] == "assistant"
@pytest.fixture()
def _vcr_outcome_gate_local_testing(request, vcr):
install_live_call_probe(request, vcr)
@ -10731,12 +10785,12 @@ def test_dict_to_response_format_helper():
def test_validate_user_messages_invalid_content_type():
from litellm.utils import validate_chat_completion_user_messages
messages = [{"content": [{"type": "invalid_type", "text": "Hello"}]}]
messages = [{"role": "user", "content": [{"type": "invalid_type", "text": "Hello"}]}]
with pytest.raises(Exception, match="Please ensure all messages are valid OpenAI chat completion") as e:
with pytest.raises(Exception, match="Please ensure all user messages are valid OpenAI chat completion") as e:
validate_chat_completion_user_messages(messages)
assert "Invalid message" in str(e)
assert "Invalid user message at index 0" in str(e)
print(e)