diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 3ef04866e0f..eb6cb43ead5 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -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 diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 39675faf735..8fb46bba70e 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -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 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..e576980d3c0 --- /dev/null +++ b/tests/test_litellm/responses/test_response_format_conversion.py @@ -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= 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() + + +@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"}})