diff --git a/litellm/responses/main.py b/litellm/responses/main.py index e0af363b1a5..c90ba4abc30 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -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 diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 4b5def790ed..5f7cea89cb5 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -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 diff --git a/tests/test_litellm/responses/test_response_format_conversion.py b/tests/test_litellm/responses/test_response_format_conversion.py new file mode 100644 index 00000000000..896cb0ffb91 --- /dev/null +++ b/tests/test_litellm/responses/test_response_format_conversion.py @@ -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= converts the same way text_format= 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()