This commit is contained in:
MHammett 2026-09-28 10:15:58 -05:00 • committed by GitHub
commit ef7f524064
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 287 additions and 24 deletions

View file

@ -615,8 +615,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
@ -1181,8 +1185,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

@ -1023,38 +1023,95 @@ class ResponsesAPIRequestUtils:
return responses_api_response
@staticmethod
def _convert_response_format_to_text_param(
response_format: type["BaseModel"] | dict | None,
) -> "ResponseText":
"""
Convert a Chat-Completions style `response_format` (or a Pydantic model) into
the Responses API `text` parameter.
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.
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 types import MappingProxyType
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 or converted.get("type") is 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,
)
format_type: Final = converted["type"]
if format_type != "json_schema":
schema_less: Final[ResponseText] = {"format": {"type": format_type}}
return schema_less
json_schema: Final = converted.get("json_schema") or MappingProxyType({})
# Only `strict`/`description` are optional; a missing `name`/`schema` is the
# provider's error to report, not ours to guess at.
schema_fields: Final = MappingProxyType(
{
key: json_schema[key]
for key in ("name", "schema", "strict", "description")
if json_schema.get(key) is not None
}
)
text_param: Final[ResponseText] = {"format": {"type": format_type, **schema_fields}}
return text_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 parameter to text parameter for the responses API.
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, text_format is ignored)
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_format is not None and text is None:
from litellm.llms.base_llm.base_utils import type_to_response_format_param
ResponseText object with the converted format, or None if none was supplied
# 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
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 not None:
return ResponsesAPIRequestUtils._convert_response_format_to_text_param(candidate)
return text
@staticmethod

View file

@ -0,0 +1,198 @@
"""
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.
"""
from unittest.mock import patch
import pytest
from pydantic import BaseModel
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()
@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 litellm.BadRequestError directly: a bare ValueError would reach the
caller through exception_type() as APIConnectionError, reporting bad input as a
network fault.
"""
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"}})