From f68ec184301d7a0486476693c77cecc6f72ca437 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:00:27 -0700 Subject: [PATCH] fix(proxy): print container subclasses and SDK models in Responses stream frames the way base did --- .../common_utils/responses_stream_errors.py | 34 ++++++------ .../proxy_server/test_streaming_helpers.py | 53 +++++++++++++++++++ 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/common_utils/responses_stream_errors.py b/litellm/proxy/common_utils/responses_stream_errors.py index 8916c0be8a9..f4acb2858fb 100644 --- a/litellm/proxy/common_utils/responses_stream_errors.py +++ b/litellm/proxy/common_utils/responses_stream_errors.py @@ -1,5 +1,6 @@ import json import time +from collections import Counter, OrderedDict, defaultdict from collections.abc import Iterable, Mapping, Sequence from enum import Enum from http import HTTPStatus @@ -7,7 +8,7 @@ from types import MappingProxyType from typing import Final from pydantic import BaseModel, ConfigDict, ValidationError, field_validator -from typing_extensions import TypeIs # noqa: TID251 # exact-type checks keep subclasses out, isinstance cannot +from typing_extensions import TypeIs # noqa: TID251 # the guards narrow hook values to object items, isinstance cannot from litellm._logging import redact_internal_details_from_client_message from litellm._uuid import uuid @@ -77,12 +78,19 @@ class _BaseForm(Enum): DUMPED = "dumped" -def _is_plain_dict(value: object) -> TypeIs[Mapping[object, object]]: # guard-ok: exact type check - return type(value) is dict +def _prints_every_item(kind: type[object]) -> bool: + repr_owner: Final = next((owner for owner in kind.__mro__ if "__repr__" in owner.__dict__), object) + return kind.__str__ is object.__str__ and repr_owner in (dict, list, tuple, OrderedDict, defaultdict, Counter) -def _is_plain_sequence(value: object) -> TypeIs[Sequence[object]]: # guard-ok: exact type check - return type(value) in (list, tuple) +def _is_shown_mapping(value: object) -> TypeIs[Mapping[object, object]]: # guard-ok: narrows dicts to object items + kind: Final = type(value) + return isinstance(value, dict) and _prints_every_item(kind) + + +def _is_shown_sequence(value: object) -> TypeIs[Sequence[object]]: # guard-ok: narrows lists and tuples to object items + kind: Final = type(value) + return isinstance(value, (list, tuple)) and _prints_every_item(kind) def _is_proxy(value: object) -> TypeIs[Mapping[object, object]]: # guard-ok: mappingproxy cannot be subclassed @@ -93,13 +101,9 @@ def _prints_as_dict(proxy: Mapping[object, object]) -> bool: return repr(proxy) == f"mappingproxy({dict(proxy)!r})" -def _prints_its_fields(model: BaseModel) -> bool: +def _repr_prints_its_fields(model: BaseModel) -> bool: kind: Final = type(model) - return ( - kind.__repr__ is BaseModel.__repr__ - and kind.__str__ is BaseModel.__str__ - and kind.__repr_str__ is BaseModel.__repr_str__ - ) + return kind.__repr__ is BaseModel.__repr__ and kind.__repr_str__ is BaseModel.__repr_str__ def _str_skips_its_repr(model: BaseModel) -> bool: @@ -140,11 +144,11 @@ def _json_key(key: object, form: _BaseForm) -> object: def _expansion(value: object, form: _BaseForm) -> tuple[Iterable[tuple[object, object]], _BaseForm] | None: - if _is_plain_dict(value): + if _is_shown_mapping(value): return value.items(), form if _is_proxy(value) and _prints_as_dict(value): return value.items(), _BaseForm.PRINTED - if isinstance(value, BaseModel) and _prints_its_fields(value): + if form is _BaseForm.PRINTED and isinstance(value, BaseModel) and _repr_prints_its_fields(value): return value.__repr_args__(), _BaseForm.PRINTED return None @@ -156,7 +160,7 @@ def _json_safe(value: object, form: _BaseForm, path: frozenset[int]) -> object: return _text(value, form) inner_path: Final = path | frozenset((id(value),)) try: - if _is_plain_sequence(value): + if _is_shown_sequence(value): shown_items: Final = [_json_safe(item, form, inner_path) for item in value] return [item for item in shown_items if item is not _DROPPED] expansion: Final = _expansion(value, form) @@ -170,7 +174,7 @@ def _json_safe(value: object, form: _BaseForm, path: frozenset[int]) -> object: def responses_chunk_json(chunk: object) -> str | None: - if not _is_plain_dict(chunk) and not _is_proxy(chunk): + if not _is_shown_mapping(chunk) and not _is_proxy(chunk): return None shown: Final = _json_safe(chunk, _BaseForm.PRINTED, frozenset()) return None if isinstance(shown, str) or shown is _DROPPED else json.dumps(shown) diff --git a/tests/unit/proxy/proxy_server/test_streaming_helpers.py b/tests/unit/proxy/proxy_server/test_streaming_helpers.py index a2045995093..1b900d78336 100644 --- a/tests/unit/proxy/proxy_server/test_streaming_helpers.py +++ b/tests/unit/proxy/proxy_server/test_streaming_helpers.py @@ -18,6 +18,7 @@ from __future__ import annotations import asyncio import functools import json +from collections import OrderedDict from collections.abc import AsyncIterator, Callable, Iterable, Mapping from datetime import datetime, timezone from enum import Enum @@ -31,6 +32,7 @@ import pytest from fastapi import HTTPException, Response from fastapi.responses import JSONResponse, StreamingResponse from openai import APIError as OpenAIAPIError +from openai.types.responses import ResponseOutputMessage, ResponseOutputText from pydantic import BaseModel, Field import litellm @@ -333,6 +335,28 @@ def test_responses_error_event_never_shows_what_a_nested_value_hides_from_its_st assert "hook-canary" not in frame +class _RigTuple(tuple[object, ...]): + pass + + +@pytest.mark.parametrize( + "detail", + [ + OrderedDict(member=_MaskedStrModel(token="hook-canary")), + _RigTuple((_MaskedStrModel(token="hook-canary"),)), + _HookOutputItem(id="msg_nested", type="message"), + ], + ids=["dict_subclass", "tuple_subclass", "model"], +) +def test_responses_error_event_sends_a_detail_the_way_base_json_dumps_did(detail: object) -> None: + frame: Final = ResponsesStreamErrorState().format_error( + {"message": "blocked", "provider_specific_fields": {"detail": detail}} + ) + + payload: Final = json.loads(frame.split("data: ", 1)[1]) + assert payload["error"]["provider_specific_fields"]["detail"] == json.loads(json.dumps(detail, default=str)) + + class _RigColor(Enum): RED = 1 @@ -406,6 +430,35 @@ def test_responses_sse_chunk_a_hook_dumped_from_an_event_streams_as_that_event( assert json.loads(frame.removeprefix("data: ")) == json.loads(_DUMPED_DELTA.model_dump_json()) +_COMPLETED_WITH_SDK_OUTPUT: Final = ResponseCompletedEvent( + type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + sequence_number=4, + response=ResponsesAPIResponse( + id="resp_1", + object="response", + created_at=0, + model="gpt-x", + status="completed", + output=[ + ResponseOutputMessage( + id="msg_1", + type="message", + role="assistant", + status="completed", + content=[ResponseOutputText(type="output_text", text="hi", annotations=[])], + ) + ], + ), +) + + +def test_responses_sse_chunk_a_hook_copied_from_an_event_streams_as_that_event() -> None: + frame: Final = _format_responses_sse_chunk(chunk=dict(_COMPLETED_WITH_SDK_OUTPUT)) + + assert isinstance(frame, str) + assert json.loads(frame.removeprefix("data: ")) == json.loads(_COMPLETED_WITH_SDK_OUTPUT.model_dump_json()) + + @pytest.mark.parametrize("mapping", [dict, MappingProxyType], ids=["dict", "mapping_proxy"]) def test_responses_stream_frames_turn_mapping_keys_json_cannot_take_into_strings( mapping: Callable[[Mapping[object, object]], Mapping[object, object]],