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.
This commit is contained in:
MHammett 2026-08-16 21:07:32 -05:00
parent 5365c3fdcc
commit da207f1f4c
2 changed files with 50 additions and 14 deletions

View file

@ -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

View file

@ -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"}})