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 <noreply@anthropic.com>
This commit is contained in:
MHammett 2026-08-16 20:10:23 -05:00
parent 973329e986
commit 46b94666d1
3 changed files with 250 additions and 27 deletions

View file

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

View file

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

View file

@ -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=<BaseModel> converts the same way text_format=<BaseModel> 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()