From 3e94f7d71e386550648146e056c04b23f50c2b97 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:47:00 -0700 Subject: [PATCH 1/4] fix(interactions): map step and turn input to Responses API roles and content types --- .../transformation.py | 159 ++++++++---------- .../test_litellm_responses_bridge.py | 72 ++++++++ 2 files changed, 146 insertions(+), 85 deletions(-) diff --git a/litellm/interactions/litellm_responses_transformation/transformation.py b/litellm/interactions/litellm_responses_transformation/transformation.py index 2a71c3e8977..4209f1538e0 100644 --- a/litellm/interactions/litellm_responses_transformation/transformation.py +++ b/litellm/interactions/litellm_responses_transformation/transformation.py @@ -2,10 +2,11 @@ Transformation utilities for bridging Interactions API to Responses API. This module handles transforming between: -- Interactions API format (Google's format with Turn[], system_instruction, etc.) +- Interactions API format (Google's format with Step[]/Turn[], system_instruction, etc.) - Responses API format (OpenAI's format with input[], instructions, etc.) """ +from types import MappingProxyType from typing import Any, Final, cast from litellm.types.interactions import ( @@ -19,6 +20,8 @@ from litellm.types.llms.openai import ( ResponsesAPIResponse, ) +_STEP_TYPE_ROLES: Final = MappingProxyType({"user_input": "user", "model_output": "assistant"}) + class LiteLLMResponsesInteractionsConfig: """Configuration class for transforming between Interactions API and Responses API.""" @@ -91,112 +94,98 @@ class LiteLLMResponsesInteractionsConfig: Interactions API input can be: - string: "Hello" - - Turn[]: [{"role": "user", "content": [...]}] - - Content object + - Step[]: [{"type": "user_input", "content": [...]}, {"type": "model_output", "content": [...]}] + - Turn[] (legacy): [{"role": "user", "content": [...]}] + - Content | Content[]: one user message worth of content parts Responses API input is: - string: "Hello" - - Message[]: [{"role": "user", "content": [...]}] + - Message[]: [{"role": "user", "content": [{"type": "input_text", ...}]}] """ if isinstance(input, str): - # ResponseInputParam accepts str return cast(ResponseInputParam, input) if isinstance(input, list): - # Turn[] format - convert to Responses API Message[] format - messages: Final = [] - for turn in input: - if isinstance(turn, dict): - role = turn.get("role", "user") - content = turn.get("content", []) - - # Transform content array - transformed_content = LiteLLMResponsesInteractionsConfig._transform_content_array(content) - - messages.append( - { - "role": role, - "content": transformed_content, - } - ) - elif isinstance(turn, Turn): - # Pydantic model - role = turn.role if hasattr(turn, "role") else "user" - content = turn.content if hasattr(turn, "content") else [] - - # Ensure content is a list for _transform_content_array - # Cast to List[Any] to handle various content types - if isinstance(content, list): - content_list: list[Any] = list(content) - elif content is not None: - content_list = [content] - else: - content_list = [] - - transformed_content = LiteLLMResponsesInteractionsConfig._transform_content_array(content_list) - - messages.append( - { - "role": role, - "content": transformed_content, - } - ) - - return cast(ResponseInputParam, messages) - - # Single content object - wrap in message - if isinstance(input, dict): + if any(LiteLLMResponsesInteractionsConfig._is_history_item(item) for item in input): + return cast( + ResponseInputParam, + [ + LiteLLMResponsesInteractionsConfig._transform_history_item(item) + for item in input + if LiteLLMResponsesInteractionsConfig._is_history_item(item) + ], + ) return cast( ResponseInputParam, [ { "role": "user", - "content": LiteLLMResponsesInteractionsConfig._transform_content_array( - input.get("content", []) if isinstance(input.get("content"), list) else [input] - ), + "content": LiteLLMResponsesInteractionsConfig._transform_content_array(list(input), "user"), + } + ], + ) + + if isinstance(input, dict): + raw_content: Final = input.get("content") + content_items: Final = raw_content if isinstance(raw_content, list) else [input] + return cast( + ResponseInputParam, + [ + { + "role": "user", + "content": LiteLLMResponsesInteractionsConfig._transform_content_array(content_items, "user"), } ], ) - # Fallback: convert to string return cast(ResponseInputParam, str(input)) @staticmethod - def _transform_content_array(content: list[Any]) -> list[dict[str, Any]]: - """Transform Interactions API content array to Responses API format.""" - if not isinstance(content, list): - # Single content item - wrap in array - content = [content] + def _is_history_item(item: Any) -> bool: + if isinstance(item, Turn): + return True + return isinstance(item, dict) and ("role" in item or item.get("type") in _STEP_TYPE_ROLES) - transformed: Final[list[dict[str, Any]]] = [] - for item in content: - if isinstance(item, dict): - # Already in dict format, pass through - transformed.append(item) - elif isinstance(item, str): - # Plain string - wrap in text format - transformed.append({"type": "text", "text": item}) - else: - # Pydantic model or other - convert to dict - if hasattr(item, "model_dump"): - dumped = item.model_dump() - if isinstance(dumped, dict): - transformed.append(dumped) - else: - # Fallback: wrap in text format - transformed.append({"type": "text", "text": str(dumped)}) - elif hasattr(item, "dict"): - dumped = item.dict() - if isinstance(dumped, dict): - transformed.append(dumped) - else: - # Fallback: wrap in text format - transformed.append({"type": "text", "text": str(dumped)}) - else: - # Fallback: wrap in text format - transformed.append({"type": "text", "text": str(item)}) + @staticmethod + def _transform_history_item(item: "Turn | dict[str, Any]") -> dict[str, Any]: + raw: Final = item.model_dump(exclude_none=True) if isinstance(item, Turn) else item + role: Final = LiteLLMResponsesInteractionsConfig._responses_role(raw) + raw_content: Final = raw.get("content") + content_items: Final = ( + raw_content if isinstance(raw_content, list) else [] if raw_content is None else [raw_content] + ) + return { + "role": role, + "content": LiteLLMResponsesInteractionsConfig._transform_content_array(content_items, role), + } - return transformed + @staticmethod + def _responses_role(item: dict[str, Any]) -> str: + step_role: Final = _STEP_TYPE_ROLES.get(str(item.get("type", ""))) + if step_role is not None: + return step_role + raw_role: Final = str(item.get("role") or "user") + return "assistant" if raw_role == "model" else raw_role + + @staticmethod + def _transform_content_array(content: list[Any], role: str) -> list[dict[str, Any]]: + """Transform Interactions API content parts to Responses API parts for the given role.""" + return [LiteLLMResponsesInteractionsConfig._transform_content_item(item, role) for item in content] + + @staticmethod + def _transform_content_item(item: Any, role: str) -> dict[str, Any]: + text_type: Final = "output_text" if role == "assistant" else "input_text" + if isinstance(item, str): + return {"type": text_type, "text": item} + if isinstance(item, dict): + if item.get("type") == "text": + return {"type": text_type, "text": str(item.get("text", ""))} + return item + if hasattr(item, "model_dump"): + dumped: Final = item.model_dump(exclude_none=True) + if isinstance(dumped, dict): + return LiteLLMResponsesInteractionsConfig._transform_content_item(dumped, role) + return {"type": text_type, "text": str(item)} @staticmethod def transform_responses_response_to_interactions_response( diff --git a/tests/test_litellm/interactions/test_litellm_responses_bridge.py b/tests/test_litellm/interactions/test_litellm_responses_bridge.py index 17e7f9fc4ff..8400f2c4840 100644 --- a/tests/test_litellm/interactions/test_litellm_responses_bridge.py +++ b/tests/test_litellm/interactions/test_litellm_responses_bridge.py @@ -7,6 +7,10 @@ the litellm_responses bridge provider, which calls litellm.responses() internall import os +from litellm.interactions.litellm_responses_transformation.transformation import ( + LiteLLMResponsesInteractionsConfig, +) +from litellm.types.interactions import Turn from tests.test_litellm.interactions.base_interactions_test import ( BaseInteractionsTest, ) @@ -26,3 +30,71 @@ class TestLiteLLMResponsesBridge(BaseInteractionsTest): def get_api_key(self) -> str: """Return the OpenAI API key from environment.""" return os.getenv("OPENAI_API_KEY", "") + + +class TestBridgeInputTransformation: + """Regression tests for translating Interactions input into Responses API input. + + The bridge used to pass Google content parts through raw ({"type": "text"}), + which the Responses API rejects with a 400, and it dropped the role encoded + in step types and in the legacy "model" turn role. + """ + + def test_step_input_maps_roles_and_content_types(self): + transformed = LiteLLMResponsesInteractionsConfig._transform_interactions_input_to_responses_input( + [ + {"type": "user_input", "content": [{"type": "text", "text": "I like apples."}]}, + {"type": "model_output", "content": [{"type": "text", "text": "I like oranges."}]}, + {"type": "user_input", "content": [{"type": "text", "text": "What did you say?"}]}, + ] + ) + assert transformed == [ + {"role": "user", "content": [{"type": "input_text", "text": "I like apples."}]}, + {"role": "assistant", "content": [{"type": "output_text", "text": "I like oranges."}]}, + {"role": "user", "content": [{"type": "input_text", "text": "What did you say?"}]}, + ] + + def test_legacy_turn_input_maps_model_role_to_assistant(self): + transformed = LiteLLMResponsesInteractionsConfig._transform_interactions_input_to_responses_input( + [ + {"role": "user", "content": [{"type": "text", "text": "I like apples."}]}, + {"role": "model", "content": [{"type": "text", "text": "I like oranges."}]}, + ] + ) + assert transformed == [ + {"role": "user", "content": [{"type": "input_text", "text": "I like apples."}]}, + {"role": "assistant", "content": [{"type": "output_text", "text": "I like oranges."}]}, + ] + + def test_turn_pydantic_model_with_string_content(self): + transformed = LiteLLMResponsesInteractionsConfig._transform_interactions_input_to_responses_input( + [Turn(role="model", content="I like oranges.")] + ) + assert transformed == [ + {"role": "assistant", "content": [{"type": "output_text", "text": "I like oranges."}]} + ] + + def test_string_input_passes_through(self): + transformed = LiteLLMResponsesInteractionsConfig._transform_interactions_input_to_responses_input("Hello") + assert transformed == "Hello" + + def test_content_list_input_becomes_single_user_message(self): + transformed = LiteLLMResponsesInteractionsConfig._transform_interactions_input_to_responses_input( + [{"type": "text", "text": "Hello"}, "world"] + ) + assert transformed == [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "Hello"}, + {"type": "input_text", "text": "world"}, + ], + } + ] + + def test_non_text_content_passes_through_unchanged(self): + image_part = {"type": "image", "data": "base64data", "mime_type": "image/png"} + transformed = LiteLLMResponsesInteractionsConfig._transform_interactions_input_to_responses_input( + [{"type": "user_input", "content": [image_part]}] + ) + assert transformed == [{"role": "user", "content": [image_part]}] From 0aeed161259aaec87e6e7e2275191bce16c11d8d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:40:32 -0700 Subject: [PATCH 2/4] test(interactions): follow Google spec drift replacing Turn with typed steps --- .../interactions/test_openapi_compliance.py | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/tests/test_litellm/interactions/test_openapi_compliance.py b/tests/test_litellm/interactions/test_openapi_compliance.py index 1fe343ca6ee..2665f8703a6 100644 --- a/tests/test_litellm/interactions/test_openapi_compliance.py +++ b/tests/test_litellm/interactions/test_openapi_compliance.py @@ -167,17 +167,39 @@ class TestRequestCompliance: assert text_schema["properties"]["type"].get("const") == "text" print("✓ TextContent schema is correct") - def test_turn_schema(self, spec_dict): - """Verify Turn schema for multi-turn conversations.""" - turn_schema = spec_dict["components"]["schemas"]["Turn"] + def test_step_schema(self, spec_dict): + """Verify step-based multi-turn input. - assert "role" in turn_schema["properties"] - assert "content" in turn_schema["properties"] + Google replaced the role-carrying `Turn` schema with typed steps + (spec update of Aug 13, 2026): conversation history is now a `Step[]` + where `UserInputStep`/`ModelOutputStep` pin `type` values that our + transformations read to recover the role. Assert exactly what our code + depends on: `InteractionsInput` accepts a Step array, both step kinds + are part of the `Step` union, each pins its `type` const, and each + carries a `Content[]` content field. + """ + input_schema = spec_dict["components"]["schemas"]["InteractionsInput"] + step_array_items = [ + option["items"]["$ref"].split("/")[-1] + for option in input_schema["oneOf"] + if option.get("type") == "array" and "$ref" in option.get("items", {}) + ] + assert "Step" in step_array_items, f"InteractionsInput should accept Step[], got arrays of {step_array_items}" - # Content can be string or Content[] - content_prop = turn_schema["properties"]["content"] - assert "oneOf" in content_prop - print("✓ Turn schema supports role + content") + step_variants = { + option["$ref"].split("/")[-1] + for option in spec_dict["components"]["schemas"]["Step"]["oneOf"] + if "$ref" in option + } + assert {"UserInputStep", "ModelOutputStep"} <= step_variants, f"Step union is missing role steps: {step_variants}" + + for step_name, type_value in [("UserInputStep", "user_input"), ("ModelOutputStep", "model_output")]: + step_schema = spec_dict["components"]["schemas"][step_name] + assert step_schema["properties"]["type"].get("const") == type_value + assert "type" in step_schema["required"] + content_items = step_schema["properties"]["content"]["items"] + assert content_items["$ref"].split("/")[-1] == "Content" + print(f"✓ {step_name} pins type '{type_value}' with Content[] content") class TestResponseCompliance: From 432ea8644b838b44685dbe5929fc45a47205eda7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:47:30 -0700 Subject: [PATCH 3/4] test(interactions): send step and content-list input to the live Gemini API --- .../test_google_interactions_integration.py | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/tests/test_litellm/interactions/test_google_interactions_integration.py b/tests/test_litellm/interactions/test_google_interactions_integration.py index 9c651cc94f5..41f0fa0d7fb 100644 --- a/tests/test_litellm/interactions/test_google_interactions_integration.py +++ b/tests/test_litellm/interactions/test_google_interactions_integration.py @@ -55,17 +55,10 @@ class TestGoogleInteractionsCreate: print(f"Usage: {response.usage}") def test_create_with_content_list(self, api_key): - """Test creating an interaction with a structured content list (Turn format).""" + """Test creating an interaction with a structured content list (Content[] input).""" response = interactions.create( model="gemini/gemini-2.5-flash", - input=[ - { - "role": "user", - "content": [ - {"type": "text", "text": "What is the capital of France?"} - ], - } - ], + input=[{"type": "text", "text": "What is the capital of France?"}], api_key=api_key, ) @@ -169,25 +162,25 @@ class TestGoogleInteractionsStreaming: class TestGoogleInteractionsMultiTurn: - """Tests for multi-turn conversations using Turn[] input.""" + """Tests for multi-turn conversations using Step[] input.""" def test_multi_turn_conversation(self, api_key): - """Test a multi-turn conversation per OpenAPI spec (Turn[] format).""" + """Test a multi-turn conversation per OpenAPI spec (Step[] format).""" response = interactions.create( model="gemini/gemini-2.5-flash", input=[ { - "role": "user", + "type": "user_input", "content": [{"type": "text", "text": "My name is Alice."}], }, { - "role": "model", + "type": "model_output", "content": [ {"type": "text", "text": "Hello Alice! Nice to meet you."} ], }, { - "role": "user", + "type": "user_input", "content": [{"type": "text", "text": "What is my name?"}], }, ], From 2a3b54394fcbbaa1992fb4d2b3085a0920aff150 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:03:33 -0700 Subject: [PATCH 4/4] refactor(interactions): drop Any and extra casts from bridge input helpers --- .../transformation.py | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/litellm/interactions/litellm_responses_transformation/transformation.py b/litellm/interactions/litellm_responses_transformation/transformation.py index 4209f1538e0..9657b444969 100644 --- a/litellm/interactions/litellm_responses_transformation/transformation.py +++ b/litellm/interactions/litellm_responses_transformation/transformation.py @@ -6,9 +6,12 @@ This module handles transforming between: - Responses API format (OpenAI's format with input[], instructions, etc.) """ +from collections.abc import Mapping, Sequence from types import MappingProxyType from typing import Any, Final, cast +from pydantic import BaseModel + from litellm.types.interactions import ( InteractionInput, InteractionsAPIOptionalRequestParams, @@ -106,24 +109,21 @@ class LiteLLMResponsesInteractionsConfig: return cast(ResponseInputParam, input) if isinstance(input, list): - if any(LiteLLMResponsesInteractionsConfig._is_history_item(item) for item in input): - return cast( - ResponseInputParam, - [ - LiteLLMResponsesInteractionsConfig._transform_history_item(item) - for item in input - if LiteLLMResponsesInteractionsConfig._is_history_item(item) - ], - ) - return cast( - ResponseInputParam, + transformed: Final = ( [ + LiteLLMResponsesInteractionsConfig._transform_history_item(item) + for item in input + if LiteLLMResponsesInteractionsConfig._is_history_item(item) + ] + if any(LiteLLMResponsesInteractionsConfig._is_history_item(item) for item in input) + else [ { "role": "user", - "content": LiteLLMResponsesInteractionsConfig._transform_content_array(list(input), "user"), + "content": LiteLLMResponsesInteractionsConfig._transform_content_array(input, "user"), } - ], + ] ) + return cast(ResponseInputParam, transformed) if isinstance(input, dict): raw_content: Final = input.get("content") @@ -141,16 +141,17 @@ class LiteLLMResponsesInteractionsConfig: return cast(ResponseInputParam, str(input)) @staticmethod - def _is_history_item(item: Any) -> bool: + def _is_history_item(item: object) -> bool: if isinstance(item, Turn): return True return isinstance(item, dict) and ("role" in item or item.get("type") in _STEP_TYPE_ROLES) @staticmethod - def _transform_history_item(item: "Turn | dict[str, Any]") -> dict[str, Any]: + def _transform_history_item(item: object) -> Mapping[str, object]: raw: Final = item.model_dump(exclude_none=True) if isinstance(item, Turn) else item - role: Final = LiteLLMResponsesInteractionsConfig._responses_role(raw) - raw_content: Final = raw.get("content") + fields: Final = raw if isinstance(raw, Mapping) else {} + role: Final = LiteLLMResponsesInteractionsConfig._responses_role(fields) + raw_content: Final = fields.get("content") content_items: Final = ( raw_content if isinstance(raw_content, list) else [] if raw_content is None else [raw_content] ) @@ -160,7 +161,7 @@ class LiteLLMResponsesInteractionsConfig: } @staticmethod - def _responses_role(item: dict[str, Any]) -> str: + def _responses_role(item: Mapping[str, object]) -> str: step_role: Final = _STEP_TYPE_ROLES.get(str(item.get("type", ""))) if step_role is not None: return step_role @@ -168,23 +169,21 @@ class LiteLLMResponsesInteractionsConfig: return "assistant" if raw_role == "model" else raw_role @staticmethod - def _transform_content_array(content: list[Any], role: str) -> list[dict[str, Any]]: + def _transform_content_array(content: Sequence[object], role: str) -> Sequence[Mapping[str, object]]: """Transform Interactions API content parts to Responses API parts for the given role.""" return [LiteLLMResponsesInteractionsConfig._transform_content_item(item, role) for item in content] @staticmethod - def _transform_content_item(item: Any, role: str) -> dict[str, Any]: + def _transform_content_item(item: object, role: str) -> Mapping[str, object]: text_type: Final = "output_text" if role == "assistant" else "input_text" if isinstance(item, str): return {"type": text_type, "text": item} - if isinstance(item, dict): + if isinstance(item, Mapping): if item.get("type") == "text": return {"type": text_type, "text": str(item.get("text", ""))} return item - if hasattr(item, "model_dump"): - dumped: Final = item.model_dump(exclude_none=True) - if isinstance(dumped, dict): - return LiteLLMResponsesInteractionsConfig._transform_content_item(dumped, role) + if isinstance(item, BaseModel): + return LiteLLMResponsesInteractionsConfig._transform_content_item(item.model_dump(exclude_none=True), role) return {"type": text_type, "text": str(item)} @staticmethod