mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
Merge c5d0800004 into 74050e03c5
This commit is contained in:
commit
7e3afda2f0
3 changed files with 284 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
201
tests/test_litellm/responses/test_response_format_conversion.py
Normal file
201
tests/test_litellm/responses/test_response_format_conversion.py
Normal 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"}})
|
||||
Loading…
Add table
Reference in a new issue