mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-09 08:18:12 +00:00
fix(chatgpt): keep structured output text.format in Responses API requests
The allowed_keys filter in transform_responses_api_request dropped the text param, so json_schema and json_object response formats never reached the upstream API and callers got free-form prose instead of schema-shaped JSON. Whitelist text only when its format type is json_schema or json_object, keeping other text configs filtered as before.
This commit is contained in:
parent
cbf4e40d38
commit
2e258547ee
2 changed files with 91 additions and 11 deletions
|
|
@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, Final, cast
|
|||
|
||||
import httpx
|
||||
from openai.types.responses.response_input_param import ResponseInputItemParam
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm.exceptions import AuthenticationError
|
||||
from litellm.litellm_core_utils.core_helpers import process_response_headers
|
||||
|
|
@ -43,6 +44,29 @@ if TYPE_CHECKING:
|
|||
|
||||
_CHATGPT_SERVICE_TIERS: Final = {"default": "default", "priority": "priority", "fast": "priority"}
|
||||
|
||||
_STRUCTURED_OUTPUT_FORMAT_TYPES: Final = frozenset({"json_schema", "json_object"})
|
||||
|
||||
|
||||
_REQUEST_FIELDS: Final = TypeAdapter(dict[str, object])
|
||||
_MAPPING_FIELDS: Final = TypeAdapter(Mapping[str, object])
|
||||
|
||||
|
||||
def _as_mapping(value: object) -> "Mapping[str, object] | None":
|
||||
try:
|
||||
return _MAPPING_FIELDS.validate_python(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _has_structured_output_format(text: object) -> bool:
|
||||
text_mapping: Final = _as_mapping(text)
|
||||
if text_mapping is None:
|
||||
return False
|
||||
format_config: Final = _as_mapping(text_mapping.get("format"))
|
||||
if format_config is None:
|
||||
return False
|
||||
return format_config.get("type") in _STRUCTURED_OUTPUT_FORMAT_TYPES
|
||||
|
||||
|
||||
def _system_item_as_developer(item: ResponseInputItemParam) -> ResponseInputItemParam:
|
||||
# The Codex backend 400s on {"type": "message", "role": "system"} items ("System messages are not
|
||||
|
|
@ -111,25 +135,30 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict,
|
||||
) -> dict:
|
||||
request: Final = super().transform_responses_api_request(
|
||||
model,
|
||||
[{"role": "user", "content": input}]
|
||||
if isinstance(input, str)
|
||||
else [_system_item_as_developer(item) for item in input],
|
||||
response_api_optional_request_params,
|
||||
litellm_params,
|
||||
headers,
|
||||
request: Final = _REQUEST_FIELDS.validate_python(
|
||||
super().transform_responses_api_request(
|
||||
model,
|
||||
[{"role": "user", "content": input}]
|
||||
if isinstance(input, str)
|
||||
else [_system_item_as_developer(item) for item in input],
|
||||
response_api_optional_request_params,
|
||||
litellm_params,
|
||||
headers,
|
||||
)
|
||||
)
|
||||
base_instructions: Final = get_chatgpt_default_instructions()
|
||||
existing_instructions: Final = request.get("instructions")
|
||||
if existing_instructions:
|
||||
if isinstance(existing_instructions, str) and existing_instructions:
|
||||
if base_instructions not in existing_instructions:
|
||||
request["instructions"] = f"{base_instructions}\n\n{existing_instructions}"
|
||||
else:
|
||||
request["instructions"] = base_instructions
|
||||
request["store"] = False
|
||||
request["stream"] = True
|
||||
include: Final = list(request.get("include") or [])
|
||||
include_value: Final = request.get("include")
|
||||
include: Final[list[object]] = ( # mutable-ok: request["include"] is a JSON array on the wire
|
||||
list(include_value) if isinstance(include_value, list) else []
|
||||
)
|
||||
if "reasoning.encrypted_content" not in include:
|
||||
include.append("reasoning.encrypted_content")
|
||||
request["include"] = include
|
||||
|
|
@ -146,10 +175,12 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
"reasoning",
|
||||
"previous_response_id",
|
||||
"truncation",
|
||||
*(("text",) if _has_structured_output_format(request.get("text")) else ()),
|
||||
}
|
||||
|
||||
filtered: Final = {k: v for k, v in request.items() if k in allowed_keys}
|
||||
service_tier: Final = _CHATGPT_SERVICE_TIERS.get(request.get("service_tier"))
|
||||
requested_tier: Final = request.get("service_tier")
|
||||
service_tier: Final = _CHATGPT_SERVICE_TIERS.get(requested_tier) if isinstance(requested_tier, str) else None
|
||||
if service_tier is not None:
|
||||
filtered["service_tier"] = service_tier
|
||||
return filtered
|
||||
|
|
|
|||
|
|
@ -266,6 +266,55 @@ class TestChatGPTResponsesAPITransformation:
|
|||
"function": {"name": "hello"},
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text_param",
|
||||
[
|
||||
{
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "verdict",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"block": {"type": "boolean"}},
|
||||
"required": ["block"],
|
||||
},
|
||||
}
|
||||
},
|
||||
{"format": {"type": "json_object"}},
|
||||
],
|
||||
)
|
||||
def test_chatgpt_preserves_structured_output_text(self, text_param):
|
||||
config = ChatGPTResponsesAPIConfig()
|
||||
request = config.transform_responses_api_request(
|
||||
model="chatgpt/gpt-5.3-codex",
|
||||
input="hi",
|
||||
response_api_optional_request_params={"text": text_param},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert request["text"] == text_param
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text_param",
|
||||
[
|
||||
{"format": {"type": "text"}},
|
||||
{"verbosity": "low"},
|
||||
"not-a-dict",
|
||||
],
|
||||
)
|
||||
def test_chatgpt_drops_non_structured_text_param(self, text_param):
|
||||
config = ChatGPTResponsesAPIConfig()
|
||||
request = config.transform_responses_api_request(
|
||||
model="chatgpt/gpt-5.3-codex",
|
||||
input="hi",
|
||||
response_api_optional_request_params={"text": text_param},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert "text" not in request
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model_name", "response_model"),
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue