From a6388d8f693db9e93366c666e6b7a0c042dcbbad Mon Sep 17 00:00:00 2001 From: Bharadwaj Pendyala Date: Sun, 16 Aug 2026 08:33:40 -0500 Subject: [PATCH] fix(interactions): map response_format onto the Responses API text param in the bridge --- .../transformation.py | 72 ++++++++++++++- .../test_litellm_responses_bridge.py | 92 +++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/litellm/interactions/litellm_responses_transformation/transformation.py b/litellm/interactions/litellm_responses_transformation/transformation.py index 9657b444969..5210630fec5 100644 --- a/litellm/interactions/litellm_responses_transformation/transformation.py +++ b/litellm/interactions/litellm_responses_transformation/transformation.py @@ -8,7 +8,7 @@ This module handles transforming between: from collections.abc import Mapping, Sequence from types import MappingProxyType -from typing import Any, Final, cast +from typing import TYPE_CHECKING, Any, Final, cast from pydantic import BaseModel @@ -23,7 +23,13 @@ from litellm.types.llms.openai import ( ResponsesAPIResponse, ) +if TYPE_CHECKING: + from openai.types.responses.response_text_config_param import ( + ResponseTextConfigParam as ResponseText, + ) + _STEP_TYPE_ROLES: Final = MappingProxyType({"user_input": "user", "model_output": "assistant"}) +_JSON_MIME_TYPE: Final = "application/json" class LiteLLMResponsesInteractionsConfig: @@ -77,6 +83,13 @@ class LiteLLMResponsesInteractionsConfig: if "max_output_tokens" in generation_config: responses_request["max_output_tokens"] = generation_config["max_output_tokens"] + text_param: Final = LiteLLMResponsesInteractionsConfig._transform_response_format_to_text_param( + response_format=optional_params.get("response_format"), + response_mime_type=optional_params.get("response_mime_type"), + ) + if text_param is not None: + responses_request["text"] = text_param + # Pass through other optional params that match passthrough_params: Final = ["stream", "store", "metadata", "user"] for param in passthrough_params: @@ -88,6 +101,63 @@ class LiteLLMResponsesInteractionsConfig: return responses_request + @staticmethod + def _transform_response_format_to_text_param( + response_format: object, + response_mime_type: str | None, + ) -> "ResponseText | None": + """ + Transform an Interactions API JSON constraint to the Responses API `text` parameter. + + The constraint arrives in one of two shapes: + - current schema: one or more polymorphic entries, + e.g. {"type": "text", "mime_type": "application/json", "schema": {...}} + - legacy schema: the bare JSON schema in `response_format`, with the mime type + in `response_mime_type` + + The legacy check mirrors GoogleAIStudioInteractionsConfig.transform_interactions_request, + so both surfaces agree on which shape they are looking at. + """ + is_legacy: Final = bool( + response_mime_type + and not isinstance(response_format, list) + and (not isinstance(response_format, Mapping) or "mime_type" not in response_format) + ) + if is_legacy: + return LiteLLMResponsesInteractionsConfig._build_text_param( + mime_type=response_mime_type, + schema=response_format, + ) + + entries: Final = response_format if isinstance(response_format, list) else [response_format] + text_entry: Final = next( + (entry for entry in entries if isinstance(entry, Mapping) and entry.get("type") == "text"), + None, + ) + if text_entry is None: + return None + return LiteLLMResponsesInteractionsConfig._build_text_param( + mime_type=text_entry.get("mime_type"), + schema=text_entry.get("schema"), + ) + + @staticmethod + def _build_text_param(mime_type: object, schema: object) -> "ResponseText | None": + if mime_type is not None and mime_type != _JSON_MIME_TYPE: + return None + if isinstance(schema, Mapping) and schema: + return { + "format": { + "type": "json_schema", + "name": "response_schema", + "schema": dict(schema), + "strict": False, + } + } + if mime_type == _JSON_MIME_TYPE: + return {"format": {"type": "json_object"}} + return None + @staticmethod def _transform_interactions_input_to_responses_input( input: InteractionInput, diff --git a/tests/test_litellm/interactions/test_litellm_responses_bridge.py b/tests/test_litellm/interactions/test_litellm_responses_bridge.py index 8400f2c4840..ae38faa87ec 100644 --- a/tests/test_litellm/interactions/test_litellm_responses_bridge.py +++ b/tests/test_litellm/interactions/test_litellm_responses_bridge.py @@ -98,3 +98,95 @@ class TestBridgeInputTransformation: [{"type": "user_input", "content": [image_part]}] ) assert transformed == [{"role": "user", "content": [image_part]}] + + +class TestBridgeResponseFormat: + """The bridge used to drop response_format and response_mime_type entirely, so a caller + who asked the Interactions API for JSON silently got free text back from the Responses API. + """ + + def test_json_schema_response_format_becomes_text_format(self): + schema = { + "type": "object", + "properties": {"greeting": {"type": "string"}, "score": {"type": "number"}}, + "required": ["greeting", "score"], + } + request = LiteLLMResponsesInteractionsConfig.transform_interactions_request_to_responses_request( + model="gemini-3.5-flash", + input="Say hello and give a score of 1.", + optional_params={ + "response_format": {"type": "text", "mime_type": "application/json", "schema": schema}, + }, + ) + assert request["text"] == { + "format": { + "type": "json_schema", + "name": "response_schema", + "schema": schema, + "strict": False, + } + } + + def test_legacy_schema_and_response_mime_type_become_text_format(self): + schema = {"type": "object", "properties": {"greeting": {"type": "string"}}} + request = LiteLLMResponsesInteractionsConfig.transform_interactions_request_to_responses_request( + model="gemini-3.5-flash", + input="Say hello.", + optional_params={"response_format": schema, "response_mime_type": "application/json"}, + ) + assert request["text"]["format"]["type"] == "json_schema" + assert request["text"]["format"]["schema"] == schema + + def test_json_mime_type_without_schema_becomes_json_object(self): + request = LiteLLMResponsesInteractionsConfig.transform_interactions_request_to_responses_request( + model="gemini-3.5-flash", + input="Say hello.", + optional_params={"response_mime_type": "application/json"}, + ) + assert request["text"] == {"format": {"type": "json_object"}} + + def test_image_response_format_entry_is_skipped(self): + schema = {"type": "object", "properties": {"caption": {"type": "string"}}} + request = LiteLLMResponsesInteractionsConfig.transform_interactions_request_to_responses_request( + model="gemini-3.5-flash", + input="Describe this.", + optional_params={ + "response_format": [ + {"type": "image", "aspect_ratio": "1:1"}, + {"type": "text", "mime_type": "application/json", "schema": schema}, + ], + }, + ) + assert request["text"]["format"]["schema"] == schema + + def test_image_only_response_format_leaves_text_unset(self): + request = LiteLLMResponsesInteractionsConfig.transform_interactions_request_to_responses_request( + model="gemini-3.5-flash", + input="Draw a cat.", + optional_params={"response_format": [{"type": "image", "aspect_ratio": "1:1"}]}, + ) + assert "text" not in request + + def test_non_json_mime_type_leaves_text_unset(self): + request = LiteLLMResponsesInteractionsConfig.transform_interactions_request_to_responses_request( + model="gemini-3.5-flash", + input="Say hello.", + optional_params={"response_format": {"type": "text", "mime_type": "text/plain"}}, + ) + assert "text" not in request + + def test_unrecognized_entry_type_is_not_read_as_a_bare_schema(self): + request = LiteLLMResponsesInteractionsConfig.transform_interactions_request_to_responses_request( + model="gemini-3.5-flash", + input="Say hello.", + optional_params={"response_format": {"type": "audio", "mime_type": "audio/wav"}}, + ) + assert "text" not in request + + def test_request_without_response_format_is_unchanged(self): + request = LiteLLMResponsesInteractionsConfig.transform_interactions_request_to_responses_request( + model="gemini-3.5-flash", + input="Say hello.", + optional_params={}, + ) + assert "text" not in request