litellm/tests/unit/responses/test_responses_api_lifecycle.py
simonp9 78d6c1aa82
fix(router): pass lifecycle events through on background OpenAI responses streams that cannot fall back (#48817)
* fix(router): pass lifecycle events through on background OpenAI responses streams that cannot fall back

The Router held response.created, response.queued and response.in_progress until the first output event on every /v1/responses stream, so a background run showed the client no response id while it was queued or reasoning. A native OpenAI stream whose upstream echoes background: true now releases those events as they arrive when no fallback, order level or weighted failover could replace it, and once the id is out a later pre-output error is raised instead of starting a second run behind it.

* test(router): type background stream test params and wait on the upstream pause instead of a clock

* fix(router): keep the content-policy note on a released background stream error

* fix(responses): return the routed id from cancel and add the router note only before output

acancel_responses and cancel_responses now re-encode the reply id with the deployment, like get. The released stream's content-policy note follows the held path, which adds it only before output text. Renames _anthropic_messages_order_levels to _stream_order_levels.

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-28 12:15:02 -07:00

151 lines
5.3 KiB
Python

from typing import Final, Literal, TypeAlias
import httpx
import openai
import pytest
import respx
import litellm
from litellm.types.llms.openai import ResponsesAPIResponse
from tests.unit.proxy.conftest import httpx_transport
pytestmark: Final = pytest.mark.usefixtures(httpx_transport.__name__)
Provider: TypeAlias = Literal["anthropic", "gemini"]
Operation: TypeAlias = Literal["delete", "get", "cancel"]
CancelProvider: TypeAlias = Literal["openai", "azure"]
def _invoke_unsupported_response_operation(provider: Provider, operation: Operation) -> object:
match operation:
case "delete":
return litellm.delete_responses(
response_id="resp_unsupported",
custom_llm_provider=provider,
api_key="sk-test",
)
case "get":
return litellm.get_responses(
response_id="resp_unsupported",
custom_llm_provider=provider,
api_key="sk-test",
)
case "cancel":
return litellm.cancel_responses(
response_id="resp_unsupported",
custom_llm_provider=provider,
api_key="sk-test",
)
async def _invoke_cancel_response(sync_mode: bool, call_kwargs: dict[str, object]) -> object:
if sync_mode:
return litellm.cancel_responses(**call_kwargs)
return await litellm.acancel_responses(**call_kwargs)
async def _invoke_get_response(sync_mode: bool, call_kwargs: dict[str, object]) -> object:
if sync_mode:
return litellm.get_responses(**call_kwargs)
return await litellm.aget_responses(**call_kwargs)
@pytest.mark.parametrize(
("provider", "operation"),
(
("anthropic", "delete"),
("anthropic", "get"),
("anthropic", "cancel"),
("gemini", "delete"),
("gemini", "get"),
("gemini", "cancel"),
),
)
def test_unsupported_response_lifecycle_operation_fails_before_http(provider: Provider, operation: Operation) -> None:
with respx.mock() as mock_router:
with pytest.raises(litellm.APIConnectionError) as exc_info:
_invoke_unsupported_response_operation(provider, operation)
calls: Final = tuple(mock_router.calls)
assert exc_info.value.status_code == 500
assert f"not supported for {provider}" in str(exc_info.value)
assert calls == ()
@pytest.mark.parametrize(
("provider", "sync_mode"),
(("openai", True), ("openai", False), ("azure", True), ("azure", False)),
)
@pytest.mark.asyncio
async def test_cancel_responses_404_surfaces_openai_api_error_with_exact_url(
provider: CancelProvider, sync_mode: bool
) -> None:
response_id: Final = "resp_missing"
error_body: Final = {
"error": {
"message": "Response was not found",
"type": "invalid_request_error",
"code": "not_found",
}
}
api_base: Final = (
"https://api.openai.com/v1" if provider == "openai" else "https://example-resource.openai.azure.com"
)
expected_url: Final = (
f"{api_base}/responses/{response_id}/cancel"
if provider == "openai"
else f"{api_base}/openai/responses/{response_id}/cancel?api-version=2025-03-01-preview"
)
with respx.mock() as mock_router:
route: Final = mock_router.post(expected_url).mock(
return_value=httpx.Response(status_code=404, json=error_body)
)
call_kwargs: Final = {
"custom_llm_provider": provider,
"api_key": "sk-test",
"api_base": api_base,
"api_version": "2025-03-01-preview",
"response_id": response_id,
}
with pytest.raises(openai.APIError) as exc_info:
await _invoke_cancel_response(sync_mode, call_kwargs)
requests: Final[tuple[respx.models.Call, ...]] = tuple(route.calls)
assert exc_info.value.status_code == 404
assert len(requests) == 1
assert str(requests[0].request.url) == expected_url
@pytest.mark.parametrize("sync_mode", (True, False))
@pytest.mark.asyncio
async def test_cancel_responses_reply_carries_the_id_retrieve_gives_for_the_same_run(sync_mode: bool) -> None:
upstream_id: Final = "resp_upstream123"
reply: Final = {
"id": upstream_id,
"object": "response",
"created_at": 0,
"status": "cancelled",
"model": "gpt-6.1-terra",
"output": [],
}
call_kwargs: Final = {
"custom_llm_provider": "openai",
"api_key": "sk-test",
"response_id": upstream_id,
"litellm_metadata": {"model_info": {"id": "deployment-1"}},
}
with respx.mock() as mock_router:
mock_router.post(f"https://api.openai.com/v1/responses/{upstream_id}/cancel").mock(
return_value=httpx.Response(status_code=200, json=reply)
)
mock_router.get(f"https://api.openai.com/v1/responses/{upstream_id}").mock(
return_value=httpx.Response(status_code=200, json=reply)
)
cancelled: Final = await _invoke_cancel_response(sync_mode, call_kwargs)
retrieved: Final = await _invoke_get_response(sync_mode, call_kwargs)
assert isinstance(cancelled, ResponsesAPIResponse)
assert isinstance(retrieved, ResponsesAPIResponse)
assert cancelled.id == retrieved.id
assert cancelled.id != upstream_id