This commit is contained in:
Bharadwaj Pendyala 2026-08-26 21:06:14 -04:00 committed by GitHub
commit bd77b07a7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 163 additions and 1 deletions

View file

@ -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,

View file

@ -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