fix(proxy): print container subclasses and SDK models in Responses stream frames the way base did

This commit is contained in:
mateo-berri 2026-10-30 18:00:27 -07:00
parent 6b851e9371
commit f68ec18430
2 changed files with 72 additions and 15 deletions

View file

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

View file

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