mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-09 08:18:12 +00:00
fix(proxy): encode mappings and models nested in Responses stream chunks as JSON objects
This commit is contained in:
parent
81dc6c1175
commit
b8bbb4039e
3 changed files with 55 additions and 3 deletions
|
|
@ -6,6 +6,7 @@ from types import MappingProxyType
|
|||
from typing import Final
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
|
||||
from typing_extensions import TypeIs # noqa: TID251 # TypeIs reaches typing only on 3.13
|
||||
|
||||
from litellm._logging import redact_internal_details_from_client_message
|
||||
from litellm._uuid import uuid
|
||||
|
|
@ -67,6 +68,20 @@ class _ErrorPayload(LiteLLMBaseModel):
|
|||
return {"message": self.error} if isinstance(self.error, str) else self.error
|
||||
|
||||
|
||||
def _is_mapping(
|
||||
value: object,
|
||||
) -> TypeIs[Mapping[object, object]]: # guard-ok: isinstance decides it, keys and values stay object
|
||||
return isinstance(value, Mapping)
|
||||
|
||||
|
||||
def jsonable_stream_value(value: object) -> object:
|
||||
if _is_mapping(value):
|
||||
return {**value}
|
||||
if isinstance(value, BaseModel):
|
||||
return value.model_dump()
|
||||
return str(value)
|
||||
|
||||
|
||||
def sse_error_object(data: str | bytes) -> Mapping[str, object] | None:
|
||||
try:
|
||||
return _ErrorPayload.model_validate_json(data).error_object()
|
||||
|
|
@ -191,7 +206,7 @@ class ResponsesStreamErrorState:
|
|||
"sequence_number": self.sequence_number + 1,
|
||||
"error": error,
|
||||
},
|
||||
default=str,
|
||||
default=jsonable_stream_value,
|
||||
)
|
||||
return f"event: error\ndata: {payload}\n\n"
|
||||
|
||||
|
|
|
|||
|
|
@ -539,7 +539,7 @@ from litellm.proxy.common_utils.periodic_reload_schedule import (
|
|||
)
|
||||
from litellm.proxy.common_utils.proxy_state import ProxyState
|
||||
from litellm.proxy.common_utils.reset_budget_job import ResetBudgetJob
|
||||
from litellm.proxy.common_utils.responses_stream_errors import ResponsesStreamErrorState
|
||||
from litellm.proxy.common_utils.responses_stream_errors import ResponsesStreamErrorState, jsonable_stream_value
|
||||
from litellm.proxy.common_utils.scheduled_job_stagger import (
|
||||
apply_scheduled_job_stagger,
|
||||
attach_job_timing_logger,
|
||||
|
|
@ -10204,7 +10204,7 @@ def _format_streaming_sse_chunk(chunk: object) -> str | bytes:
|
|||
|
||||
def _format_responses_sse_chunk(chunk: object) -> str | bytes:
|
||||
if isinstance(chunk, Mapping):
|
||||
return f"data: {json.dumps({**chunk}, default=str)}\n\n"
|
||||
return f"data: {json.dumps(chunk, default=jsonable_stream_value)}\n\n"
|
||||
return _format_streaming_sse_chunk(chunk=chunk)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ from litellm.proxy.proxy_server import (
|
|||
_apply_streaming_chunk_hooks,
|
||||
_fast_serialize_simple_model_response_stream,
|
||||
_format_fallback_metadata_sse_event,
|
||||
_format_responses_sse_chunk,
|
||||
_format_streaming_sse_chunk,
|
||||
_get_client_requested_model_for_streaming,
|
||||
_get_streaming_fallback_metadata,
|
||||
|
|
@ -182,6 +183,42 @@ async def test_responses_stream_sends_no_failure_event_after_an_upstream_error_e
|
|||
assert tuple(frame.split("\n", 1)[0] for frame in frames) == ("event: response.created", "event: error")
|
||||
assert json.loads(frames[-1].split("data: ", 1)[1])["message"] == "upstream failed"
|
||||
|
||||
|
||||
class _HookOutputItem(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"item",
|
||||
[
|
||||
MappingProxyType({"id": "msg_nested", "type": "message"}),
|
||||
_HookOutputItem(id="msg_nested", type="message"),
|
||||
],
|
||||
ids=["mapping_proxy", "model"],
|
||||
)
|
||||
def test_responses_sse_chunk_encodes_values_nested_in_a_hook_chunk_as_json_objects(
|
||||
item: Mapping[str, object] | BaseModel,
|
||||
) -> None:
|
||||
frame: Final = _format_responses_sse_chunk(
|
||||
chunk=MappingProxyType({"type": "response.output_item.added", "output_index": 0, "item": item})
|
||||
)
|
||||
|
||||
assert isinstance(frame, str)
|
||||
assert json.loads(frame.removeprefix("data: "))["item"] == {"id": "msg_nested", "type": "message"}
|
||||
|
||||
|
||||
def test_responses_error_event_encodes_nested_error_fields_as_json_objects() -> None:
|
||||
frame: Final = ResponsesStreamErrorState().format_error(
|
||||
{
|
||||
"message": "overloaded",
|
||||
"provider_specific_fields": MappingProxyType({"upstream": MappingProxyType({"type": "overloaded_error"})}),
|
||||
}
|
||||
)
|
||||
|
||||
payload: Final = json.loads(frame.split("data: ", 1)[1])
|
||||
assert payload["error"]["provider_specific_fields"] == {"upstream": {"type": "overloaded_error"}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("guardrail_error", "error_object"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue