fix(responses): carry dict-valued reasoning_effort and keep the frame type on websocket defaults

A deployment whose reasoning_effort is an object is copied through as
reasoning the way the HTTP mapper does it instead of being dropped, and
the relay re-asserts the response.create frame type after merging
extra_body so a type key inside it can never replace it. The lazy
OpenAPI snapshot goes back to main: the earlier regeneration came from a
Python 3.14 interpreter dedenting docstrings, which CI on 3.12 rejects
This commit is contained in:
mateo-berri 2026-09-18 15:13:07 -07:00
parent 4a951847bb
commit 417a88daed
4 changed files with 51 additions and 11 deletions

View file

@ -19394,7 +19394,7 @@
}
}
},
"description": "\nUnified rate-limit error.\n\nEvery rate-limit condition surfaced by litellm \u2014 whether it originated from\nan upstream LLM provider, a vendor batch endpoint, or one of litellm's own\nproxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\nmax-iterations, etc.) \u2014 is raised as an instance of this class.\n\nThe :attr:`category` attribute lets callers distinguish the source. See\n:class:`RateLimitErrorCategory` for the available values.\n"
"description": "\n Unified rate-limit error.\n\n Every rate-limit condition surfaced by litellm \u2014 whether it originated from\n an upstream LLM provider, a vendor batch endpoint, or one of litellm's own\n proxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\n max-iterations, etc.) \u2014 is raised as an instance of this class.\n\n The :attr:`category` attribute lets callers distinguish the source. See\n :class:`RateLimitErrorCategory` for the available values.\n "
},
"500": {
"content": {

View file

@ -2262,24 +2262,28 @@ def _build_litellm_metadata_for_ws(kwargs: dict) -> dict:
return metadata
_EXTRA_BODY_ADAPTER: Final = TypeAdapter(dict[str, object] | None)
_JSON_OBJECT_ADAPTER: Final = TypeAdapter(dict[str, object] | None)
def _deployment_reasoning_default(kwargs: Mapping[str, object]) -> Reasoning | dict[str, object] | None:
if kwargs.get("reasoning") is not None:
return None
reasoning_effort: Final = kwargs.get("reasoning_effort")
if isinstance(reasoning_effort, str):
return LiteLLMResponsesTransformationHandler()._map_reasoning_effort(reasoning_effort)
return _JSON_OBJECT_ADAPTER.validate_python(reasoning_effort) if isinstance(reasoning_effort, Mapping) else None
def _build_responses_websocket_request_defaults(kwargs: Mapping[str, object]) -> ResponsesWebSocketRequestDefaults:
reasoning_effort: Final = kwargs.get("reasoning_effort")
mapped_reasoning: Final = (
LiteLLMResponsesTransformationHandler()._map_reasoning_effort(reasoning_effort)
if kwargs.get("reasoning") is None and isinstance(reasoning_effort, str)
else None
)
default_reasoning: Final = _deployment_reasoning_default(kwargs)
candidate_params: Final[dict[str, object]] = {
**kwargs,
**({"reasoning": mapped_reasoning} if mapped_reasoning is not None else {}),
**({"reasoning": default_reasoning} if default_reasoning is not None else {}),
}
fill_missing: Final = ResponsesAPIRequestUtils.get_requested_response_api_optional_param(candidate_params)
return ResponsesWebSocketRequestDefaults(
fill_missing=MappingProxyType(dict(fill_missing)),
overrides=MappingProxyType(_EXTRA_BODY_ADAPTER.validate_python(kwargs.get("extra_body")) or {}),
overrides=MappingProxyType(_JSON_OBJECT_ADAPTER.validate_python(kwargs.get("extra_body")) or {}),
)

View file

@ -1883,7 +1883,7 @@ class ResponsesWebSocketStreaming:
nested: Final = msg_obj.get("response")
if _is_json_object(nested):
return {**msg_obj, "response": self.request_defaults.merged_into(nested)}
return self.request_defaults.merged_into(msg_obj)
return {**self.request_defaults.merged_into(msg_obj), "type": msg_obj["type"]}
async def _mask_response_create(self, message: str) -> str:
"""

View file

@ -1252,6 +1252,42 @@ class TestNativeWebSocketDeploymentDefaults:
assert dict(defaults.fill_missing) == {"reasoning": {"effort": "low"}}
assert dict(defaults.overrides) == {}
def test_builder_copies_dict_valued_reasoning_effort_like_the_http_path(self):
from litellm.responses.main import _build_responses_websocket_request_defaults
defaults = _build_responses_websocket_request_defaults(
{"model": "gpt-5-pro", "reasoning_effort": {"effort": "xhigh", "summary": "auto"}}
)
assert dict(defaults.fill_missing) == {"reasoning": {"effort": "xhigh", "summary": "auto"}}
@pytest.mark.asyncio
async def test_extra_body_type_key_never_replaces_the_frame_type(self):
from types import MappingProxyType
from litellm.types.responses.streaming_websocket import ResponsesWebSocketRequestDefaults
handler = _make_streaming(
authorized_model="gpt-5-pro",
request_defaults=ResponsesWebSocketRequestDefaults(
fill_missing=MappingProxyType({}),
overrides=MappingProxyType({"type": "session.update", "provider_default": "configured"}),
),
)
forwarded = json.loads(
await handler._mask_response_create(
json.dumps({"type": "response.create", "model": "gpt-5-pro", "input": "hi"})
)
)
assert forwarded == {
"type": "response.create",
"model": "gpt-5-pro",
"input": "hi",
"provider_default": "configured",
}
@pytest.mark.asyncio
async def test_flat_frame_gets_defaults_client_keys_win_extra_body_overrides(self):
handler = _make_streaming(authorized_model="gpt-5-pro", request_defaults=_deployment_defaults())