mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-15 04:19:08 +00:00
* fix(responses): preserve prompt cache breakpoints in chat bridge * fix(responses): preserve multimodal cache breakpoints * fix(cache): retain implicit lookup with injected breakpoints * test(cache): expect implicit responses lookup * test(cache): expect implicit chat lookup
981 lines
38 KiB
Python
981 lines
38 KiB
Python
"""
|
|
Test that litellm.responses() / litellm.aresponses() send the expected request body
|
|
over the wire and surface provider errors correctly. Expected JSON bodies are stored
|
|
in expected_responses_api_request/.
|
|
"""
|
|
|
|
import copy
|
|
import json
|
|
from pathlib import Path
|
|
from importlib import import_module
|
|
from typing import Final
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
import litellm
|
|
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
|
|
|
|
|
def _expected_dir() -> Path:
|
|
"""Path to expected_responses_api_request folder (sibling of tests/unit/responses)."""
|
|
return Path(__file__).resolve().parent.parent / "expected_responses_api_request"
|
|
|
|
|
|
def _load_expected_body(filename: str) -> dict:
|
|
expected_path = _expected_dir() / filename
|
|
assert expected_path.exists(), f"Expected file not found: {expected_path}"
|
|
with open(expected_path) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def _minimal_responses_api_payload(response_id: str, model: str) -> dict:
|
|
return {
|
|
"id": response_id,
|
|
"object": "response",
|
|
"created_at": 1734366691,
|
|
"status": "completed",
|
|
"model": model,
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "msg_1",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Done.", "annotations": []}],
|
|
}
|
|
],
|
|
"parallel_tool_calls": True,
|
|
"usage": {
|
|
"input_tokens": 10,
|
|
"output_tokens": 5,
|
|
"total_tokens": 15,
|
|
"output_tokens_details": {"reasoning_tokens": 0},
|
|
},
|
|
"error": None,
|
|
"incomplete_details": None,
|
|
"instructions": None,
|
|
"metadata": None,
|
|
"temperature": None,
|
|
"tool_choice": "auto",
|
|
"tools": [],
|
|
"top_p": None,
|
|
"max_output_tokens": None,
|
|
"previous_response_id": None,
|
|
"reasoning": None,
|
|
"truncation": None,
|
|
"user": None,
|
|
}
|
|
|
|
|
|
class MockResponse:
|
|
def __init__(self, json_data, status_code=200):
|
|
self._json_data = json_data
|
|
self.status_code = status_code
|
|
self.text = json.dumps(json_data)
|
|
self.headers = httpx.Headers({})
|
|
|
|
def json(self):
|
|
return self._json_data
|
|
|
|
|
|
def _assert_request_body_matches(request_body: dict, expected_body: dict) -> None:
|
|
for key, expected_value in expected_body.items():
|
|
assert key in request_body, f"Missing key in request body: {key}"
|
|
assert request_body[key] == expected_value, (
|
|
f"Mismatch for key {key}: got {request_body[key]!r}, expected {expected_value!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_context_management_and_shell_request_body_matches_expected():
|
|
"""
|
|
Call litellm.aresponses() with context_management and shell tool;
|
|
assert the httpx POST request body matches the expected JSON.
|
|
"""
|
|
expected_body = _load_expected_body("context_management_and_shell.json")
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
|
new_callable=AsyncMock,
|
|
) as mock_post:
|
|
mock_post.return_value = MockResponse(_minimal_responses_api_payload("resp_ctx_shell_test", "gpt-4o"), 200)
|
|
|
|
await litellm.aresponses(
|
|
model="openai/gpt-4o",
|
|
input=expected_body["input"],
|
|
context_management=expected_body["context_management"],
|
|
tools=expected_body["tools"],
|
|
tool_choice=expected_body["tool_choice"],
|
|
max_output_tokens=expected_body["max_output_tokens"],
|
|
)
|
|
|
|
mock_post.assert_called_once()
|
|
_assert_request_body_matches(mock_post.call_args.kwargs["json"], expected_body)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_azure_shell_tool_request_body_matches_expected():
|
|
"""
|
|
Call litellm.aresponses() on the Azure route with the shell tool;
|
|
assert the httpx POST request body carries the shell tool verbatim.
|
|
"""
|
|
expected_body = _load_expected_body("azure_shell_tool.json")
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
|
new_callable=AsyncMock,
|
|
) as mock_post:
|
|
mock_post.return_value = MockResponse(
|
|
_minimal_responses_api_payload("resp_azure_shell_test", "gpt-5-mini"), 200
|
|
)
|
|
|
|
await litellm.aresponses(
|
|
model="azure/gpt-5-mini",
|
|
api_base="https://fake-resource.openai.azure.com",
|
|
api_key="fake-api-key",
|
|
api_version="2025-03-01-preview",
|
|
input=expected_body["input"],
|
|
tools=expected_body["tools"],
|
|
tool_choice=expected_body["tool_choice"],
|
|
max_output_tokens=expected_body["max_output_tokens"],
|
|
)
|
|
|
|
mock_post.assert_called_once()
|
|
_assert_request_body_matches(mock_post.call_args.kwargs["json"], expected_body)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_azure_shell_tool_400_maps_to_bad_request_error():
|
|
"""
|
|
Azure rejects the shell tool for unsupported deployments with a 400;
|
|
litellm must surface that as litellm.BadRequestError carrying the provider message.
|
|
"""
|
|
error_body = {
|
|
"error": {
|
|
"message": "Tool of type 'shell' is not supported with this model.",
|
|
"type": "invalid_request_error",
|
|
"param": "tools",
|
|
"code": None,
|
|
}
|
|
}
|
|
|
|
def _raise_azure_400(*args, **kwargs):
|
|
response = httpx.Response(
|
|
status_code=400,
|
|
json=error_body,
|
|
request=httpx.Request(
|
|
"POST",
|
|
kwargs.get(
|
|
"url",
|
|
"https://fake-resource.openai.azure.com/openai/responses",
|
|
),
|
|
),
|
|
)
|
|
response.raise_for_status()
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
|
new_callable=AsyncMock,
|
|
) as mock_post:
|
|
mock_post.side_effect = _raise_azure_400
|
|
|
|
with pytest.raises(litellm.BadRequestError) as excinfo:
|
|
await litellm.aresponses(
|
|
model="azure/gpt-5-mini",
|
|
api_base="https://fake-resource.openai.azure.com",
|
|
api_key="fake-api-key",
|
|
api_version="2025-03-01-preview",
|
|
input="List files in /mnt/data and run python --version.",
|
|
tools=[{"type": "shell", "environment": {"type": "container_auto"}}],
|
|
tool_choice="auto",
|
|
max_output_tokens=256,
|
|
)
|
|
|
|
assert excinfo.value.status_code == 400
|
|
assert "shell" in str(excinfo.value).lower()
|
|
assert "not supported" in str(excinfo.value).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_drops_stream_options():
|
|
"""The Responses API rejects include_usage, so include_usage-only stream_options must never reach the wire."""
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
|
new_callable=AsyncMock,
|
|
) as mock_post:
|
|
mock_post.return_value = MockResponse(
|
|
_minimal_responses_api_payload("resp_stream_options_test", "gpt-5.5"), 200
|
|
)
|
|
|
|
await litellm.aresponses(
|
|
model="openai/gpt-5.5",
|
|
api_key="fake-api-key",
|
|
input="hi",
|
|
stream_options={"include_usage": True},
|
|
)
|
|
|
|
mock_post.assert_called_once()
|
|
post_kwargs = mock_post.call_args.kwargs
|
|
request_body = post_kwargs["json"] if "json" in post_kwargs else json.loads(post_kwargs["data"])
|
|
assert "stream_options" not in request_body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_forwards_non_enum_reasoning_effort(
|
|
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
|
):
|
|
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").mock(
|
|
return_value=httpx.Response(200, json=_minimal_responses_api_payload("resp_effort_int", "gpt-5.4"))
|
|
)
|
|
|
|
response: Final = await litellm.aresponses(model="openai/gpt-5.4", input="hi", reasoning_effort=5)
|
|
|
|
assert upstream.call_count == 1
|
|
request_body: Final = json.loads(upstream.calls[0].request.read())
|
|
assert request_body["reasoning"] == {"effort": 5}
|
|
assert response.output[0].content[0].text == "Done."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acompletion_with_tools_forwards_non_enum_reasoning_effort_over_the_bridge(
|
|
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
|
):
|
|
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").mock(
|
|
return_value=httpx.Response(200, json=_minimal_responses_api_payload("resp_bridge_int", "gpt-5.4"))
|
|
)
|
|
|
|
response: Final = await litellm.acompletion(
|
|
model="openai/gpt-5.4",
|
|
messages=[{"role": "user", "content": "What is the weather in Paris?"}],
|
|
tools=[
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
|
|
},
|
|
}
|
|
],
|
|
reasoning_effort=5,
|
|
)
|
|
|
|
assert upstream.call_count == 1
|
|
request_body: Final = json.loads(upstream.calls[0].request.read())
|
|
assert request_body["reasoning"] == {"effort": 5}
|
|
assert response.id == "resp_bridge_int"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_forwards_prompt_managed_reasoning_effort(
|
|
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
|
):
|
|
from litellm.responses.main import _AsyncPromptManagementOutcome
|
|
|
|
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").mock(
|
|
return_value=httpx.Response(200, json=_minimal_responses_api_payload("resp_prompt_effort", "gpt-5.4"))
|
|
)
|
|
|
|
response: Final = await litellm.aresponses(
|
|
model="openai/gpt-5.4",
|
|
input="hi",
|
|
_async_prompt_merged_params=_AsyncPromptManagementOutcome(
|
|
merged_optional_params={"reasoning_effort": 5}, deployment_model_info=None
|
|
),
|
|
)
|
|
|
|
assert upstream.call_count == 1
|
|
request_body: Final = json.loads(upstream.calls[0].request.read())
|
|
assert request_body["reasoning"] == {"effort": 5}
|
|
assert "reasoning_effort" not in request_body
|
|
assert response.output[0].content[0].text == "Done."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_forwards_prompt_managed_reasoning_dict(
|
|
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
|
):
|
|
from litellm.responses.main import _AsyncPromptManagementOutcome
|
|
|
|
monkeypatch.setenv("OPENAI_API_KEY", "fake-api-key")
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
upstream: Final = respx_mock.post("https://api.openai.com/v1/responses").mock(
|
|
return_value=httpx.Response(200, json=_minimal_responses_api_payload("resp_prompt_reasoning", "gpt-5.4"))
|
|
)
|
|
|
|
response: Final = await litellm.aresponses(
|
|
model="openai/gpt-5.4",
|
|
input="hi",
|
|
_async_prompt_merged_params=_AsyncPromptManagementOutcome(
|
|
merged_optional_params={"reasoning": {"effort": "high", "summary": "detailed"}}, deployment_model_info=None
|
|
),
|
|
)
|
|
|
|
assert upstream.call_count == 1
|
|
request_body: Final = json.loads(upstream.calls[0].request.read())
|
|
assert request_body["reasoning"] == {"effort": "high", "summary": "detailed"}
|
|
assert response.output[0].content[0].text == "Done."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_keeps_include_obfuscation_in_stream_options():
|
|
"""include_obfuscation is a valid Responses API stream option and must survive the include_usage strip."""
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
|
new_callable=AsyncMock,
|
|
) as mock_post:
|
|
mock_post.return_value = MockResponse(
|
|
_minimal_responses_api_payload("resp_stream_options_obfuscation", "gpt-5.5"), 200
|
|
)
|
|
|
|
await litellm.aresponses(
|
|
model="openai/gpt-5.5",
|
|
api_key="fake-api-key",
|
|
input="hi",
|
|
stream_options={"include_usage": True, "include_obfuscation": False},
|
|
)
|
|
|
|
mock_post.assert_called_once()
|
|
post_kwargs = mock_post.call_args.kwargs
|
|
request_body = post_kwargs["json"] if "json" in post_kwargs else json.loads(post_kwargs["data"])
|
|
assert request_body["stream_options"] == {"include_obfuscation": False}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("drop_params", [True, "true"])
|
|
async def test_aresponses_request_level_drop_params_drops_bedrock_mantle_service_tier(
|
|
monkeypatch,
|
|
drop_params,
|
|
):
|
|
"""
|
|
Request-level drop_params=True (as the proxy injects for agentic CLIs) must
|
|
reach the provider config so bedrock_mantle strips the unsupported
|
|
service_tier before the request hits the wire.
|
|
"""
|
|
monkeypatch.setattr(litellm, "drop_params", False)
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
|
new_callable=AsyncMock,
|
|
) as mock_post:
|
|
mock_post.return_value = MockResponse(
|
|
_minimal_responses_api_payload("resp_mantle_tier_test", "openai.gpt-5.5"),
|
|
200,
|
|
)
|
|
|
|
await litellm.aresponses(
|
|
model="bedrock_mantle/openai.gpt-5.5",
|
|
api_key="fake-bearer-token",
|
|
aws_region_name="us-east-1",
|
|
input="hi",
|
|
service_tier="priority",
|
|
drop_params=drop_params,
|
|
)
|
|
|
|
mock_post.assert_called_once()
|
|
post_kwargs = mock_post.call_args.kwargs
|
|
request_body = post_kwargs["json"] if "json" in post_kwargs else json.loads(post_kwargs["data"])
|
|
assert "service_tier" not in request_body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_bedrock_mantle_service_tier_raises_without_drop_params(
|
|
monkeypatch,
|
|
):
|
|
"""
|
|
Without drop_params, an unsupported service_tier must fail fast with an
|
|
error that names drop_params instead of sending a request Mantle rejects.
|
|
"""
|
|
monkeypatch.setattr(litellm, "drop_params", False)
|
|
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
|
new_callable=AsyncMock,
|
|
) as mock_post:
|
|
with pytest.raises(litellm.BadRequestError) as excinfo:
|
|
await litellm.aresponses(
|
|
model="bedrock_mantle/openai.gpt-5.5",
|
|
api_key="fake-bearer-token",
|
|
aws_region_name="us-east-1",
|
|
input="hi",
|
|
service_tier="priority",
|
|
)
|
|
|
|
mock_post.assert_not_called()
|
|
assert "drop_params" in str(excinfo.value)
|
|
assert "priority" in str(excinfo.value)
|
|
|
|
|
|
async def _aresponses_and_get_request_headers(**request_kwargs) -> dict:
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
|
new_callable=AsyncMock,
|
|
) as mock_post:
|
|
mock_post.return_value = MockResponse(_minimal_responses_api_payload("resp_headers_test", "gpt-4o"), 200)
|
|
|
|
await litellm.aresponses(
|
|
model="openai/gpt-4o",
|
|
api_key="fake-api-key",
|
|
input="hi",
|
|
**request_kwargs,
|
|
)
|
|
|
|
mock_post.assert_called_once()
|
|
return dict(mock_post.call_args.kwargs["headers"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_forwards_client_headers_kwarg_to_provider():
|
|
"""
|
|
The proxy passes client headers it forwards (`forward_client_headers_to_llm_api`)
|
|
as a `headers` kwarg; those must reach the provider request.
|
|
"""
|
|
request_headers = await _aresponses_and_get_request_headers(headers={"x-my-new-header": "hello-from-client"})
|
|
|
|
assert request_headers["x-my-new-header"] == "hello-from-client"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_merges_client_headers_with_extra_headers():
|
|
"""
|
|
A `headers` kwarg and an explicit `extra_headers` are merged, with
|
|
`extra_headers` winning on conflicts.
|
|
"""
|
|
request_headers = await _aresponses_and_get_request_headers(
|
|
headers={"x-my-new-header": "hello-from-client", "x-shared": "from-client"},
|
|
extra_headers={"x-explicit": "from-caller", "x-shared": "from-caller"},
|
|
)
|
|
|
|
assert request_headers["x-my-new-header"] == "hello-from-client"
|
|
assert request_headers["x-explicit"] == "from-caller"
|
|
assert request_headers["x-shared"] == "from-caller"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_client_header_conflict_is_case_insensitive():
|
|
"""
|
|
HTTP header names are case-insensitive, so a differently cased client header
|
|
must not survive alongside the explicit `extra_headers` value.
|
|
"""
|
|
request_headers = await _aresponses_and_get_request_headers(
|
|
headers={"X-Shared": "from-client"},
|
|
extra_headers={"x-shared": "from-caller"},
|
|
)
|
|
|
|
assert [name for name in request_headers if name.lower() == "x-shared"] == ["x-shared"]
|
|
assert request_headers["x-shared"] == "from-caller"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("model", "custom_llm_provider"),
|
|
[
|
|
("openai/responses/gpt-5.6", None),
|
|
("responses/gpt-5.6", "openai"),
|
|
],
|
|
)
|
|
async def test_aresponses_strips_responses_routing_prefix_from_openai_model(model, custom_llm_provider):
|
|
"""
|
|
`responses/` is LiteLLM routing sugar, never part of the provider model id.
|
|
Deployments configured as openai/responses/<model> reach this path directly via
|
|
/v1/responses and via the /v1/messages adapter (which passes responses/<model>
|
|
with custom_llm_provider="openai"), so both shapes must hit OpenAI as <model>.
|
|
"""
|
|
injected_client = AsyncHTTPHandler()
|
|
mock_post = AsyncMock(return_value=MockResponse(_minimal_responses_api_payload("resp_prefix_test", "gpt-5.6"), 200))
|
|
injected_client.post = mock_post
|
|
|
|
await litellm.aresponses(
|
|
model=model,
|
|
custom_llm_provider=custom_llm_provider,
|
|
input="ping",
|
|
api_key="sk-test",
|
|
client=injected_client,
|
|
)
|
|
|
|
mock_post.assert_called_once()
|
|
assert mock_post.call_args.kwargs["url"].endswith("/responses")
|
|
assert mock_post.call_args.kwargs["json"]["model"] == "gpt-5.6"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_websocket_strips_responses_routing_prefix_from_openai_model():
|
|
from unittest.mock import MagicMock
|
|
|
|
from litellm.responses.main import _aresponses_websocket
|
|
|
|
with patch.object(
|
|
import_module("litellm.responses.main").base_llm_http_handler, "async_responses_websocket",
|
|
new_callable=AsyncMock,
|
|
) as mock_ws:
|
|
await _aresponses_websocket(
|
|
model="openai/responses/gpt-5.6",
|
|
websocket=MagicMock(),
|
|
api_key="sk-test",
|
|
litellm_logging_obj=MagicMock(),
|
|
)
|
|
|
|
mock_ws.assert_awaited_once()
|
|
assert mock_ws.call_args.kwargs["model"] == "gpt-5.6"
|
|
assert mock_ws.call_args.kwargs["custom_llm_provider"] == "openai"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_websocket_keeps_routing_hints_out_of_the_relay_kwargs(): # test-quality-ok: the relay kwargs are the only place a dropped key is observable; the provider socket behind them is the boundary
|
|
from unittest.mock import MagicMock
|
|
|
|
from litellm.responses.main import _aresponses_websocket
|
|
|
|
with patch.object(
|
|
import_module("litellm.responses.main").base_llm_http_handler, "async_responses_websocket",
|
|
new_callable=AsyncMock,
|
|
) as mock_ws:
|
|
await _aresponses_websocket(
|
|
model="openai/gpt-5.6",
|
|
websocket=MagicMock(),
|
|
api_key="sk-test",
|
|
litellm_logging_obj=MagicMock(),
|
|
input=[{"type": "message", "role": "user", "content": "hi"}],
|
|
previous_response_id="resp_prev",
|
|
)
|
|
|
|
mock_ws.assert_awaited_once()
|
|
assert "input" not in mock_ws.call_args.kwargs
|
|
assert "previous_response_id" not in mock_ws.call_args.kwargs
|
|
|
|
|
|
_STRIPPED_WS_INPUT = [{"role": "user", "content": "hi"}]
|
|
_ORIGINAL_WS_INPUT = [
|
|
{"type": "reasoning", "id": "rs_1", "encrypted_content": "blob-from-a-removed-deployment", "summary": []},
|
|
*_STRIPPED_WS_INPUT,
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("nested", [False, True])
|
|
async def test_aresponses_websocket_forwards_the_routed_input_in_the_first_frame(nested: bool): # test-quality-ok: the first frame handed to the relay is the only place the routed input is observable before the provider socket
|
|
from unittest.mock import MagicMock
|
|
|
|
from litellm.responses.main import _aresponses_websocket
|
|
|
|
body = {"model": "gpt-5.6", "input": _ORIGINAL_WS_INPUT, "store": False}
|
|
first_message = json.dumps(
|
|
{"type": "response.create", "response": body} if nested else {"type": "response.create", **body}
|
|
)
|
|
|
|
with patch.object(
|
|
import_module("litellm.responses.main").base_llm_http_handler, "async_responses_websocket",
|
|
new_callable=AsyncMock,
|
|
) as mock_ws:
|
|
await _aresponses_websocket(
|
|
model="openai/gpt-5.6",
|
|
websocket=MagicMock(),
|
|
api_key="sk-test",
|
|
litellm_logging_obj=MagicMock(),
|
|
input=list(_STRIPPED_WS_INPUT),
|
|
first_message=first_message,
|
|
)
|
|
|
|
forwarded = json.loads(mock_ws.call_args.kwargs["first_message"])
|
|
container = forwarded["response"] if nested else forwarded
|
|
assert container["input"] == _STRIPPED_WS_INPUT
|
|
assert container["store"] is False
|
|
assert container["model"] == "gpt-5.6"
|
|
assert forwarded["type"] == "response.create"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_websocket_forwards_the_first_frame_verbatim_when_routing_left_the_input_alone(): # test-quality-ok: the relay kwargs are the boundary; byte-identical passthrough is only observable there
|
|
from unittest.mock import MagicMock
|
|
|
|
from litellm.responses.main import _aresponses_websocket
|
|
|
|
first_message = '{"type": "response.create", "model": "gpt-5.6", "input": [{"role": "user", "content": "hi"}]}'
|
|
|
|
with patch.object(
|
|
import_module("litellm.responses.main").base_llm_http_handler, "async_responses_websocket",
|
|
new_callable=AsyncMock,
|
|
) as mock_ws:
|
|
await _aresponses_websocket(
|
|
model="openai/gpt-5.6",
|
|
websocket=MagicMock(),
|
|
api_key="sk-test",
|
|
litellm_logging_obj=MagicMock(),
|
|
input=list(_STRIPPED_WS_INPUT),
|
|
first_message=first_message,
|
|
)
|
|
|
|
assert mock_ws.call_args.kwargs["first_message"] == first_message
|
|
|
|
|
|
_INJECTION_POINT_INPUT = [{"role": "system", "content": "You are terse."}, {"role": "user", "content": "hi"}]
|
|
_SYSTEM_POINT = {"location": "message", "role": "system"}
|
|
_USER_POINT = {"location": "message", "role": "user"}
|
|
_SYSTEM_INJECTION_POINT = [_SYSTEM_POINT]
|
|
_ANTHROPIC_MESSAGES_PAYLOAD = {
|
|
"id": "msg_1",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": "claude-sonnet-4-5",
|
|
"content": [{"type": "text", "text": "Done."}],
|
|
"stop_reason": "end_turn",
|
|
"usage": {"input_tokens": 10, "output_tokens": 5},
|
|
}
|
|
|
|
|
|
def _sent_body(mock_post) -> dict:
|
|
kwargs = mock_post.call_args.kwargs
|
|
return kwargs["json"] if "json" in kwargs else json.loads(kwargs["data"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_injection_point_marks_input_text_on_gpt_5_6():
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
|
new_callable=AsyncMock,
|
|
) as mock_post:
|
|
mock_post.return_value = MockResponse(_minimal_responses_api_payload("resp_pcb_async", "gpt-5.6"), 200)
|
|
|
|
await litellm.aresponses(
|
|
model="openai/gpt-5.6",
|
|
api_key="fake-api-key",
|
|
input=copy.deepcopy(_INJECTION_POINT_INPUT),
|
|
cache_control_injection_points=copy.deepcopy(_SYSTEM_INJECTION_POINT),
|
|
)
|
|
|
|
body = _sent_body(mock_post)
|
|
assert body["input"][0]["content"][0] == {
|
|
"type": "input_text",
|
|
"text": "You are terse.",
|
|
"prompt_cache_breakpoint": {"mode": "explicit"},
|
|
}
|
|
assert body["input"][1] == {"role": "user", "content": "hi"}
|
|
assert body["prompt_cache_options"] == {"mode": "implicit"}
|
|
|
|
|
|
def test_responses_injection_point_marks_input_text_on_gpt_5_6():
|
|
with patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post") as mock_post:
|
|
mock_post.return_value = MockResponse(_minimal_responses_api_payload("resp_pcb_sync", "gpt-5.6"), 200)
|
|
|
|
litellm.responses(
|
|
model="openai/gpt-5.6",
|
|
api_key="fake-api-key",
|
|
input=copy.deepcopy(_INJECTION_POINT_INPUT),
|
|
cache_control_injection_points=copy.deepcopy(_SYSTEM_INJECTION_POINT),
|
|
)
|
|
|
|
body = _sent_body(mock_post)
|
|
assert body["input"][0]["content"][0] == {
|
|
"type": "input_text",
|
|
"text": "You are terse.",
|
|
"prompt_cache_breakpoint": {"mode": "explicit"},
|
|
}
|
|
assert body["input"][1] == {"role": "user", "content": "hi"}
|
|
assert body["prompt_cache_options"] == {"mode": "implicit"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aresponses_injection_point_sends_nothing_extra_below_gpt_5_6():
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
|
new_callable=AsyncMock,
|
|
) as mock_post:
|
|
mock_post.return_value = MockResponse(_minimal_responses_api_payload("resp_pcb_old", "gpt-4.1"), 200)
|
|
|
|
await litellm.aresponses(
|
|
model="openai/gpt-4.1",
|
|
api_key="fake-api-key",
|
|
input=copy.deepcopy(_INJECTION_POINT_INPUT),
|
|
cache_control_injection_points=copy.deepcopy(_SYSTEM_INJECTION_POINT),
|
|
)
|
|
|
|
body = _sent_body(mock_post)
|
|
assert body["input"] == _INJECTION_POINT_INPUT
|
|
assert "prompt_cache_options" not in body
|
|
assert "cache_control" not in json.dumps(body)
|
|
|
|
|
|
@pytest.fixture
|
|
def _no_openai_api_base_override(monkeypatch):
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
|
|
monkeypatch.setattr(litellm, "api_base", None)
|
|
|
|
|
|
_CUSTOM_API_BASE = "http://127.0.0.1:9/v1"
|
|
|
|
|
|
async def _aresponses_body_with_system_point(**request_kwargs) -> dict:
|
|
with patch(
|
|
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
|
new_callable=AsyncMock,
|
|
) as mock_post:
|
|
mock_post.return_value = MockResponse(_minimal_responses_api_payload("resp_pcb_gate", "gpt-5.6"), 200)
|
|
await litellm.aresponses(
|
|
api_key="fake-api-key",
|
|
input=copy.deepcopy(_INJECTION_POINT_INPUT),
|
|
cache_control_injection_points=copy.deepcopy(_SYSTEM_INJECTION_POINT),
|
|
**request_kwargs,
|
|
)
|
|
return _sent_body(mock_post)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.usefixtures("_no_openai_api_base_override")
|
|
async def test_aresponses_litellm_proxy_target_sends_no_openai_markers():
|
|
body = await _aresponses_body_with_system_point(model="litellm_proxy/gpt-5.6", api_base=_CUSTOM_API_BASE)
|
|
assert body["input"] == _INJECTION_POINT_INPUT
|
|
assert "prompt_cache_options" not in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.usefixtures("_no_openai_api_base_override")
|
|
async def test_aresponses_custom_api_base_sends_no_openai_markers():
|
|
body = await _aresponses_body_with_system_point(model="gpt-5.6", api_base=_CUSTOM_API_BASE)
|
|
assert body["input"] == _INJECTION_POINT_INPUT
|
|
assert "prompt_cache_options" not in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.usefixtures("_no_openai_api_base_override")
|
|
async def test_aresponses_custom_api_base_opts_in_through_prompt_cache_options():
|
|
body = await _aresponses_body_with_system_point(
|
|
model="gpt-5.6", api_base=_CUSTOM_API_BASE, prompt_cache_options={"mode": "explicit"}
|
|
)
|
|
assert body["input"][0]["content"][0] == {
|
|
"type": "input_text",
|
|
"text": "You are terse.",
|
|
"prompt_cache_breakpoint": {"mode": "explicit"},
|
|
}
|
|
assert body["prompt_cache_options"] == {"mode": "explicit"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.usefixtures("_no_openai_api_base_override")
|
|
async def test_aresponses_regional_openai_api_base_marks_input_text():
|
|
body = await _aresponses_body_with_system_point(model="gpt-5.6", api_base="https://eu.api.openai.com/v1")
|
|
assert body["input"][0]["content"][0]["prompt_cache_breakpoint"] == {"mode": "explicit"}
|
|
assert body["prompt_cache_options"] == {"mode": "implicit"}
|
|
|
|
|
|
@pytest.mark.usefixtures("_no_openai_api_base_override")
|
|
def test_responses_custom_base_url_sends_no_openai_markers():
|
|
with patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post") as mock_post:
|
|
mock_post.return_value = MockResponse(_minimal_responses_api_payload("resp_pcb_gate_base_url", "gpt-5.6"), 200)
|
|
|
|
litellm.responses(
|
|
model="gpt-5.6",
|
|
api_key="fake-api-key",
|
|
base_url=_CUSTOM_API_BASE,
|
|
input=copy.deepcopy(_INJECTION_POINT_INPUT),
|
|
cache_control_injection_points=copy.deepcopy(_SYSTEM_INJECTION_POINT),
|
|
)
|
|
|
|
body = _sent_body(mock_post)
|
|
assert body["input"] == _INJECTION_POINT_INPUT
|
|
assert "prompt_cache_options" not in body
|
|
|
|
|
|
@pytest.mark.usefixtures("_no_openai_api_base_override")
|
|
def test_responses_custom_api_base_sends_no_openai_markers():
|
|
with patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post") as mock_post:
|
|
mock_post.return_value = MockResponse(_minimal_responses_api_payload("resp_pcb_gate_sync", "gpt-5.6"), 200)
|
|
|
|
litellm.responses(
|
|
model="gpt-5.6",
|
|
api_key="fake-api-key",
|
|
api_base=_CUSTOM_API_BASE,
|
|
input=copy.deepcopy(_INJECTION_POINT_INPUT),
|
|
cache_control_injection_points=copy.deepcopy(_SYSTEM_INJECTION_POINT),
|
|
)
|
|
|
|
body = _sent_body(mock_post)
|
|
assert body["input"] == _INJECTION_POINT_INPUT
|
|
assert "prompt_cache_options" not in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_injection_points_still_reach_a_native_responses_provider():
|
|
"""Providers that serve Responses natively never reach the chat-completions bridge,
|
|
so this layer is their only chance to inject and must keep doing so."""
|
|
injected_client = AsyncHTTPHandler()
|
|
mock_post = AsyncMock(return_value=MockResponse(_minimal_responses_api_payload("resp_native", "gpt-5.6"), 200))
|
|
injected_client.post = mock_post
|
|
|
|
await litellm.aresponses(
|
|
model="openai/gpt-5.6",
|
|
api_key="fake-api-key",
|
|
input=copy.deepcopy(_INJECTION_POINT_INPUT),
|
|
cache_control_injection_points=copy.deepcopy(_SYSTEM_INJECTION_POINT),
|
|
client=injected_client,
|
|
)
|
|
|
|
body = _sent_body(mock_post)
|
|
assert body["input"][0]["content"][0]["prompt_cache_breakpoint"] == {"mode": "explicit"}
|
|
assert "cache_control_injection_points" not in body
|
|
|
|
|
|
async def _bridged_body(mock_post, *, points, input, instructions="You are a documentation assistant."):
|
|
injected_client = AsyncHTTPHandler()
|
|
injected_client.post = mock_post
|
|
|
|
await litellm.aresponses(
|
|
model="anthropic/claude-sonnet-4-5",
|
|
api_key="fake-api-key",
|
|
instructions=instructions,
|
|
input=copy.deepcopy(input),
|
|
cache_control_injection_points=copy.deepcopy(points),
|
|
client=injected_client,
|
|
)
|
|
return _sent_body(mock_post)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"content",
|
|
[
|
|
pytest.param("hi", id="string-content"),
|
|
pytest.param([{"type": "input_text", "text": "hi there friend"}], id="list-content"),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"points",
|
|
[
|
|
pytest.param([_SYSTEM_POINT], id="system-only"),
|
|
pytest.param([_USER_POINT, _SYSTEM_POINT], id="mixed-user-and-system"),
|
|
],
|
|
)
|
|
async def test_instructions_are_marked_when_the_bridge_builds_the_system_message(points, content):
|
|
"""The system prompt lives in `instructions`, which is not a message until the bridge
|
|
builds one, so the point targeting it matches nothing at the Responses layer.
|
|
|
|
Carrying it forward is what marks it at all. Carrying it *stamped* is what keeps a
|
|
second point that did match from stranding it: without the stamp the next pass reads
|
|
litellm's own marks as client breakpoints and stands the whole configuration down.
|
|
"""
|
|
mock_post = AsyncMock(return_value=MockResponse(_ANTHROPIC_MESSAGES_PAYLOAD, 200))
|
|
body = await _bridged_body(mock_post, points=points, input=[{"role": "user", "content": content}])
|
|
|
|
assert body["system"][0]["cache_control"] == {"type": "ephemeral"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("instructions", [None, "You are a documentation assistant."])
|
|
async def test_positional_points_address_the_input_item_the_caller_indexed(instructions):
|
|
"""`index` counts the caller's `input` items, and the Responses layer is where that
|
|
list still is, so a matched positional point must be spent there and never re-resolved
|
|
against the bridge's list, where the system message shifts every ordinal by one."""
|
|
mock_post = AsyncMock(return_value=MockResponse(_ANTHROPIC_MESSAGES_PAYLOAD, 200))
|
|
body = await _bridged_body(
|
|
mock_post,
|
|
points=[{"location": "message", "index": 0}],
|
|
input=[{"role": "user", "content": [{"type": "input_text", "text": "hi there friend"}]}],
|
|
instructions=instructions,
|
|
)
|
|
|
|
assert body["messages"][0]["content"][0]["cache_control"] == {"type": "ephemeral"}
|
|
if instructions:
|
|
assert "cache_control" not in json.dumps(body["system"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_out_of_bounds_positional_points_are_not_revived_by_a_longer_list():
|
|
"""An ordinal addresses the list in front of the pass that reads it.
|
|
|
|
Carrying one forward would re-resolve it against the bridge's longer list, where an
|
|
index that named nothing in the caller's `input` can land on a real message -- the
|
|
system prompt included. Positional points are resolved where they were written or not
|
|
at all.
|
|
"""
|
|
mock_post = AsyncMock(return_value=MockResponse(_ANTHROPIC_MESSAGES_PAYLOAD, 200))
|
|
body = await _bridged_body(
|
|
mock_post,
|
|
points=[{"location": "message", "index": 1}],
|
|
input=[{"role": "user", "content": [{"type": "input_text", "text": "only item"}]}],
|
|
)
|
|
|
|
assert "cache_control" not in json.dumps(body["system"])
|
|
assert "cache_control" not in json.dumps(body["messages"])
|
|
|
|
|
|
def _four_user_turns() -> list:
|
|
return [
|
|
item
|
|
for i in range(4)
|
|
for item in (
|
|
{"role": "user", "content": [{"type": "input_text", "text": f"msg{i}"}]},
|
|
{"role": "assistant", "content": [{"type": "output_text", "text": f"reply{i}", "annotations": []}]},
|
|
)
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"points,instructions,system_marked,marked_messages",
|
|
[
|
|
pytest.param([_SYSTEM_POINT, _USER_POINT], "You are terse.", True, [0, 2, 4], id="earlier-point-wins"),
|
|
pytest.param([_USER_POINT, _SYSTEM_POINT], "You are terse.", False, [0, 2, 4, 6], id="reversed-order-reverses"),
|
|
pytest.param([_USER_POINT, _SYSTEM_POINT], None, False, [0, 2, 4, 6], id="target-never-built-costs-nothing"),
|
|
],
|
|
)
|
|
async def test_config_order_decides_who_wins_the_shared_breakpoint_budget(
|
|
points, instructions, system_marked, marked_messages
|
|
):
|
|
"""Injection points are honoured in config order, earlier ones winning scarce slots.
|
|
|
|
A role-targeted point is placed a pass later than a positional one, so the four
|
|
breakpoints it competes for are shared across both passes. Every role point being
|
|
settled in the pass that holds the final list -- rather than the earlier pass holding
|
|
a slot for one it cannot place -- is what keeps that competition ordered in both
|
|
directions, and what stops a point whose target is never built from costing anything.
|
|
"""
|
|
mock_post = AsyncMock(return_value=MockResponse(_ANTHROPIC_MESSAGES_PAYLOAD, 200))
|
|
body = await _bridged_body(mock_post, points=points, input=_four_user_turns(), instructions=instructions)
|
|
|
|
assert ("cache_control" in json.dumps(body.get("system", []))) is system_marked
|
|
assert [i for i, msg in enumerate(body["messages"]) if "cache_control" in json.dumps(msg)] == marked_messages
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_native_responses_provider_places_every_point_itself():
|
|
"""A provider serving Responses natively gets no second pass.
|
|
|
|
This layer is the last one that can place anything, so handing a point forward here
|
|
drops it -- and an unmatchable point must not cost a matching one its slot either.
|
|
The request has to be known to be bridged before anything is deferred.
|
|
"""
|
|
input_items = _four_user_turns()
|
|
|
|
async def _marked_indices(points):
|
|
injected_client = AsyncHTTPHandler()
|
|
mock_post = AsyncMock(return_value=MockResponse(_minimal_responses_api_payload("resp_native", "gpt-5.6"), 200))
|
|
injected_client.post = mock_post
|
|
await litellm.aresponses(
|
|
model="openai/gpt-5.6",
|
|
api_key="fake-api-key",
|
|
input=copy.deepcopy(input_items),
|
|
cache_control_injection_points=copy.deepcopy(points),
|
|
client=injected_client,
|
|
)
|
|
body = _sent_body(mock_post)
|
|
return [i for i, item in enumerate(body["input"]) if "prompt_cache_breakpoint" in json.dumps(item)]
|
|
|
|
user_only = await _marked_indices([_USER_POINT])
|
|
# The system point can never match here: nothing turns `instructions` into a message
|
|
# on the native path, so it must not cost the user point a slot.
|
|
with_unmatchable_system = await _marked_indices([_SYSTEM_POINT, _USER_POINT])
|
|
|
|
assert user_only == [0, 2, 4, 6]
|
|
assert with_unmatchable_system == user_only
|