mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-09 08:18:12 +00:00
fix(proxy): print container subclasses and SDK models in Responses stream frames the way base did
This commit is contained in:
parent
6b851e9371
commit
f68ec18430
2 changed files with 72 additions and 15 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue