This commit is contained in:
Dev Hiren Chiniwala 2026-11-06 18:28:05 +00:00 • committed by GitHub
commit e8bc2834ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 157 additions and 44 deletions

View file

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

View file

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

View file

@ -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"}])),

View file

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