This commit is contained in:
MHammett 2026-08-27 19:57:48 -05:00 committed by GitHub
commit 7e3afda2f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 284 additions and 24 deletions

View file

@ -488,8 +488,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
@ -1022,8 +1026,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

@ -959,38 +959,89 @@ 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 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) or {}
format_type: Final = converted.get("type")
if format_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,
)
if format_type != "json_schema":
return {"format": {"type": format_type}}
json_schema: Final = converted.get("json_schema") or {}
# 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")
if json_schema.get(key) is not None
}
return {"format": {"type": format_type, **schema_fields}}
@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,201 @@
"""
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()
@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"}})