From 46b94666d124a0137713c5724cdba6b43da7e817 Mon Sep 17 00:00:00 2001 From: MHammett Date: Sun, 16 Aug 2026 20:10:23 -0500 Subject: [PATCH 1/4] fix(responses): map response_format onto text.format instead of dropping it `litellm.responses(..., response_format=...)` accepted the parameter and silently ignored it. `response_format` is not declared on `ResponsesAPIOptionalRequestParams`, so `get_requested_response_api_optional_param` filtered it out before `_check_valid_arg` ran -- no mapping, no `UnsupportedParamsError`, no warning. The call succeeded and the caller believed a schema was being enforced when nothing was. That is worse than an unsupported parameter, because the capability does exist: the responses API spells it `text.format`, and litellm already converts to that shape for its own `text_format=` parameter. Probing with the familiar `completion()` spelling therefore returns the wrong answer about the library -- it reads as "structured output is unsupported here" when it is fully supported under a different name. Route `response_format` through the same conversion, with precedence text > text_format > response_format so the existing spellings are unchanged. The conversion is factored into `_convert_response_format_to_text_param`, which also handles the schema-less formats (`{"type": "json_object"}`, `{"type": "text"}`) and a `json_schema` block with no `strict` key -- both previously raised `KeyError`, reachable today via `text_format`. Tests assert on the params bound for the provider rather than on a successful return: the call returned successfully in the broken case too, so a test that only checks for a 200 passes against the unfixed version. Five of the eight fail without this change. Fixes #37125 Co-Authored-By: Claude Opus 5 --- litellm/responses/main.py | 16 +- litellm/responses/utils.py | 87 ++++++--- .../test_response_format_conversion.py | 174 ++++++++++++++++++ 3 files changed, 250 insertions(+), 27 deletions(-) create mode 100644 tests/test_litellm/responses/test_response_format_conversion.py diff --git a/litellm/responses/main.py b/litellm/responses/main.py index e0af363b1a5..c90ba4abc30 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -433,8 +433,12 @@ async def aresponses( loop: Final = asyncio.get_event_loop() kwargs["aresponses"] = True - # Convert text_format to text parameter if provided - text = ResponsesAPIRequestUtils.convert_text_format_to_text_param(text_format=text_format, text=text) + # Convert text_format/response_format to text parameter if provided + text = ResponsesAPIRequestUtils.convert_text_format_to_text_param( + text_format=text_format, + text=text, + response_format=kwargs.pop("response_format", None), + ) if text is not None: # Update local_vars to include the converted text parameter local_vars["text"] = text @@ -912,8 +916,12 @@ def responses( ) local_vars["extra_headers"] = extra_headers - # Convert text_format to text parameter if provided - text = ResponsesAPIRequestUtils.convert_text_format_to_text_param(text_format=text_format, text=text) + # Convert text_format/response_format to text parameter if provided + text = ResponsesAPIRequestUtils.convert_text_format_to_text_param( + text_format=text_format, + text=text, + response_format=kwargs.pop("response_format", None), + ) if text is not None: # Update local_vars to include the converted text parameter local_vars["text"] = text diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 4b5def790ed..5f7cea89cb5 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -917,37 +917,78 @@ class ResponsesAPIRequestUtils: return responses_api_response @staticmethod - def convert_text_format_to_text_param( - text_format: type["BaseModel"] | dict | None, - text: Optional["ResponseText"] = None, + def _convert_response_format_to_text_param( + response_format: type["BaseModel"] | dict | None, ) -> Optional["ResponseText"]: """ - Convert text_format parameter to text parameter for the responses API. + Convert a Chat-Completions style `response_format` (or a Pydantic model) into + the Responses API `text` parameter. - Args: - text_format: Pydantic model class or dict to convert to response format - text: Existing text parameter (if provided, text_format is ignored) + Chat Completions nests the schema under `json_schema`, the Responses API hoists + those fields to the top level of `text.format`: + + {"type": "json_schema", "json_schema": {"name": ..., "schema": ...}} + -> {"format": {"type": "json_schema", "name": ..., "schema": ...}} + + 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 """ - if text_format is not None and text is None: - from litellm.llms.base_llm.base_utils import type_to_response_format_param + from litellm.llms.base_llm.base_utils import type_to_response_format_param - # Convert Pydantic model to response format - response_format: Final = type_to_response_format_param(text_format) - if response_format is not None: - # Create ResponseText object with the format - # The responses API expects the format to have name at the top level - text = { - "format": { - "type": response_format["type"], - "name": response_format["json_schema"]["name"], - "schema": response_format["json_schema"]["schema"], - "strict": response_format["json_schema"]["strict"], - } - } - return text + # Normalizes a Pydantic model into a response_format dict; passes a dict through. + converted = type_to_response_format_param(response_format) + if converted is None: + return None + + format_type = converted.get("type") + if format_type is None: + return None + if format_type != "json_schema": + return {"format": {"type": format_type}} + + json_schema = converted.get("json_schema") or {} + text_format_param: dict = {"type": format_type} + # `name`/`schema` are required by the API and `strict`/`description` are optional, + # so copy whatever was supplied and let the provider reject a malformed schema. + for key in ("name", "schema", "strict", "description"): + if json_schema.get(key) is not None: + text_format_param[key] = json_schema[key] + return {"format": text_format_param} + + @staticmethod + def convert_text_format_to_text_param( + text_format: type["BaseModel"] | dict | None, + text: Optional["ResponseText"] = None, + response_format: type["BaseModel"] | dict | None = None, + ) -> Optional["ResponseText"]: + """ + Convert text_format/response_format parameters to the text parameter for the responses API. + + `response_format` is accepted as a compatibility alias for callers coming from + `litellm.completion()`, where it is the spelling for structured output. It was + previously discarded without an error, which read as "structured output is + unsupported here" when it is supported under a different name. + + Args: + text_format: Pydantic model class or dict to convert to response format + text: Existing text parameter (if provided, the other two are ignored) + response_format: Chat-Completions style response_format (used only if + neither text nor text_format was supplied) + + Returns: + ResponseText object with the converted format, or None if conversion fails + """ + 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 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 new file mode 100644 index 00000000000..896cb0ffb91 --- /dev/null +++ b/tests/test_litellm/responses/test_response_format_conversion.py @@ -0,0 +1,174 @@ +""" +Tests for `response_format` on the responses API. + +`response_format` is the spelling `litellm.completion()` uses for structured output. +The responses API equivalent is `text.format`, and `response_format` used to be dropped +by the `ResponsesAPIOptionalRequestParams` filter in +`ResponsesAPIRequestUtils.get_requested_response_api_optional_param` before any +validation ran -- so the call succeeded with no format enforced and no error raised. + +These assert on the params that would be sent to the provider rather than on a +successful return, because the call returned successfully in the broken case too. +""" + +import os +import sys +from unittest.mock import patch + +import pytest +from pydantic import BaseModel + +sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system path + +import litellm +from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse + +FLAGS_SCHEMA = { + "type": "object", + "properties": {"flags": {"type": "array", "items": {"type": "string"}}}, + "required": ["flags"], + "additionalProperties": False, +} + + +def _capture_request_params(**responses_kwargs): + """Call litellm.responses and return the optional request params bound for the provider.""" + captured = {} + + def mock_handler( + model, + input, + responses_api_provider_config, + response_api_optional_request_params, + custom_llm_provider, + litellm_params, + logging_obj, + _is_async=False, + **kwargs, + ): + captured["params"] = response_api_optional_request_params + return ResponsesAPIResponse( + id="resp_123", + object="response", + created_at=1741476542, + status="completed", + model=model, + output=[], + usage=ResponseAPIUsage(input_tokens=10, output_tokens=20, total_tokens=30), + error=None, + incomplete_details=None, + ) + + with patch( + "litellm.responses.main.base_llm_http_handler.response_api_handler", + new=mock_handler, + ): + litellm.responses( + model="gpt-4o", + api_key="test-key", + api_base="https://api.openai.com/v1", + input="Review this draft.", + **responses_kwargs, + ) + + return captured["params"] + + +def test_response_format_json_schema_reaches_request_as_text_format(): + """A json_schema response_format is hoisted into text.format with the schema intact.""" + params = _capture_request_params( + response_format={ + "type": "json_schema", + "json_schema": {"name": "Flags", "strict": True, "schema": FLAGS_SCHEMA}, + } + ) + + # The bug: params carried no "text" key at all, so nothing constrained the output. + assert "text" in params, "response_format should be mapped onto the text parameter" + assert params["text"]["format"] == { + "type": "json_schema", + "name": "Flags", + "strict": True, + "schema": FLAGS_SCHEMA, + } + + # The chat-completions spelling must not also be forwarded to the provider. + assert "response_format" not in params + + +def test_response_format_accepts_pydantic_model(): + """response_format= converts the same way text_format= does.""" + + class Flags(BaseModel): + flags: list[str] + + params = _capture_request_params(response_format=Flags) + + text_format = params["text"]["format"] + assert text_format["type"] == "json_schema" + assert text_format["name"] == "Flags" + assert "flags" in text_format["schema"]["properties"] + + +def test_response_format_json_schema_without_strict(): + """`strict` is optional in a hand-written response_format; omitting it is not an error.""" + params = _capture_request_params( + response_format={ + "type": "json_schema", + "json_schema": {"name": "Flags", "schema": FLAGS_SCHEMA}, + } + ) + + assert params["text"]["format"] == { + "type": "json_schema", + "name": "Flags", + "schema": FLAGS_SCHEMA, + } + + +@pytest.mark.parametrize("format_type", ["json_object", "text"]) +def test_response_format_without_schema(format_type): + """ + The schema-less formats carry no json_schema block. These previously raised + KeyError in the conversion helper rather than mapping across. + """ + params = _capture_request_params(response_format={"type": format_type}) + + assert params["text"]["format"] == {"type": format_type} + + +def test_explicit_text_takes_precedence_over_response_format(): + """text is the native spelling, so it wins when both are supplied.""" + native_text = {"format": {"type": "json_schema", "name": "Native", "schema": FLAGS_SCHEMA}} + + params = _capture_request_params( + text=native_text, + response_format={ + "type": "json_schema", + "json_schema": {"name": "Alias", "strict": True, "schema": FLAGS_SCHEMA}, + }, + ) + + assert params["text"]["format"]["name"] == "Native" + + +def test_text_format_takes_precedence_over_response_format(): + """text_format is the responses-API spelling, so it also wins over the alias.""" + + class Native(BaseModel): + flags: list[str] + + params = _capture_request_params( + text_format=Native, + response_format={ + "type": "json_schema", + "json_schema": {"name": "Alias", "strict": True, "schema": FLAGS_SCHEMA}, + }, + ) + + assert params["text"]["format"]["name"] == "Native" + + +def test_no_format_leaves_text_unset(): + """Absent every spelling, nothing is invented.""" + assert "text" not in _capture_request_params() From 5365c3fdcc21506fb6141e0b0cfe1ac1bc0f69bd Mon Sep 17 00:00:00 2001 From: MHammett Date: Sun, 16 Aug 2026 20:23:34 -0500 Subject: [PATCH 2/4] style(responses): satisfy the LIT010 type-discipline gate The four locals in _convert_response_format_to_text_param were assigned without Final, which the gate counts as open to rebinding -- LIT010 sits at its ceiling, so four new ones tripped it. None of them is ever rebound, so Final is simply correct here and matches the rest of the file. Building `text_format_param` by mutation was also the only reason it needed to stay non-Final. Replaced with a comprehension merged into the returned literal, which reads better and drops one LIT011 in-place mutation as well. No behaviour change: the tests are untouched and still pass, and 5 of the 8 still fail without the fix commit. --- litellm/responses/utils.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 5f7cea89cb5..733f4f17f31 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -939,24 +939,25 @@ class ResponsesAPIRequestUtils: 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 = type_to_response_format_param(response_format) + converted: Final = type_to_response_format_param(response_format) if converted is None: return None - format_type = converted.get("type") + format_type: Final = converted.get("type") if format_type is None: return None if format_type != "json_schema": return {"format": {"type": format_type}} - json_schema = converted.get("json_schema") or {} - text_format_param: dict = {"type": format_type} + json_schema: Final = converted.get("json_schema") or {} # `name`/`schema` are required by the API and `strict`/`description` are optional, # so copy whatever was supplied and let the provider reject a malformed schema. - for key in ("name", "schema", "strict", "description"): - if json_schema.get(key) is not None: - text_format_param[key] = json_schema[key] - return {"format": text_format_param} + schema_fields: Final = { + key: json_schema[key] + for key in ("name", "schema", "strict", "description") + if json_schema.get(key) is not None + } + return {"format": {"type": format_type, **schema_fields}} @staticmethod def convert_text_format_to_text_param( From da207f1f4cd9c3bf29f742cd4a8fa6c0cafeb349 Mon Sep 17 00:00:00 2001 From: MHammett Date: Sun, 16 Aug 2026 21:07:32 -0500 Subject: [PATCH 3/4] 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"}}) From c5d0800004935554a95328e6970e008e4bb08f36 Mon Sep 17 00:00:00 2001 From: MHammett Date: Sun, 16 Aug 2026 21:10:35 -0500 Subject: [PATCH 4/4] docs(responses): trim the field-copy comment per review Keeps the two comments carrying non-obvious facts (the json_schema nesting difference between the two APIs, and type_to_response_format_param passing a dict through unchanged) and drops the one restating the copy. --- litellm/responses/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index ea0f0b962ea..abf7303607a 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -959,8 +959,8 @@ class ResponsesAPIRequestUtils: return {"format": {"type": format_type}} json_schema: Final = converted.get("json_schema") or {} - # `name`/`schema` are required by the API and `strict`/`description` are optional, - # so copy whatever was supplied and let the provider reject a malformed schema. + # Only `strict`/`description` are optional; a missing `name`/`schema` is the + # provider's error to report, not ours to guess at. schema_fields: Final = { key: json_schema[key] for key in ("name", "schema", "strict", "description")