From da207f1f4cd9c3bf29f742cd4a8fa6c0cafeb349 Mon Sep 17 00:00:00 2001 From: MHammett Date: Sun, 16 Aug 2026 21:07:32 -0500 Subject: [PATCH] fix(responses): raise instead of dropping a format with no readable type Greptile caught a silent path this PR had reintroduced for malformed input: `_convert_response_format_to_text_param` returned None when it could not read a `type`, and because both entry points pop `response_format` from kwargs before conversion, the request then went out completely unconstrained -- the exact silent drop this change exists to remove, just narrowed to bad input. Raise instead. A bare ValueError would be wrong here: exception_type() does not recognise it and surfaces it as APIConnectionError, reporting a malformed argument as a network fault. litellm.BadRequestError passes through intact with the message the caller needs, and matches how the prompt-template helpers report the same kind of bad content block. Also covers text_format, which previously raised a bare KeyError on the same input. --- litellm/responses/utils.py | 37 ++++++++++++------- .../test_response_format_conversion.py | 27 ++++++++++++++ 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 733f4f17f31..ea0f0b962ea 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -919,7 +919,7 @@ class ResponsesAPIRequestUtils: @staticmethod def _convert_response_format_to_text_param( response_format: type["BaseModel"] | dict | None, - ) -> Optional["ResponseText"]: + ) -> "ResponseText": """ Convert a Chat-Completions style `response_format` (or a Pydantic model) into the Responses API `text` parameter. @@ -933,19 +933,28 @@ class ResponsesAPIRequestUtils: The schema-less formats (`{"type": "json_object"}`, `{"type": "text"}`) carry no extra fields and map straight across. - Returns: - ResponseText object with the converted format, or None if conversion fails + Raises: + litellm.BadRequestError: if no `type` can be read from the supplied format. + Returning None here would drop the caller's format and send the request + unconstrained, which is the silent failure this conversion exists to + remove. A bare ValueError would surface through exception_type() as + APIConnectionError, reporting malformed input as a network fault. """ from litellm.llms.base_llm.base_utils import type_to_response_format_param # Normalizes a Pydantic model into a response_format dict; passes a dict through. - converted: Final = type_to_response_format_param(response_format) - if converted is None: - return None - + converted: Final = type_to_response_format_param(response_format) or {} format_type: Final = converted.get("type") if format_type is None: - return None + raise litellm.BadRequestError( + message=( + f"Could not read a `type` from the supplied response format: {response_format!r}. " + 'Expected {"type": "json_schema", "json_schema": {...}}, {"type": "json_object"}, ' + '{"type": "text"}, or a Pydantic model.' + ), + model=None, + llm_provider=None, + ) if format_type != "json_schema": return {"format": {"type": format_type}} @@ -980,16 +989,16 @@ class ResponsesAPIRequestUtils: neither text nor text_format was supplied) Returns: - ResponseText object with the converted format, or None if conversion fails + ResponseText object with the converted format, or None if none was supplied + + Raises: + litellm.BadRequestError: if a format was supplied but no `type` could be read from it """ if text is not None: return text for candidate in (text_format, response_format): - if candidate is None: - continue - converted = ResponsesAPIRequestUtils._convert_response_format_to_text_param(candidate) - if converted is not None: - return converted + if candidate is not None: + return ResponsesAPIRequestUtils._convert_response_format_to_text_param(candidate) return text @staticmethod diff --git a/tests/test_litellm/responses/test_response_format_conversion.py b/tests/test_litellm/responses/test_response_format_conversion.py index 896cb0ffb91..e576980d3c0 100644 --- a/tests/test_litellm/responses/test_response_format_conversion.py +++ b/tests/test_litellm/responses/test_response_format_conversion.py @@ -172,3 +172,30 @@ def test_text_format_takes_precedence_over_response_format(): def test_no_format_leaves_text_unset(): """Absent every spelling, nothing is invented.""" assert "text" not in _capture_request_params() + + +@pytest.mark.parametrize( + "bad_format", + [ + {}, + {"json_schema": {"name": "Flags", "schema": FLAGS_SCHEMA}}, # `type` omitted + ], +) +def test_response_format_without_type_raises(bad_format): + """ + A format with no readable `type` cannot be converted. It must raise rather than + return None: response_format is consumed before the request is built, so returning + None would send the request unconstrained -- reintroducing, for malformed input, + exactly the silent drop this conversion exists to remove. + + The helper raises ValueError; responses() surfaces it through exception_type() as + BadRequestError, which is what a caller actually sees. + """ + with pytest.raises(litellm.BadRequestError, match="Could not read a `type`"): + _capture_request_params(response_format=bad_format) + + +def test_text_format_without_type_raises(): + """Same guarantee for the text_format spelling, which previously raised KeyError.""" + with pytest.raises(litellm.BadRequestError, match="Could not read a `type`"): + _capture_request_params(text_format={"json_schema": {"name": "Flags"}})