litellm/tests/test_litellm_rust/messages/test_callbacks.py
yujonglee 0fc11653e2
test(rust-bridge): pin Messages and Responses callback contracts on the native path (#46443)
* test(rust-bridge): pin Messages and Responses callback contracts on the native path

Each test runs the native route and asserts what the Python path does today, so a
known gap is a strict xfail that names its reason and the exception it fails with
Messages and Responses share one Surface and hook recorder in support/callbacks.py,
and each surface folder lists its own expected counts and gaps.

Body-level hooks from the function body (pre-request hook, websearch short-circuit,
Messages interceptors, planned and legacy agentic loops, streaming deployment hook)
are xfail until native runs them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(rust-bridge): give every route folder the same one-concern-per-file layout

Route data, the route Surface and the server fixture move into each route folder, and support/ keeps only
route-neutral helpers. test_inference.py splits into chat_completions/ and responses/, ocr/test_requests.py
splits into requests, failures and settings, and Messages and Responses get streaming and body hook files.
Messages request tests that duplicate inference-messages request.rs are removed, and drop_params read
from litellm.drop_params is now covered

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(rust-bridge): expect the required-route decline error for unsupported Responses requests

Co-Authored-By: Yujong Lee <yujong@berri.ai>

* test(rust-bridge): import route data from the route folders in the secrets unit tests

Co-Authored-By: Yujong Lee <yujong@berri.ai>

* test(rust-bridge): import NoReturn in the OCR dispatch tests

Co-Authored-By: Yujong Lee <yujong@berri.ai>

* test(rust-bridge): move route-neutral helpers out of support/callbacks.py

Co-Authored-By: Yujong Lee <yujong@berri.ai>

* test(rust-bridge): cover deployment rejection and concurrent callbacks

* test(rust-bridge): assert native serving via the x-litellm-rust marker in the route folder tests

Co-Authored-By: Yujong Lee <yujong@berri.ai>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 17:07:20 -07:00

429 lines
18 KiB
Python

import asyncio
from collections.abc import Iterator
from contextvars import ContextVar
from datetime import datetime
from typing import Final
import pytest
from pydantic import TypeAdapter
import litellm
from litellm.integrations.argilla import ArgillaLogger
from litellm.integrations.custom_logger import CustomLogger
from litellm.types.integrations.argilla import ArgillaItem
from litellm.types.utils import ModelResponse, StandardLoggingPayload
from tests.test_litellm_rust.messages.data import BAD_REQUEST, MESSAGES_RESPONSE, RATE_LIMITED, call
from tests.test_litellm_rust.support.callback_recorder import HookRecorder, RecordingLogger, request_body
from tests.test_litellm_rust.support.gaps import gap
from tests.test_litellm_rust.support.isolation import rebound
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec, relaxed
from tests.test_litellm_rust.support.results import response_id, with_id
from tests.test_litellm_rust.support.routing import assert_served_natively
pytestmark = pytest.mark.requires_rust_extension
SYNC_HANDLERS_ON_ASYNC_CALLS: Final = gap("native async calls also run the sync success and failure handlers")
_OBJECT: Final = TypeAdapter(dict[str, object])
_HEADERS: Final = TypeAdapter(dict[str, str])
@pytest.fixture
def server(server: RecordingServer) -> Iterator[RecordingServer]:
yield from relaxed(server)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"hook,expected",
(
pytest.param("log_pre_api_call", 1),
pytest.param("log_post_api_call", 1),
pytest.param("async_log_success_event", 1),
pytest.param("async_logging_hook", 1),
pytest.param("redact_standard_logging_payload_from_model_call_details", 1, id="redact"),
pytest.param("async_pre_call_deployment_hook", 1),
pytest.param("async_post_call_success_deployment_hook", 1),
pytest.param("log_success_event", 0),
pytest.param("logging_hook", 0),
),
)
async def test_success_hook_fires_the_expected_number_of_times(
server: RecordingServer, hook: str, expected: int
) -> None:
recorder: Final = HookRecorder()
with rebound(litellm, "callbacks", [recorder]):
response: Final = await call(server)
await recorder.wait_for_async("async_log_success_event")
assert_served_natively(server, response)
assert recorder.names.count(hook) == expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
"hook,expected",
(
pytest.param("async_log_failure_event", 1),
pytest.param("async_post_call_failure_deployment_hook", 1),
pytest.param("log_model_group_rate_limit_error", 1),
pytest.param("log_failure_event", 0, marks=SYNC_HANDLERS_ON_ASYNC_CALLS),
),
)
async def test_failure_hook_fires_the_expected_number_of_times(
server: RecordingServer, hook: str, expected: int
) -> None:
server.default_response = ResponseSpec(body=RATE_LIMITED, status=429)
recorder: Final = HookRecorder()
with rebound(litellm, "callbacks", [recorder]):
with pytest.raises(litellm.RateLimitError):
await call(server, metadata={"model_group_size": 1})
await recorder.wait_for_async("async_log_failure_event")
assert_served_natively(server)
assert recorder.names.count(hook) == expected
@pytest.mark.asyncio
async def test_failure_events_carry_the_error_raised_to_the_caller(server: RecordingServer) -> None:
server.default_response = ResponseSpec(body=BAD_REQUEST, status=400)
recorder: Final = RecordingLogger()
with rebound(litellm, "callbacks", [recorder]):
with pytest.raises(litellm.BadRequestError) as raised:
await call(server)
events: Final = await recorder.wait_for_async("async_log_failure_event")
assert_served_natively(server)
assert isinstance(events[0].kwargs, dict)
assert events[0].kwargs["exception"] is raised.value
@pytest.mark.asyncio
async def test_pre_api_call_sees_the_wire_body(server: RecordingServer) -> None:
recorder: Final = RecordingLogger()
response: Final = await call(server, callbacks=[recorder])
assert_served_natively(server, response)
pre_call: Final = recorder.wait_for("log_pre_api_call")[0].kwargs
assert isinstance(pre_call, dict)
assert request_body(pre_call) == server.requests[0].body
@pytest.mark.asyncio
async def test_pre_api_call_edits_reach_the_provider(server: RecordingServer) -> None:
class Edit(CustomLogger):
def log_pre_api_call(self, model: str, messages: object, kwargs: dict[str, object]) -> None:
request_body(kwargs)["temperature"] = 0.25
response: Final = await call(server, callbacks=[Edit()])
assert_served_natively(server, response)
assert isinstance(server.requests[0].body, dict)
assert server.requests[0].body["temperature"] == 0.25
@pytest.mark.asyncio
async def test_pre_api_call_sees_the_shaped_optional_params(server: RecordingServer) -> None:
recorder: Final = RecordingLogger()
await call(server, callbacks=[recorder], temperature=0.2, top_k=3, drop_params=True)
sent: Final = server.requests[0].body
assert isinstance(sent, dict)
assert not {"temperature", "top_k"} & sent.keys()
pre_call: Final = recorder.wait_for("log_pre_api_call")[0].kwargs
assert isinstance(pre_call, dict)
optional_params: Final = pre_call["optional_params"]
assert isinstance(optional_params, dict)
assert not {"model", "messages", "temperature", "top_k"} & optional_params.keys()
assert optional_params["max_tokens"] == sent["max_tokens"]
@pytest.mark.asyncio
async def test_logging_hook_and_redaction_edits_reach_the_async_success_event(server: RecordingServer) -> None:
class Rewrite(HookRecorder):
async def async_logging_hook(
self, kwargs: dict[str, object], result: object, call_type: str
) -> tuple[dict[str, object], object]:
return {**kwargs, "logging_hook_ran": True}, result
def redact_standard_logging_payload_from_model_call_details(
self, model_call_details: dict[str, object]
) -> dict[str, object]:
return {**model_call_details, "redacted": True}
recorder: Final = Rewrite()
with rebound(litellm, "callbacks", [recorder]):
await call(server)
success: Final = await recorder.wait_for_async("async_log_success_event")
assert isinstance(success[0].kwargs, dict)
assert success[0].kwargs["logging_hook_ran"] is True
assert success[0].kwargs["redacted"] is True
@pytest.mark.asyncio
async def test_pre_call_deployment_hooks_compose_the_wire_request_in_registration_order(
server: RecordingServer,
) -> None:
class Rewrite(CustomLogger):
def __init__(self, marker: str) -> None:
super().__init__()
self.marker = marker
async def async_pre_call_deployment_hook(
self, kwargs: dict[str, object], call_type: object
) -> dict[str, object]:
headers: Final = _HEADERS.validate_python(kwargs["extra_headers"])
return {
**kwargs,
"extra_headers": {**headers, "x-deployment-hook": headers["x-deployment-hook"] + self.marker},
}
with rebound(litellm, "callbacks", [Rewrite("first"), Rewrite("-second")]):
response: Final = await call(server, extra_headers={"x-deployment-hook": "caller-", "x-kept": "unchanged"})
assert_served_natively(server, response)
assert server.requests[0].headers["x-deployment-hook"] == "caller-first-second"
assert server.requests[0].headers["x-kept"] == "unchanged"
@pytest.mark.asyncio
async def test_pre_call_deployment_rejection_logs_failure_without_provider_io(server: RecordingServer) -> None:
rejection: Final = litellm.BadRequestError(
message="deployment rejected", model="test-model", llm_provider="anthropic"
)
recorder: Final = RecordingLogger()
class Reject(CustomLogger):
async def async_pre_call_deployment_hook(
self, kwargs: dict[str, object], call_type: object
) -> dict[str, object]:
raise rejection
with rebound(litellm, "callbacks", [Reject(), recorder]):
with pytest.raises(litellm.BadRequestError, match="deployment rejected") as raised:
await call(server)
failures: Final = await recorder.wait_for_async("async_log_failure_event")
assert raised.value is rejection
assert server.requests == []
assert len(failures) == 1
assert _OBJECT.validate_python(failures[0].kwargs)["exception"] is raised.value
assert not {"log_pre_api_call", "log_success_event", "async_log_success_event"} & set(recorder.names)
@pytest.mark.asyncio
async def test_pre_call_deployment_hook_can_delete_keywords(server: RecordingServer) -> None:
class DropKeywords(CustomLogger):
async def async_pre_call_deployment_hook(
self, kwargs: dict[str, object], call_type: object
) -> dict[str, object]:
return {name: value for name, value in kwargs.items() if name not in ("system", "extra_headers")}
with rebound(litellm, "callbacks", [DropKeywords()]):
response: Final = await call(
server,
system="remove this instruction",
extra_headers={"x-deleted": "remove this header"},
headers={"x-kept": "keep this header"},
)
assert_served_natively(server, response)
sent: Final = server.requests[0]
assert isinstance(sent.body, dict)
assert "system" not in sent.body
assert "x-deleted" not in sent.headers
assert sent.headers["x-kept"] == "keep this header"
@pytest.mark.asyncio
async def test_post_call_success_deployment_hook_replaces_the_response(server: RecordingServer) -> None:
recorder: Final = RecordingLogger()
class Replace(CustomLogger):
async def async_post_call_success_deployment_hook(
self, request_data: dict[str, object], response: object, call_type: object
) -> object:
return with_id(response, "replaced")
with rebound(litellm, "callbacks", [Replace(), recorder]):
response: Final = await call(server)
success: Final = await recorder.wait_for_async("async_log_success_event")
assert_served_natively(server, response)
assert response_id(response) == "replaced"
assert len(success) == 1
assert isinstance(success[0].response, ModelResponse)
assert success[0].response.id == response_id(response)
@pytest.mark.asyncio
async def test_concurrent_calls_keep_callback_context_metadata_and_responses_isolated(server: RecordingServer) -> None:
context: Final = ContextVar("messages-callback-context", default="outside")
first_entered: Final = asyncio.Event()
second_entered: Final = asyncio.Event()
server.enqueue(ResponseSpec(body={**MESSAGES_RESPONSE, "id": "msg_first"}))
server.enqueue(ResponseSpec(body={**MESSAGES_RESPONSE, "id": "msg_second"}))
class Observe(RecordingLogger):
async def async_pre_call_deployment_hook(
self, kwargs: dict[str, object], call_type: object
) -> dict[str, object]:
arrived, other = (
(first_entered, second_entered)
if kwargs["litellm_call_id"] == "first"
else (second_entered, first_entered)
)
arrived.set()
await asyncio.wait_for(other.wait(), timeout=5)
return kwargs
def log_pre_api_call(self, model: object, messages: object, kwargs: dict[str, object]) -> None:
self._record("log_pre_api_call", {**kwargs, "caller_context": context.get()})
async def async_log_success_event(
self, kwargs: dict[str, object], response_obj: object, start_time: datetime, end_time: datetime
) -> None:
self._record("async_log_success_event", {**kwargs, "caller_context": context.get()}, response_obj)
recorder: Final = Observe()
async def invoke(marker: str) -> object:
token: Final = context.set(marker)
try:
return await call(server, litellm_call_id=marker, metadata={"marker": marker})
finally:
context.reset(token)
with rebound(litellm, "callbacks", [recorder]):
responses: Final = await asyncio.gather(invoke("first"), invoke("second"))
success: Final = await recorder.wait_for_async("async_log_success_event", count=2)
assert_served_natively(server, *responses)
expected: Final = {"first": responses[0], "second": responses[1]}
pre_call: Final = recorder.wait_for("log_pre_api_call", count=2)
assert len(pre_call) == len(success) == 2
assert {_OBJECT.validate_python(event.kwargs)["litellm_call_id"] for event in success} == expected.keys()
for event in (*pre_call, *success):
details: Final = _OBJECT.validate_python(event.kwargs)
call_id: Final = str(details["litellm_call_id"])
params: Final = _OBJECT.validate_python(details["litellm_params"])
assert details["caller_context"] == call_id
assert _OBJECT.validate_python(params["metadata"])["marker"] == call_id
for event in success:
logged_call_id: Final = str(_OBJECT.validate_python(event.kwargs)["litellm_call_id"])
assert isinstance(event.response, ModelResponse)
assert event.response.id == response_id(expected[logged_call_id])
assert context.get() == "outside"
@pytest.mark.asyncio
async def test_a_failing_logger_does_not_fail_the_call(server: RecordingServer) -> None:
class Broken(CustomLogger):
def log_pre_api_call(self, model: str, messages: object, kwargs: dict[str, object]) -> None:
raise RuntimeError("logger exploded")
response: Final = await call(server, callbacks=[Broken()])
assert_served_natively(server, response)
assert response_id(response) == MESSAGES_RESPONSE["id"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"event,asynchronous,dynamic,expected",
(
pytest.param("input", False, False, 1, id="global-input"),
pytest.param("success", True, False, 1, id="global-async-success"),
pytest.param("success", True, True, 1, id="request-async-success"),
pytest.param("success", False, False, 0, id="global-sync-success", marks=SYNC_HANDLERS_ON_ASYNC_CALLS),
pytest.param("success", False, True, 0, id="request-sync-success", marks=SYNC_HANDLERS_ON_ASYNC_CALLS),
pytest.param("failure", True, False, 1, id="global-async-failure"),
pytest.param("failure", False, False, 1, id="global-sync-failure"),
pytest.param("failure", False, True, 1, id="request-sync-failure"),
),
)
async def test_function_callback_fires_the_expected_number_of_times(
server: RecordingServer, event: str, asynchronous: bool, dynamic: bool, expected: int
) -> None:
recorder: Final = RecordingLogger()
def callback(kwargs: dict[str, object], *_: object) -> None:
recorder._record(event, kwargs)
async def async_callback(kwargs: dict[str, object], *_: object) -> None:
recorder._record(event, kwargs)
registered: Final = async_callback if asynchronous else callback
global_list: Final = f"_async_{event}_callback" if asynchronous else f"{event}_callback"
request_callbacks: Final = {f"{event}_callback": [registered]} if dynamic else {}
if event == "failure":
server.default_response = ResponseSpec(body=BAD_REQUEST, status=400)
with rebound(litellm, "callbacks", [recorder]), rebound(litellm, global_list, [] if dynamic else [registered]):
try:
await call(server, **request_callbacks)
except litellm.BadRequestError:
assert event == "failure"
await recorder.wait_for_async(f"async_log_{'failure' if event == 'failure' else 'success'}_event")
assert_served_natively(server)
assert recorder.names.count(event) == expected
@pytest.mark.asyncio
async def test_pre_call_rule_rejects_before_provider_io(server: RecordingServer) -> None:
checked: Final[list[str]] = []
def reject(text: str) -> bool:
checked.append(text)
return False
with rebound(litellm, "pre_call_rules", [reject]):
with pytest.raises(litellm.APIResponseValidationError):
await call(server)
assert checked == ["Hello"]
assert server.requests == []
@pytest.mark.asyncio
async def test_dataset_hook_returning_none_drops_the_record_from_the_success_sink(server: RecordingServer) -> None:
datasets: Final = RecordingLogger()
class Drop(CustomLogger):
async def async_dataset_hook(
self, logged_data: ArgillaItem, standard_logging_payload: StandardLoggingPayload | None
) -> None:
datasets._record("async_dataset_hook", response=logged_data)
server.enqueue(ResponseSpec(body={"items": [{"id": "callback-dataset"}]}))
with rebound(litellm, "argilla_transformation_object", {"prompt": "messages", "answer": "response"}):
sink: Final = ArgillaLogger(
argilla_api_key="test-key",
argilla_base_url=server.base_url,
argilla_dataset_name="callback-dataset",
flush_interval=3600,
)
with rebound(litellm, "callbacks", [sink, Drop()]):
await call(server)
await datasets.wait_for_async("async_dataset_hook")
await sink.flush_queue()
assert [request.method for request in server.requests] == ["GET", "POST"]
@pytest.mark.asyncio
async def test_sync_call_returns_the_provider_message(server: RecordingServer) -> None:
recorder: Final = RecordingLogger()
response: Final = await call(server, asynchronous=False, callbacks=[recorder])
assert_served_natively(server, response)
assert response_id(response) == MESSAGES_RESPONSE["id"]
assert len(await recorder.wait_for_async("log_success_event")) == 1