fix(responses): keep declared shape when Responses API payload validation fails

This commit is contained in:
Devin AI 2026-07-28 15:53:25 +00:00
parent daf22ec871
commit eaa0abbf07
8 changed files with 269 additions and 37 deletions

View file

@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Coroutine, Optional, Union
from typing_extensions import TypedDict
from litellm.responses.response_construction import construct_responses_api_response
from litellm.types.llms.openai import ResponsesAPIResponse
if TYPE_CHECKING:
@ -51,10 +52,7 @@ class ResponsesToCompletionBridgeHandler:
if isinstance(response_obj, ResponsesAPIResponse):
response = response_obj
elif isinstance(response_obj, dict):
try:
response = ResponsesAPIResponse(**response_obj)
except Exception:
response = ResponsesAPIResponse.model_construct(**response_obj)
response = construct_responses_api_response(response_obj)
else:
raise ValueError("Unexpected responses stream payload")

View file

@ -7,6 +7,7 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo
)
from litellm.llms.openai.common_utils import OpenAIError
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.responses.response_construction import construct_responses_api_response
from litellm.responses.sse_output_recovery import (
parse_sse_json_chunk,
record_output_item_chunk,
@ -206,10 +207,7 @@ class ChatGPTResponsesAPIConfig(OpenAIResponsesAPIConfig):
response_payload["output"] = [item for _, item in sorted(streamed_output_items.items())]
if "created_at" in response_payload:
response_payload["created_at"] = _safe_convert_created_field(response_payload["created_at"])
try:
return ResponsesAPIResponse(**response_payload)
except Exception:
return ResponsesAPIResponse.model_construct(**response_payload)
return construct_responses_api_response(response_payload)
def _extract_error_message(self, parsed_chunk: Dict[str, Any]) -> Optional[str]:
error_obj = parsed_chunk.get("error") or (parsed_chunk.get("response") or {}).get("error")

View file

@ -12,6 +12,7 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo
)
from litellm.llms.openai.common_utils import OpenAIError
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.responses.response_construction import construct_responses_api_response
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import (
ResponseAPIUsage,
@ -216,11 +217,7 @@ class ManusResponsesAPIConfig(OpenAIResponsesAPIConfig):
# This allows the response object to be created even when the API doesn't return an id
raw_response_json["id"] = f"unknown-{uuid.uuid4().hex[:8]}"
try:
response = ResponsesAPIResponse(**raw_response_json)
except Exception:
verbose_logger.debug(f"Error constructing ResponsesAPIResponse: {raw_response_json}, using model_construct")
response = ResponsesAPIResponse.model_construct(**raw_response_json)
response = construct_responses_api_response(raw_response_json)
# Store processed headers in additional_headers so they get returned to the client
response._hidden_params["additional_headers"] = processed_headers
@ -304,11 +301,7 @@ class ManusResponsesAPIConfig(OpenAIResponsesAPIConfig):
# Generate a placeholder id for failed responses
raw_response_json["id"] = f"unknown-{uuid.uuid4().hex[:8]}"
try:
response = ResponsesAPIResponse(**raw_response_json)
except Exception:
verbose_logger.debug(f"Error constructing ResponsesAPIResponse: {raw_response_json}, using model_construct")
response = ResponsesAPIResponse.model_construct(**raw_response_json)
response = construct_responses_api_response(raw_response_json)
# Store processed headers in additional_headers so they get returned to the client
response._hidden_params["additional_headers"] = processed_headers

View file

@ -12,6 +12,10 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo
_safe_convert_created_field,
)
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
from litellm.responses.response_construction import (
construct_responses_api_response,
construct_responses_api_stream_event,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import *
from litellm.types.responses.main import *
@ -279,11 +283,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
raise OpenAIError(message=raw_response.text, status_code=raw_response.status_code)
raw_response_headers = dict(raw_response.headers)
processed_headers = process_response_headers(raw_response_headers)
try:
response = ResponsesAPIResponse(**raw_response_json)
except Exception:
verbose_logger.debug(f"Error constructing ResponsesAPIResponse: {raw_response_json}, using model_construct")
response = ResponsesAPIResponse.model_construct(**raw_response_json)
response = construct_responses_api_response(raw_response_json)
# Store processed headers in additional_headers so they get returned to the client
response._hidden_params["additional_headers"] = processed_headers
@ -349,7 +349,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
event_pydantic_model.__name__,
parsed_chunk,
)
return event_pydantic_model.model_construct(**parsed_chunk)
return construct_responses_api_stream_event(event_pydantic_model, parsed_chunk)
@staticmethod
def get_event_model_class(event_type: str) -> Any:
@ -646,11 +646,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
raw_response_headers = dict(raw_response.headers)
processed_headers = process_response_headers(raw_response_headers)
try:
response = ResponsesAPIResponse(**raw_response_json)
except Exception:
verbose_logger.debug(f"Error constructing ResponsesAPIResponse: {raw_response_json}, using model_construct")
response = ResponsesAPIResponse.model_construct(**raw_response_json)
response = construct_responses_api_response(raw_response_json)
response._hidden_params["additional_headers"] = processed_headers
response._hidden_params["headers"] = raw_response_headers

View file

@ -20,6 +20,7 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo
)
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.responses.response_construction import construct_responses_api_response
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import (
ResponseInputParam,
@ -259,12 +260,7 @@ class VolcEngineResponsesAPIConfig(OpenAIResponsesAPIConfig):
raw_response_headers = dict(raw_response.headers)
processed_headers = process_response_headers(raw_response_headers)
try:
response = ResponsesAPIResponse.model_validate(raw_response_json)
except Exception:
verbose_logger.debug("Volcengine Responses API: falling back to model_construct for response parsing.")
construct_response: Callable[..., ResponsesAPIResponse] = ResponsesAPIResponse.model_construct
response = construct_response(**raw_response_json)
response = construct_responses_api_response(raw_response_json)
response._hidden_params["additional_headers"] = processed_headers
response._hidden_params["headers"] = raw_response_headers

View file

@ -0,0 +1,109 @@
"""
Best-effort construction of Responses API objects from raw provider payloads.
Providers deviate from the OpenAI Responses spec often enough that strict
pydantic validation fails (missing ``output``, a ``usage`` block with unexpected
fields, ...). Callers used to fall back to ``model_construct``, which skips
validation *and* nested model coercion, so ``response.usage`` stayed a plain
dict and ``response.output`` could be missing entirely. Everything downstream
reads those attributes as declared (``ResponsesAPIResponse.usage`` is a
``ResponseAPIUsage``, ``.output`` is a list), so the fallback produced objects
that violate their own type and blew up with ``AttributeError`` mid-stream.
These helpers keep the declared shape intact even when validation fails, so the
fallback stays a degraded-data path instead of a crash path.
"""
from collections.abc import Callable, Mapping
from pydantic import TypeAdapter, ValidationError
from litellm._logging import verbose_logger
from litellm.types.llms.base import BaseLiteLLMOpenAIResponseObject
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
_FIELD_MAPPING_ADAPTER = TypeAdapter(dict[str, object])
def construct_responses_api_response(payload: Mapping[str, object]) -> ResponsesAPIResponse:
"""Build a ``ResponsesAPIResponse``, validating when possible.
On validation failure, fields required by the model are defaulted and
``usage`` is coerced, so attribute access on the result behaves as declared.
"""
try:
return ResponsesAPIResponse.model_validate(dict(payload))
except ValidationError:
verbose_logger.debug(
"Responses API: validation failed for payload %s, falling back to model_construct",
payload,
)
output = payload.get("output")
construct: Callable[..., ResponsesAPIResponse] = ResponsesAPIResponse.model_construct
return construct(
**{
**payload,
"output": output if isinstance(output, list) else [],
"usage": _construct_usage(payload.get("usage")),
}
)
def construct_responses_api_stream_event(
event_model: type[BaseLiteLLMOpenAIResponseObject],
payload: Mapping[str, object],
) -> BaseLiteLLMOpenAIResponseObject:
"""Build a Responses API streaming event without validation.
Terminal events (``response.completed`` / ``.failed`` / ``.incomplete``)
declare a ``ResponsesAPIResponse``; ``model_construct`` alone would leave it
as the raw dict, so construct that nested object explicitly.
"""
construct: Callable[..., BaseLiteLLMOpenAIResponseObject] = event_model.model_construct
response_payload = _as_field_mapping(payload.get("response"))
if response_payload is None:
return construct(**payload)
return construct(**{**payload, "response": construct_responses_api_response(response_payload)})
def _construct_usage(usage: object) -> ResponseAPIUsage | None:
"""Coerce a raw ``usage`` payload into ``ResponseAPIUsage``, or drop it."""
if usage is None or isinstance(usage, ResponseAPIUsage):
return usage
usage_fields = _as_field_mapping(usage)
if usage_fields is None:
return None
input_tokens = _as_token_count(usage_fields.get("input_tokens"))
output_tokens = _as_token_count(usage_fields.get("output_tokens"))
try:
return ResponseAPIUsage.model_validate(
{
**usage_fields,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": _as_token_count(usage_fields.get("total_tokens")) or input_tokens + output_tokens,
}
)
except ValidationError:
verbose_logger.debug("Responses API: unparseable usage payload %s, dropping it", usage_fields)
return None
def _as_field_mapping(value: object) -> Mapping[str, object] | None:
"""Validate an untyped payload into a string-keyed field mapping."""
try:
return _FIELD_MAPPING_ADAPTER.validate_python(value)
except ValidationError:
return None
def _as_token_count(value: object) -> int:
if isinstance(value, bool):
return 0
if isinstance(value, int):
return value
if isinstance(value, str) and value.isdigit():
return int(value)
return 0

View file

@ -295,7 +295,7 @@ class TestVolcengineResponsesAPITransformation:
assert event.response.output == []
assert event.response.created_at == 0
def test_transform_response_api_response_falls_back_to_model_construct(self):
def test_transform_response_api_response_keeps_declared_shape_on_bad_payload(self):
config = VolcEngineResponsesAPIConfig()
http_response = httpx.Response(
status_code=200,
@ -315,7 +315,7 @@ class TestVolcengineResponsesAPITransformation:
)
assert result.id == "resp_fallback"
assert result.output == "not-a-list"
assert result.output == []
assert result._hidden_params["headers"].get("x-test") == "1"
def test_transform_delete_response_api_request_builds_url(self):

View file

@ -0,0 +1,142 @@
"""
Regression tests for GitHub issue #34754.
Providers whose Responses API payloads fail strict validation used to be built
with bare ``model_construct``, leaving ``event.response`` as a raw dict and
``response.usage`` unparsed. Consumers read those as declared, so streaming
requests died with ``AttributeError: 'dict' object has no attribute 'usage'``
(HTTP 500 mid-stream) or silently dropped the SpendLogs entry.
"""
import datetime
import pytest
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.responses.response_construction import construct_responses_api_response
from litellm.router import Router
from litellm.types.llms.openai import (
ResponseAPIUsage,
ResponseCompletedEvent,
ResponsesAPIResponse,
)
# `response.completed` payload that fails validation: `output` is required by
# ResponsesAPIResponse but plenty of providers omit it.
DICT_FORMAT_COMPLETED_CHUNK = {
"type": "response.completed",
"response": {
"id": "resp_34754",
"created_at": 1700000000,
"status": "completed",
"usage": {"input_tokens": 11, "output_tokens": 7, "total_tokens": 18},
},
}
class _StubStreamingIterator:
def __init__(self, completed_response: object) -> None:
self.completed_response = completed_response
def _transform_completed_chunk() -> ResponseCompletedEvent:
event = OpenAIResponsesAPIConfig().transform_streaming_response(
model="gpt-5",
parsed_chunk=DICT_FORMAT_COMPLETED_CHUNK,
logging_obj=None,
)
assert isinstance(event, ResponseCompletedEvent)
return event
def test_unvalidatable_payload_keeps_declared_shape():
response = construct_responses_api_response(DICT_FORMAT_COMPLETED_CHUNK["response"])
assert isinstance(response, ResponsesAPIResponse)
assert isinstance(response.usage, ResponseAPIUsage)
assert response.usage.input_tokens == 11
assert response.usage.total_tokens == 18
assert response.output == []
assert response.id == "resp_34754"
def test_usage_total_tokens_derived_when_missing():
response = construct_responses_api_response(
{"id": "resp_1", "created_at": 1, "usage": {"input_tokens": 3, "output_tokens": 4}}
)
assert isinstance(response.usage, ResponseAPIUsage)
assert response.usage.total_tokens == 7
def test_unparseable_usage_is_dropped_not_left_as_dict():
response = construct_responses_api_response({"id": "resp_1", "created_at": 1, "usage": "not-a-usage-block"})
assert response.usage is None
def test_valid_payload_is_validated_not_constructed():
response = construct_responses_api_response(
{
"id": "resp_1",
"created_at": 1,
"output": [],
"usage": {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3},
}
)
assert isinstance(response.usage, ResponseAPIUsage)
assert response.model_fields_set # validated instance, not a bare model_construct
def test_streaming_event_response_is_not_a_dict():
event = _transform_completed_chunk()
assert isinstance(event.response, ResponsesAPIResponse)
assert isinstance(event.response.usage, ResponseAPIUsage)
def test_router_extracts_partial_usage_from_dict_format_response():
"""Path 1: router fallback usage extraction returned .response.usage -> HTTP 500."""
event = _transform_completed_chunk()
usage = Router._extract_partial_responses_usage(_StubStreamingIterator(event))
assert isinstance(usage, ResponseAPIUsage)
assert (usage.input_tokens, usage.output_tokens, usage.total_tokens) == (11, 7, 18)
def test_streaming_logging_transforms_usage_for_dict_format_response():
"""Path 2: logging silently dropped the SpendLogs entry for these responses."""
event = _transform_completed_chunk()
logging_obj = LiteLLMLoggingObj(
model="gpt-5",
messages=[],
stream=True,
call_type="aresponses",
start_time=datetime.datetime.now(),
litellm_call_id="34754",
function_id="34754",
)
assembled = logging_obj._get_assembled_streaming_response(
result=event,
start_time=datetime.datetime.now(),
end_time=datetime.datetime.now(),
is_async=False,
streaming_chunks=[],
)
assert assembled is not None
chat_usage = assembled.usage # pyright: ignore[reportAttributeAccessIssue] # set as a dict for serialization
assert (chat_usage["prompt_tokens"], chat_usage["completion_tokens"], chat_usage["total_tokens"]) == (11, 7, 18)
@pytest.mark.parametrize("output_value", [None, "not-a-list"])
def test_output_always_iterable_for_chat_bridge(output_value):
"""Path 3: the responses -> chat bridge iterates response.output directly."""
response = construct_responses_api_response({"id": "resp_1", "created_at": 1, "output": output_value})
assert response.output == []
assert list(response.output) == []