mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
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:
parent
973329e986
commit
46b94666d1
3 changed files with 250 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
174
tests/test_litellm/responses/test_response_format_conversion.py
Normal file
174
tests/test_litellm/responses/test_response_format_conversion.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue