diff --git a/litellm/responses/litellm_completion_transformation/handler.py b/litellm/responses/litellm_completion_transformation/handler.py index 802ed6bf9c5..870490034cf 100644 --- a/litellm/responses/litellm_completion_transformation/handler.py +++ b/litellm/responses/litellm_completion_transformation/handler.py @@ -8,8 +8,10 @@ from types import MappingProxyType from typing import Final from pydantic import TypeAdapter +from typing_extensions import ReadOnly, TypedDict import litellm +from litellm.litellm_core_utils.asyncify import run_async_function from litellm.litellm_core_utils.core_helpers import drops_unsupported_params from litellm.litellm_core_utils.model_param_helper import embeds_thought_signature_in_tool_call_id from litellm.responses.additional_tools import HoistedAdditionalTools, hoist_additional_tools @@ -33,6 +35,7 @@ from litellm.types.llms.openai import ( from litellm.types.utils import ModelResponse _REQUEST_KWARGS_ADAPTER: Final = TypeAdapter(dict[str, object]) +_METADATA_ADAPTER: Final = TypeAdapter[dict[object, object] | None](dict[object, object] | None) _BRIDGE_TRANSFORM_KEYWORDS: Final = frozenset({"caller_input", "names_non_string_fields"}) @@ -50,6 +53,14 @@ def _chat_verbosity(responses_api_request: Mapping[str, object]) -> Mapping[str, return {} +class _ContinuationFields(TypedDict, total=False): + previous_response_id: ReadOnly[str | None] + instructions: ReadOnly[str | None] + + +_CONTINUATION_FIELDS_ADAPTER: Final = TypeAdapter(_ContinuationFields) + + @dataclass(frozen=True, slots=True) class _BridgedRequest: input: str | ResponseInputParam @@ -85,7 +96,7 @@ class LiteLLMCompletionTransformationHandler: _is_async: bool = False, stream: bool | None = None, extra_headers: Mapping[str, object] | None = None, - **kwargs, + **kwargs: object, ) -> ( ResponsesAPIResponse | BaseResponsesAPIStreamingIterator @@ -96,7 +107,8 @@ class LiteLLMCompletionTransformationHandler: responses_api_request.get("tools"), drop_params=drops_unsupported_params(_REQUEST_KWARGS_ADAPTER.validate_python(kwargs).get("drop_params")), ) - previous_response_id: Final = responses_api_request.get("previous_response_id") + continuation: Final = _CONTINUATION_FIELDS_ADAPTER.validate_python(responses_api_request) + previous_response_id: Final = continuation.get("previous_response_id") if _is_async and previous_response_id and has_client_tool_search(hoisted.tools): return self._async_response_api_handler_after_earlier_searches( previous_response_client_ids, @@ -113,7 +125,7 @@ class LiteLLMCompletionTransformationHandler: bridged: Final = _bridged_request(hoisted, responses_api_request, ()) bridged_input: Final = bridged.input bridged_request: Final = bridged.responses_api_request - litellm_completion_request: Final[dict] = { + litellm_completion_request: Final[dict[str, object]] = { **LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request( model=model, input=bridged_input, @@ -137,8 +149,25 @@ class LiteLLMCompletionTransformationHandler: **kwargs, ) + sync_request: Final = ( + _REQUEST_KWARGS_ADAPTER.validate_python( + run_async_function( + LiteLLMCompletionResponsesConfig.async_responses_api_session_handler, + previous_response_id=previous_response_id, + litellm_completion_request=litellm_completion_request, + instructions=continuation.get("instructions"), + request_kwargs=kwargs, + previous_response_client_ids=previous_response_client_ids, + input_replaces_history=holds_compaction_summary(with_compaction_items_as_params(bridged_input)), + ) + ) + if previous_response_id + else litellm_completion_request + ) + completion_args: Final = {**kwargs, **sync_request, "_skip_responses_api_bridge": True} + litellm_completion_response: Final[ModelResponse | litellm.CustomStreamWrapper] = litellm.completion( - **{**kwargs, **litellm_completion_request, "_skip_responses_api_bridge": True}, + **completion_args, ) if isinstance(litellm_completion_response, ModelResponse): @@ -159,7 +188,7 @@ class LiteLLMCompletionTransformationHandler: request_input=bridged_input, responses_api_request=bridged_request, custom_llm_provider=custom_llm_provider, - litellm_metadata=kwargs.get("litellm_metadata", {}), + litellm_metadata=_METADATA_ADAPTER.validate_python(kwargs.get("litellm_metadata", {})), tool_call_provider_fields=not embeds_thought_signature_in_tool_call_id( _REQUEST_KWARGS_ADAPTER.validate_python(kwargs) ), @@ -222,7 +251,7 @@ class LiteLLMCompletionTransformationHandler: litellm_completion_request: dict, request_input: str | ResponseInputParam, responses_api_request: ResponsesAPIOptionalRequestParams, - **kwargs, + **kwargs: object, ) -> ResponsesAPIResponse | BaseResponsesAPIStreamingIterator: previous_response_id: Final[str | None] = responses_api_request.get("previous_response_id") if previous_response_id: @@ -236,8 +265,14 @@ class LiteLLMCompletionTransformationHandler: input_replaces_history=holds_compaction_summary(with_compaction_items_as_params(request_input)), ) + acompletion_args: Final = { + **kwargs, + **_REQUEST_KWARGS_ADAPTER.validate_python(litellm_completion_request), + "_skip_responses_api_bridge": True, + } + litellm_completion_response: Final[ModelResponse | litellm.CustomStreamWrapper] = await litellm.acompletion( - **{**kwargs, **litellm_completion_request, "_skip_responses_api_bridge": True}, + **acompletion_args, ) if isinstance(litellm_completion_response, ModelResponse): responses_api_response: Final[ResponsesAPIResponse] = ( @@ -257,7 +292,7 @@ class LiteLLMCompletionTransformationHandler: request_input=request_input, responses_api_request=responses_api_request, custom_llm_provider=litellm_completion_request.get("custom_llm_provider"), - litellm_metadata=kwargs.get("litellm_metadata", {}), + litellm_metadata=_METADATA_ADAPTER.validate_python(kwargs.get("litellm_metadata", {})), tool_call_provider_fields=not embeds_thought_signature_in_tool_call_id( _REQUEST_KWARGS_ADAPTER.validate_python(kwargs) ), diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 5b1964a4b5e..4ff68b626d4 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -1504,6 +1504,14 @@ class LiteLLMCompletionResponsesConfig: _messages: Final = litellm_completion_request.get("messages") or [] route: Final = CompletionRoute(model=model, custom_llm_provider=provider) session_history: Final = chat_completion_session.get("messages") or [] + if previous_response_id and not session_history: + raise litellm.BadRequestError( + message="No stored conversation history is available for previous_response_id. " + "Start a new conversation without previous_response_id and include the full history.", + model=model, + llm_provider=provider or "", + body={"param": "previous_response_id"}, + ) stored_messages: Final = LiteLLMCompletionResponsesConfig._with_compaction_blocks_the_route_takes( _instruction_messages(session_history) if input_replaces_history else session_history, route, @@ -1536,8 +1544,6 @@ class LiteLLMCompletionResponsesConfig: else _messages[:instructions_end] ) - # If session messages are empty (e.g., no database in test environment), - # we still need to process the new input messages # Store original _messages before combining for safety check original_new_messages: Final = _messages.copy() if _messages else [] diff --git a/tests/unit/responses/litellm_completion_transformation/test_handler.py b/tests/unit/responses/litellm_completion_transformation/test_handler.py index 4794f3701c5..0e32cdb9862 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_handler.py +++ b/tests/unit/responses/litellm_completion_transformation/test_handler.py @@ -22,7 +22,7 @@ import httpx import pytest import litellm -from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.responses.litellm_completion_transformation.handler import ( LiteLLMCompletionTransformationHandler, ) @@ -209,15 +209,17 @@ async def test_bridged_follow_up_turn_keeps_the_addressed_response_id_off_the_pr client: Final = AsyncHTTPHandler() client.client = httpx.AsyncClient(transport=httpx.MockTransport(provider)) - response = await litellm.aresponses( - model="azure_ai/claude-sonnet-4-6", - api_base="https://fake-foundry-resource.services.ai.azure.com", - api_key="fake-api-key", - input="Double it", - previous_response_id="resp_turn_one", - client=client, - **{ADDRESSED_RESPONSE_ID_FIELD: "resp_turn_one"}, - ) + stored_turns: Final = _StoredTurns((_stored_mcp_turn("resp_turn_one"),)) + with patch("litellm.proxy.proxy_server.prisma_client", SimpleNamespace(db=stored_turns)): + response = await litellm.aresponses( + model="azure_ai/claude-sonnet-4-6", + api_base="https://fake-foundry-resource.services.ai.azure.com", + api_key="fake-api-key", + input="Double it", + previous_response_id="resp_turn_one", + client=client, + **{ADDRESSED_RESPONSE_ID_FIELD: "resp_turn_one"}, + ) assert provider.request_body is not None, "the bridged turn never reached the provider" assert ADDRESSED_RESPONSE_ID_FIELD not in provider.request_body, ( @@ -275,6 +277,60 @@ class _StoredTurns: return [dict(row) for row in self.rows if row["session_id"] in sessions] +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("async_call", [False, True]) +@pytest.mark.asyncio +async def test_unknown_previous_response_fails_before_sending_a_contextless_completion( + async_call: bool, stream: bool +) -> None: + provider: Final = _RecordingAnthropicHandler( + { + "id": "chatcmpl-local", + "object": "chat.completion", + "created": 0, + "model": "test-model", + "choices": [ + {"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "Lost context"}} + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + ) + transport: Final = httpx.MockTransport(provider) + async_client: Final = AsyncHTTPHandler(transport=transport) + with httpx.Client(transport=transport) as sync_client: + try: + if async_call: + with pytest.raises(litellm.BadRequestError, match="previous_response_id") as raised: + await litellm.aresponses( + model="hosted_vllm/test-model", + input="What is my favorite color?", + previous_response_id="resp_does_not_exist", + stream=stream, + use_chat_completions_api=True, + client=async_client, + api_key="test", + api_base="https://provider.invalid/v1", + num_retries=0, + ) + else: + with pytest.raises(litellm.BadRequestError, match="previous_response_id") as raised: + litellm.responses( + model="hosted_vllm/test-model", + input="What is my favorite color?", + previous_response_id="resp_does_not_exist", + stream=stream, + use_chat_completions_api=True, + client=HTTPHandler(client=sync_client), + api_key="test", + api_base="https://provider.invalid/v1", + num_retries=0, + ) + assert raised.value.status_code == 400 + assert provider.request_body is None, "unresolved history must not send the current turn to the provider" + finally: + await async_client.client.aclose() + + def _stored_mcp_turn(request_id: str) -> Mapping[str, object]: return { "request_id": request_id, @@ -299,7 +355,10 @@ def _stored_mcp_turn(request_id: str) -> Mapping[str, object]: @pytest.mark.asyncio @pytest.mark.parametrize("stored_under", ["litellm_encoded_id", "proxy_issued_id"]) -async def test_bridged_next_turn_replays_a_turn_stored_under_the_id_the_client_holds(stored_under: str): +@pytest.mark.parametrize("async_call", [False, True]) +async def test_bridged_next_turn_replays_a_turn_stored_under_the_id_the_client_holds( + stored_under: str, async_call: bool +): litellm_encoded_id: Final = ResponsesAPIRequestUtils._build_responses_api_response_id( custom_llm_provider="azure_ai", model_id="deployment-a", response_id="chatcmpl-turn-one" ) @@ -311,21 +370,34 @@ async def test_bridged_next_turn_replays_a_turn_stored_under_the_id_the_client_h client: Final = AsyncHTTPHandler() client.client = httpx.AsyncClient(transport=httpx.MockTransport(provider)) - with patch("litellm.proxy.proxy_server.prisma_client", SimpleNamespace(db=stored_turns)): - await litellm.aresponses( - model="azure_ai/claude-sonnet-4-6", - api_base="https://fake-foundry-resource.services.ai.azure.com", - api_key="fake-api-key", - input="Double it", - previous_response_id=litellm_encoded_id, - client=client, - **{ADDRESSED_RESPONSE_ID_FIELD: proxy_issued_id}, - ) + with httpx.Client(transport=httpx.MockTransport(provider)) as sync_client: + try: + with patch("litellm.proxy.proxy_server.prisma_client", SimpleNamespace(db=stored_turns)): + if async_call: + await litellm.aresponses( + model="azure_ai/claude-sonnet-4-6", + api_base="https://fake-foundry-resource.services.ai.azure.com", + api_key="fake-api-key", + input="Double it", + previous_response_id=litellm_encoded_id, + client=client, + **{ADDRESSED_RESPONSE_ID_FIELD: proxy_issued_id}, + ) + else: + litellm.responses( + model="azure_ai/claude-sonnet-4-6", + api_base="https://fake-foundry-resource.services.ai.azure.com", + api_key="fake-api-key", + input="Double it", + previous_response_id=litellm_encoded_id, + client=HTTPHandler(client=sync_client), + **{ADDRESSED_RESPONSE_ID_FIELD: proxy_issued_id}, + ) + finally: + await client.client.aclose() assert provider.request_body is not None, "the bridged turn never reached the provider" - assert [ - (message["role"], json.dumps(message["content"])) for message in provider.request_body["messages"] - ] == [ + assert [(message["role"], json.dumps(message["content"])) for message in provider.request_body["messages"]] == [ ("user", json.dumps([{"type": "text", "text": "My favorite number is 7."}])), ("assistant", json.dumps([{"type": "text", "text": "Noted."}])), ("user", json.dumps([{"type": "text", "text": "Double it"}])), diff --git a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py index d5860b7519b..92251d18c86 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -7137,7 +7137,7 @@ def test_native_compaction_replay_moves_a_call_its_compaction_item_splits_from_i pytest.param(ResponsesAPIOptionalRequestParams(), id="summary_as_text"), ], ) -def test_compaction_replay_invents_no_call_for_an_output_whose_call_was_never_stored( +def test_compaction_replay_rejects_missing_history_instead_of_inventing_tool_calls( responses_api_request: ResponsesAPIOptionalRequestParams, ) -> None: async def no_stored_turns(previous_response_id: str) -> ChatCompletionSession: @@ -7158,16 +7158,16 @@ def test_compaction_replay_invents_no_call_for_an_output_whose_call_was_never_st ), ) - request: Final = asyncio.run( - LiteLLMCompletionResponsesConfig.async_responses_api_session_handler( - previous_response_id="resp_unknown", - litellm_completion_request=chat_request, - instructions=None, - session_lookup=no_stored_turns, + with pytest.raises(litellm.BadRequestError, match="previous_response_id") as raised: + asyncio.run( + LiteLLMCompletionResponsesConfig.async_responses_api_session_handler( + previous_response_id="resp_unknown", + litellm_completion_request=chat_request, + instructions=None, + session_lookup=no_stored_turns, + ) ) - ) - - assert not any(message.get("tool_calls") for message in request["messages"]) + assert raised.value.status_code == 400 @pytest.mark.parametrize(