test(ocr): clarify retained Rust contracts

This commit is contained in:
Yujong Lee 2026-09-09 11:57:34 -07:00
parent 348d0aea66
commit fc0dd1d904
30 changed files with 161 additions and 4137 deletions

View file

@ -6,11 +6,9 @@ on:
- "litellm-rust/**"
- "litellm/rust_bridge/**"
- "tests/test_litellm_rust/**"
- "litellm/integrations/**"
- "litellm/integrations/custom_logger.py"
- "litellm/litellm_core_utils/litellm_logging.py"
- "litellm/litellm_core_utils/logging_worker.py"
- "litellm/proxy/guardrails/**"
- "litellm/proxy/common_request_processing.py"
- "litellm/utils.py"
- "litellm/ocr/**"
- "litellm/llms/base_llm/ocr/**"
@ -37,11 +35,9 @@ on:
- "litellm-rust/**"
- "litellm/rust_bridge/**"
- "tests/test_litellm_rust/**"
- "litellm/integrations/**"
- "litellm/integrations/custom_logger.py"
- "litellm/litellm_core_utils/litellm_logging.py"
- "litellm/litellm_core_utils/logging_worker.py"
- "litellm/proxy/guardrails/**"
- "litellm/proxy/common_request_processing.py"
- "litellm/utils.py"
- "litellm/ocr/**"
- "litellm/llms/base_llm/ocr/**"

View file

@ -1,24 +0,0 @@
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Final
from prometheus_client import REGISTRY, CollectorRegistry
def clear_prometheus_registry() -> None:
for collector in tuple(REGISTRY._collector_to_names): # pyright: ignore[reportPrivateUsage] # prometheus_client has no public collector enumeration
REGISTRY.unregister(collector)
@contextmanager
def isolated_prometheus_registry(registry: CollectorRegistry = REGISTRY) -> Iterator[None]:
original: Final = tuple(registry._collector_to_names) # pyright: ignore[reportPrivateUsage] # prometheus_client has no public collector enumeration
for collector in original:
registry.unregister(collector)
try:
yield
finally:
for collector in tuple(registry._collector_to_names): # pyright: ignore[reportPrivateUsage] # remove collectors created inside the isolation scope
registry.unregister(collector)
for collector in original:
registry.register(collector)

View file

@ -1,15 +1,13 @@
# Rust bridge tests
# Rust OCR bridge tests
Put API-specific tests under `ocr/`, `messages/`, or `chat/`. Keep request construction, recording servers, callback recorders, and state isolation in `support/`. Under `integrations/`, keep registry inventory in `test_catalogue.py`, Python/Rust comparisons in `test_backend_parity.py`, callback ownership and completion in `test_callback_lifecycle.py`, individual exporters in `test_exporters.py`, and only multi-integration interactions in `test_composition.py`
This suite covers OCR requests through LiteLLM's compiled Rust extension. OCR behavior tests live under `ocr/`; reusable OCR request, callback, and recording-server fixtures live under `support/`
Use `backend` with `"python"` and `"rust"` for parity tests. A test name must identify the observer and expected result. Keep multiple assertions together only when they are consequences of one mutation, failure, scheduling, or lifecycle event. Record callback observations and assert them after the callback returns because production logging can swallow callback exceptions. Shared route parametrization belongs in `support/routes.py` only when its observable contract is the same for every route
A test name identifies the OCR entrypoint or callback under test and its expected observable result. Parameter IDs state the execution mode or credential case. Keep multiple assertions together only when they prove one request, mutation, failure, or callback lifecycle behavior. Record callback observations and assert them after the callback returns because production logging can swallow callback exceptions
The catalogue stores labels for selected behavioral cases. Those labels are inventory metadata and do not prove that matching pytest cases exist or execute
`ocr/test_requests.py` covers provider payloads, endpoint and credential resolution, normalized responses, errors, timeouts, and Azure token-provider behavior. `ocr/test_callbacks.py` covers OCR callback inputs, mutations, ordering, context, failure handling, concurrency, and cleanup. `ocr/test_dispatch.py` covers public sync and async native dispatch, explicit Python dispatch, fallback, and the native compression header. `test_ocr.py` is the strict wire-level smoke test
Run `make test-rust-extension` as the acceptance command. It builds a fresh wheel, installs that wheel into a temporary environment, requires `LITELLM_RUST=1`, and runs this suite with isolated Python imports
Collection fails when `LITELLM_RUST=1` is set but the compiled `_native` module cannot be imported. The autouse fixture selects Rust unless a parity or fallback case explicitly selects Python. Backend selection alone does not prove native execution because a public route can fall back. Tests that claim native request coverage must also assert either the `x-litellm-rust` response marker or a wire property that distinguishes the native client. `test_public_ocr_executes_the_compiled_extension_without_python_fallback` is strict and its server rejects Python HTTPX requests
Collection fails when `LITELLM_RUST=1` is set but the compiled `_native` module cannot be imported. The autouse fixture selects Rust for every test unless a fallback or parity case explicitly selects Python. Backend selection alone does not prove native execution because a public OCR request can fall back. Tests that claim native dispatch assert either the Rust response marker or a wire property that distinguishes the native client. `test_public_ocr_executes_the_compiled_extension_without_python_fallback` is strict and its server rejects Python HTTPX requests
The retained callback contract modules are marked as non-strict expected failures until the implementation from #40070 lands. Harness tests and the compiled-extension OCR smoke test remain strict. Passing contract cases appear as XPASS so staging coverage stays visible
`isolated_backend` restores the previous backend override and callback state on exit, including after exceptions or cancellation. Scopes can nest in the same task. Run backend comparisons sequentially: overlapping scopes in different tasks raise before changing process-global state. Use separate worker processes for parallel backend comparisons
The OCR contract modules are non-strict expected failures until the retained callback implementation from #40070 lands. The compiled-extension OCR smoke test remains strict. Passing contract cases appear as XPASS so staging coverage stays visible

View file

@ -1 +0,0 @@

View file

@ -1,470 +0,0 @@
import hashlib
import hmac
import json
from typing import Final
from urllib.parse import urlsplit
import pytest
import litellm
from tests.test_litellm_rust.support.recording_server import RecordedRequest, RecordingServer, ResponseSpec
pytestmark = pytest.mark.requires_rust_extension
def _verify_sigv4(request: RecordedRequest, secret_key: str) -> None:
authorization: Final = request.headers["authorization"]
algorithm, attributes_text = authorization.split(" ", 1)
attributes: Final = dict(item.split("=", 1) for item in attributes_text.split(", "))
credential_scope: Final = attributes["Credential"].split("/", 1)[1]
signed_names: Final = attributes["SignedHeaders"].split(";")
canonical_headers: Final = "".join(f"{name}:{' '.join(request.headers[name].split())}\n" for name in signed_names)
parsed_path: Final = urlsplit(request.path)
canonical_request: Final = "\n".join(
(
request.method,
parsed_path.path,
parsed_path.query,
canonical_headers,
attributes["SignedHeaders"],
hashlib.sha256(request.raw_body).hexdigest(),
)
)
amz_date: Final = request.headers["x-amz-date"]
string_to_sign: Final = "\n".join(
(algorithm, amz_date, credential_scope, hashlib.sha256(canonical_request.encode()).hexdigest())
)
date, region, service, terminator = credential_scope.split("/")
date_key: Final = hmac.new(f"AWS4{secret_key}".encode(), date.encode(), hashlib.sha256).digest()
region_key: Final = hmac.new(date_key, region.encode(), hashlib.sha256).digest()
service_key: Final = hmac.new(region_key, service.encode(), hashlib.sha256).digest()
signing_key: Final = hmac.new(service_key, terminator.encode(), hashlib.sha256).digest()
expected: Final = hmac.new(signing_key, string_to_sign.encode(), hashlib.sha256).hexdigest()
assert hmac.compare_digest(attributes["Signature"], expected)
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("rebind_logging_view", [False, True], ids=["in-place-view", "rebound-view"])
@pytest.mark.parametrize("native", [False, True], ids=["python", "rust"])
async def test_anthropic_pre_call_body_and_header_mutations_reach_provider(
recording_server: RecordingServer,
asynchronous: bool,
rebind_logging_view: bool,
native: bool,
) -> None:
litellm.rust(native)
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger
from tests.test_litellm_rust.support.requests import MESSAGES_RESPONSE
recording_server.default_response = ResponseSpec(body=MESSAGES_RESPONSE)
callback_received_dict: Final = []
class EditingLogger(RecordingLogger):
def log_pre_api_call(self, model, messages, kwargs):
super().log_pre_api_call(model, messages, kwargs)
body = kwargs["additional_args"]["complete_input_dict"]
headers = kwargs["additional_args"]["headers"]
callback_received_dict.append(isinstance(body, dict))
if not isinstance(body, dict):
return
body["messages"][0]["content"][0]["text"] = "edited by callback"
body["max_tokens"] = 32
headers["x-retained-callback"] = "original"
if rebind_logging_view:
kwargs["additional_args"]["complete_input_dict"] = {"replacement": True}
kwargs["additional_args"]["headers"] = {"x-retained-callback": "replacement"}
recorder: Final = EditingLogger()
kwargs: Final = {
"model": "anthropic/claude-opus-5",
"messages": [{"role": "user", "content": "original"}],
"max_tokens": 64,
"api_key": "test-key",
"api_base": recording_server.base_url,
"callbacks": [recorder],
"num_retries": 0,
}
response: Final = await litellm.acompletion(**kwargs) if asynchronous else litellm.completion(**kwargs)
assert response._hidden_params.get("additional_headers", {}).get("x-litellm-rust") == ("true" if native else None)
assert callback_received_dict == [True]
assert recorder.names.count("log_pre_api_call") == 1
assert recording_server.requests[0].body["messages"][0]["content"][0]["text"] == "edited by callback"
assert recording_server.requests[0].headers["x-retained-callback"] == "original"
assert recording_server.requests[0].body["max_tokens"] == 32
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("rebind_logging_view", [False, True], ids=["in-place-view", "rebound-view"])
@pytest.mark.parametrize("native", [False, True], ids=["python", "rust"])
async def test_bedrock_pre_call_rebinding_does_not_replace_signed_transport(
recording_server: RecordingServer,
asynchronous: bool,
rebind_logging_view: bool,
native: bool,
) -> None:
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger
litellm.rust(native)
recording_server.default_response = ResponseSpec(
body={
"output": {"message": {"role": "assistant", "content": [{"text": "native chat"}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 5, "outputTokens": 4, "totalTokens": 9},
"metrics": {"latencyMs": 1},
}
)
callback_body_text: Final = []
class EditingLogger(RecordingLogger):
def log_pre_api_call(self, model, messages, kwargs):
super().log_pre_api_call(model, messages, kwargs)
additional: Final = kwargs["additional_args"]
body: Final = additional["complete_input_dict"]
callback_body_text.append(
json.loads(body)["messages"][0]["content"][0]["text"] if isinstance(body, str) else None
)
additional["headers"]["x-retained-callback"] = "original"
if rebind_logging_view:
additional["complete_input_dict"] = {"replacement": True}
additional["headers"] = {"x-retained-callback": "replacement"}
recorder: Final = EditingLogger()
request: Final = {
"model": "bedrock/anthropic.claude-opus-5",
"messages": [{"role": "user", "content": "original"}],
"max_tokens": 64,
"api_base": recording_server.base_url,
"callbacks": [recorder],
"num_retries": 0,
"aws_access_key_id": "test-access",
"aws_secret_access_key": "test-secret",
"aws_region_name": "us-east-1",
}
response: Final = await litellm.acompletion(**request) if asynchronous else litellm.completion(**request)
recorded: Final = recording_server.requests[0]
assert response._hidden_params.get("additional_headers", {}).get("x-litellm-rust") == ("true" if native else None)
assert callback_body_text == ["original"]
assert recorder.names.count("log_pre_api_call") == 1
assert recorded.body["messages"][0]["content"][0]["text"] == "original"
assert "replacement" not in recorded.body
assert recorded.headers["x-retained-callback"] == "original"
assert recorded.body["inferenceConfig"]["maxTokens"] == 64
assert recorded.headers["authorization"].startswith("AWS4-HMAC-SHA256 ")
_verify_sigv4(recorded, "test-secret")
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("provider", ["anthropic", "bedrock"])
@pytest.mark.parametrize("native", [False, True], ids=["python", "rust"])
async def test_chat_provider_failure_reaches_one_failure_callback(
recording_server: RecordingServer,
asynchronous: bool,
provider: str,
native: bool,
) -> None:
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger
litellm.rust(native)
recording_server.default_response = ResponseSpec(status=429, body={"message": "rate limited"})
recorder: Final = RecordingLogger()
request: Final = {
"model": "anthropic/claude-opus-5" if provider == "anthropic" else "bedrock/anthropic.claude-opus-5",
"messages": [{"role": "user", "content": "original"}],
"max_tokens": 64,
"api_key": "test-key",
"api_base": recording_server.base_url,
"callbacks": [recorder],
"num_retries": 0,
**(
{"aws_access_key_id": "test", "aws_secret_access_key": "test", "aws_region_name": "us-east-1"}
if provider == "bedrock"
else {}
),
}
with pytest.raises((litellm.APIError, litellm.RateLimitError)) as raised:
await litellm.acompletion(**request) if asynchronous else litellm.completion(**request)
failure_event: Final = "async_log_failure_event" if asynchronous else "log_failure_event"
failures: Final = await recorder.wait_for_async(failure_event)
assert raised.value.status_code == 429
assert failures[0].kwargs["exception"] is raised.value
assert recorder.names.count(failure_event) == 1
assert recorder.names.count("log_pre_api_call") == 1
assert len(recording_server.requests) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("status", [200, 429])
@pytest.mark.parametrize("native", [False, True], ids=["python", "rust"])
async def test_bedrock_callbacks_share_state_without_replacing_signed_transport(
recording_server: RecordingServer,
monkeypatch: pytest.MonkeyPatch,
asynchronous: bool,
status: int,
native: bool,
) -> None:
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger
litellm.rust(native)
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
recording_server.default_response = ResponseSpec(
status=status,
body={
"output": {"message": {"role": "assistant", "content": [{"text": "native chat"}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 5, "outputTokens": 4, "totalTokens": 9},
"metrics": {"latencyMs": 1},
},
)
observations: Final[list[tuple[int, int]]] = []
shared_state: Final[list[tuple[object, object, object]]] = []
replacement_body: Final = {"replacement": True}
rebound_headers: Final = {"x-callback-header": "rebound"}
class FirstCallback(RecordingLogger):
def log_pre_api_call(self, model, messages, kwargs):
additional: Final = kwargs["additional_args"]
callback_body: Final = additional["complete_input_dict"]
original_headers: Final = additional["headers"]
observations.append((id(kwargs), id(additional)))
shared_state.append(
(
isinstance(callback_body, str),
json.loads(callback_body)["messages"][0]["content"][0]["text"]
if isinstance(callback_body, str)
else None,
None,
)
)
kwargs["callback_marker"] = "visible"
original_headers["x-callback-header"] = "in-place"
additional["complete_input_dict"] = replacement_body
additional["headers"] = rebound_headers
raise RuntimeError("first callback failure is non-blocking")
class SecondCallback(RecordingLogger):
def log_pre_api_call(self, model, messages, kwargs):
additional: Final = kwargs["additional_args"]
observations.append((id(kwargs), id(additional)))
shared_state.append(
(
kwargs.get("callback_marker"),
additional["complete_input_dict"] is replacement_body,
additional["headers"] is rebound_headers,
)
)
request: Final = {
"model": "bedrock/anthropic.claude-opus-5",
"messages": [{"role": "user", "content": "original"}],
"max_tokens": 64,
"api_base": recording_server.base_url,
"callbacks": [FirstCallback(), SecondCallback()],
"num_retries": 0,
"aws_access_key_id": "test-access",
"aws_secret_access_key": "test-secret",
"aws_region_name": "us-east-1",
}
if status == 200:
await litellm.acompletion(**request) if asynchronous else litellm.completion(**request)
else:
with pytest.raises((litellm.APIError, litellm.RateLimitError)):
await litellm.acompletion(**request) if asynchronous else litellm.completion(**request)
assert observations[0] == observations[1]
assert shared_state == [(True, "original", None), ("visible", True, True)]
recorded: Final = recording_server.requests[0]
assert recorded.body["messages"][0]["content"][0]["text"] == "original"
assert "replacement" not in recorded.body
assert recorded.headers["x-callback-header"] == "in-place"
assert recorded.headers["authorization"].startswith("AWS4-HMAC-SHA256 ")
_verify_sigv4(recorded, "test-secret")
@pytest.mark.asyncio
async def test_native_chat_pre_call_preserves_the_caller_task_and_context(
recording_server: RecordingServer,
) -> None:
import asyncio
from contextvars import ContextVar
from litellm.integrations.custom_logger import CustomLogger
from tests.test_litellm_rust.support.requests import MESSAGES_RESPONSE
recording_server.default_response = ResponseSpec(body=MESSAGES_RESPONSE)
marker: Final[ContextVar[str]] = ContextVar("native_chat_marker", default="missing")
marker.set("caller")
caller_task: Final = asyncio.current_task()
observations: Final = []
class Observe(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
observations.append((asyncio.current_task(), marker.get()))
marker.set("callback")
response: Final = await litellm.acompletion(
model="anthropic/claude-opus-5",
messages=[{"role": "user", "content": "task context"}],
max_tokens=16,
api_key="test-key",
api_base=recording_server.base_url,
callbacks=[Observe()],
num_retries=0,
)
assert response._hidden_params["additional_headers"]["x-litellm-rust"] == "true"
assert observations == [(caller_task, "caller")]
assert marker.get() == "callback"
@pytest.mark.asyncio
async def test_chat_concurrent_calls_keep_callback_roots_isolated(
recording_server: RecordingServer,
) -> None:
import asyncio
import threading
from litellm.integrations.custom_logger import CustomLogger
from tests.test_litellm_rust.support.callback_recorder import drain_logging
from tests.test_litellm_rust.support.requests import MESSAGES_RESPONSE
call_ids: Final = tuple(f"chat-{index}" for index in range(4))
recording_server.expected_requests = len(call_ids)
accepted: Final = tuple(threading.Event() for _ in call_ids)
release: Final = threading.Event()
for signal in accepted:
recording_server.enqueue(ResponseSpec(body=MESSAGES_RESPONSE, accepted=signal, release_before_response=release))
tokens: Final = {call_id: object() for call_id in call_ids}
terminal_state: Final = []
class Correlate(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
body: Final = kwargs["additional_args"]["complete_input_dict"]
body["messages"][0]["content"][0]["text"] = f"callback-{kwargs['litellm_call_id']}"
kwargs["correlation-token"] = tokens[kwargs["litellm_call_id"]]
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
terminal_state.append((kwargs["litellm_call_id"], kwargs["correlation-token"]))
logger: Final = Correlate()
async def invoke(call_id: str) -> object:
return await litellm.acompletion(
model="anthropic/claude-opus-5",
messages=[{"role": "user", "content": f"original-{call_id}"}],
max_tokens=16,
api_key="test-key",
api_base=recording_server.base_url,
callbacks=[logger],
litellm_call_id=call_id,
num_retries=0,
)
tasks: Final = tuple(asyncio.create_task(invoke(call_id)) for call_id in call_ids)
try:
assert all(await asyncio.gather(*(asyncio.to_thread(signal.wait, 3) for signal in accepted)))
finally:
release.set()
responses: Final = await asyncio.gather(*tasks)
await drain_logging()
sent: Final = {request.body["messages"][0]["content"][0]["text"] for request in recording_server.requests}
assert sent == {f"callback-{call_id}" for call_id in call_ids}
assert len(terminal_state) == len(call_ids)
assert all(token is tokens[call_id] for call_id, token in terminal_state)
assert all(response._hidden_params["additional_headers"]["x-litellm-rust"] == "true" for response in responses)
@pytest.mark.asyncio
async def test_native_chat_callback_can_make_a_nested_native_call(
recording_server: RecordingServer,
) -> None:
import threading
from litellm.integrations.custom_logger import CustomLogger
from tests.test_litellm_rust.support.requests import MESSAGES_RESPONSE
recording_server.expected_requests = 2
recording_server.default_response = ResponseSpec(body=MESSAGES_RESPONSE)
nested_responses: Final = []
nested_started: Final = threading.Event()
class NestedCall(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
if nested_started.is_set():
return
nested_started.set()
nested_responses.append(
litellm.completion(
model="anthropic/claude-opus-5",
messages=[{"role": "user", "content": "nested"}],
max_tokens=16,
api_key="test-key",
api_base=recording_server.base_url,
num_retries=0,
)
)
outer: Final = await litellm.acompletion(
model="anthropic/claude-opus-5",
messages=[{"role": "user", "content": "outer"}],
max_tokens=16,
api_key="test-key",
api_base=recording_server.base_url,
callbacks=[NestedCall()],
num_retries=0,
)
assert outer._hidden_params["additional_headers"]["x-litellm-rust"] == "true"
assert nested_responses[0]._hidden_params["additional_headers"]["x-litellm-rust"] == "true"
sent: Final = {request.body["messages"][0]["content"][0]["text"] for request in recording_server.requests}
assert sent == {"outer", "nested"}
@pytest.mark.asyncio
async def test_chat_cancellation_during_io_does_not_publish_a_terminal(
recording_server: RecordingServer,
) -> None:
import asyncio
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger
from tests.test_litellm_rust.support.requests import MESSAGES_RESPONSE
recording_server.default_response = ResponseSpec(body=MESSAGES_RESPONSE, delay=0.5)
recorder: Final = RecordingLogger()
task: Final = asyncio.create_task(
litellm.acompletion(
model="anthropic/claude-opus-5",
messages=[{"role": "user", "content": "cancel"}],
max_tokens=16,
api_key="test-key",
api_base=recording_server.base_url,
callbacks=[recorder],
num_retries=0,
)
)
async with asyncio.timeout(10):
while not recording_server.requests:
await asyncio.sleep(0.01)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=10)
assert recorder.names.count("log_pre_api_call") == 1
assert not {
"log_success_event",
"async_log_success_event",
"log_failure_event",
"async_log_failure_event",
}.intersection(recorder.names)

View file

@ -1,17 +1,13 @@
import asyncio
import os
from collections.abc import AsyncIterator, Generator
from collections.abc import AsyncIterator, Generator, Iterator
from concurrent.futures import ThreadPoolExecutor
from contextlib import ExitStack, asynccontextmanager, contextmanager
from threading import Lock
from contextlib import ExitStack, contextmanager
from types import ModuleType
from typing import Final, Literal, cast
from typing import Final, cast
import pytest
import pytest_asyncio
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
import litellm
from litellm import utils
@ -21,7 +17,6 @@ from litellm.rust_bridge.configuration import ( # pyright: ignore[reportPrivate
_CONFIGURATION,
_parse_env_bool,
)
from tests._prometheus_helpers import isolated_prometheus_registry
from tests.test_litellm_rust.support.callback_recorder import drain_logging
from tests.test_litellm_rust.support.recording_server import RecordingServer, recording_service
@ -34,46 +29,11 @@ CALLBACK_ATTRIBUTES: Final = (
"_async_success_callback",
"_async_failure_callback",
)
Backend = Literal["python", "rust"]
EXPECTED_FAILURE_FILES: Final = frozenset(
{
"chat/test_callback_mutation.py",
"integrations/test_backend_parity.py",
"integrations/test_callback_lifecycle.py",
"integrations/test_composition.py",
"integrations/test_exporters.py",
"integrations/test_guardrails.py",
"messages/test_callback_mutation.py",
"messages/test_streaming.py",
"ocr/test_callbacks.py",
"ocr/test_dispatch.py",
"ocr/test_requests.py",
"test_provenance.py",
}
)
class _BackendScopes:
def __init__(self) -> None:
self._owner: asyncio.Task[object] | None = None
self._lock: Final = Lock()
@contextmanager
def enter(self) -> Generator[None]:
task: Final = asyncio.current_task()
with self._lock:
previous: Final = self._owner
if previous is not None and previous is not task:
raise RuntimeError("isolated_backend scopes cannot overlap across tasks; run them sequentially")
self._owner = task
try:
yield
finally:
with self._lock:
self._owner = previous
_BACKEND_SCOPES: Final = _BackendScopes()
EXPECTED_FAILURE_REASONS: Final = {
"ocr/test_callbacks.py": "requires the OCR callback lifecycle implementation from #40070",
"ocr/test_dispatch.py": "requires the OCR native dispatch implementation from #40070",
"ocr/test_requests.py": "requires the OCR request and Azure authentication implementation from #40070",
}
def _list_attribute(container: ModuleType, attribute: str) -> list[object]:
@ -84,7 +44,7 @@ def _list_attribute(container: ModuleType, attribute: str) -> list[object]:
@contextmanager
def _isolated_list(container: ModuleType, attribute: str) -> Generator[None]:
def _isolated_list(container: ModuleType, attribute: str) -> Iterator[None]:
source: Final = _list_attribute(container, attribute)
original: Final = list(source)
source.clear() # mutable-ok: test isolation mutates global registries by design
@ -97,7 +57,7 @@ def _isolated_list(container: ModuleType, attribute: str) -> Generator[None]:
@contextmanager
def _rebound(container: object, attribute: str, value: object) -> Generator[None]:
def _rebound(container: object, attribute: str, value: object) -> Iterator[None]:
original: Final[object] = getattr(container, attribute)
setattr(container, attribute, value)
try:
@ -107,36 +67,24 @@ def _rebound(container: object, attribute: str, value: object) -> Generator[None
@contextmanager
def _rust_mode(enabled: bool) -> Generator[None]:
def _rust_mode(enabled: bool) -> Iterator[None]:
with _rebound(_CONFIGURATION, "override", enabled):
yield
@asynccontextmanager
async def isolated_backend(backend: Backend) -> AsyncIterator[ExitStack]:
with _BACKEND_SCOPES.enter():
async with _isolated_backend(backend) as stack:
yield stack
@asynccontextmanager
async def _isolated_backend(backend: Backend) -> AsyncIterator[ExitStack]:
@pytest_asyncio.fixture(autouse=True, loop_scope="function")
async def isolate_ocr_test_state() -> AsyncIterator[None]:
with ExitStack() as stack:
for attribute in CALLBACK_ATTRIBUTES:
stack.enter_context(_isolated_list(litellm, attribute))
stack.enter_context(_isolated_list(litellm_logging, "_in_memory_loggers")) # pyright: ignore[reportPrivateUsage] # string-name callback cache has no public accessor
stack.enter_context(
_rebound(utils, "callback_list", [])
) # rebind-ok: legacy global registry mutated by set_callbacks
stack.enter_context(
_rebound(litellm, "cache", None)
) # test-quality-ok: isolate the process-global cache from native extension tests
stack.enter_context(isolated_prometheus_registry())
stack.enter_context(_rust_mode(backend == "rust"))
executor: Final = ThreadPoolExecutor(thread_name_prefix="rust-test-logging")
stack.enter_context(_isolated_list(litellm_logging, "_in_memory_loggers")) # pyright: ignore[reportPrivateUsage] # no public callback-cache accessor
stack.enter_context(_rebound(utils, "callback_list", [])) # rebind-ok: isolate legacy callback registry
stack.enter_context(_rebound(litellm, "cache", None)) # test-quality-ok: isolate process-global cache
stack.enter_context(_rust_mode(True))
executor: Final = ThreadPoolExecutor(thread_name_prefix="rust-ocr-test-logging")
stack.enter_context(_rebound(utils, "executor", executor))
try:
yield stack
yield
finally:
try:
await drain_logging()
@ -145,13 +93,6 @@ async def _isolated_backend(backend: Backend) -> AsyncIterator[ExitStack]:
await GLOBAL_LOGGING_WORKER.stop()
@pytest_asyncio.fixture(autouse=True, loop_scope="function")
async def isolate_rust_state() -> AsyncIterator[ExitStack]:
# pytest-asyncio runs fixture setup and the test body in different tasks.
async with _isolated_backend("rust") as stack:
yield stack
@pytest.fixture
def recording_server() -> Generator[RecordingServer]:
with recording_service() as server:
@ -159,16 +100,13 @@ def recording_server() -> Generator[RecordingServer]:
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
expected_failure: Final = pytest.mark.xfail(
reason="requires the retained callback implementation from #40070",
strict=False,
)
for item in items:
if "test_litellm_rust" not in item.path.parts:
continue
relative_path: Final = "/".join(item.path.parts[item.path.parts.index("test_litellm_rust") + 1 :])
if relative_path in EXPECTED_FAILURE_FILES:
item.add_marker(expected_failure)
reason: Final = EXPECTED_FAILURE_REASONS.get(relative_path)
if reason is not None:
item.add_marker(pytest.mark.xfail(reason=reason, strict=False))
if not _parse_env_bool(os.environ.get("LITELLM_RUST")):
skip: Final = pytest.mark.skip(reason="requires LITELLM_RUST=1 and a compiled Rust extension")

View file

@ -1,75 +0,0 @@
from tests.test_litellm_rust.integrations.catalogue import (
DISCOVERED_ONLY_GUARDRAIL_NAMES,
ENTERPRISE_LOGGER_NAMES,
GUARDRAIL_NAMES,
GUARDRAIL_OBLIGATIONS,
LOGGER_OBLIGATIONS,
OSS_LOGGER_NAMES,
REQUIRED_GUARDRAIL_BEHAVIOR,
REQUIRED_LOGGER_BEHAVIOR,
)
from tests.test_litellm_rust.integrations.harness import (
AsyncBoundaryLogger,
GCSLiteralAIHarness,
GenericAPIExportHarness,
MutatingFailingLogger,
OtelHarness,
PurviewHarness,
RecordingAsyncClient,
RecordingVertexInstance,
ReviewGuardrail,
RunObservation,
gcs_literalai_harness,
metric_value,
purview_harness,
wait_for_audits,
)
from tests.test_litellm_rust.support.routes import (
ALL_ROUTES,
ASYNC_ROUTES,
MESSAGES_ROUTE,
MESSAGES_STREAM,
NON_STREAM_ASYNC_ROUTES,
OCR_ASYNC,
OCR_SYNC,
Route,
provider_response,
route_id,
wait_for_callback,
)
__all__ = (
"ALL_ROUTES",
"ASYNC_ROUTES",
"DISCOVERED_ONLY_GUARDRAIL_NAMES",
"ENTERPRISE_LOGGER_NAMES",
"GUARDRAIL_NAMES",
"GUARDRAIL_OBLIGATIONS",
"LOGGER_OBLIGATIONS",
"MESSAGES_ROUTE",
"MESSAGES_STREAM",
"NON_STREAM_ASYNC_ROUTES",
"OCR_ASYNC",
"OCR_SYNC",
"OSS_LOGGER_NAMES",
"REQUIRED_GUARDRAIL_BEHAVIOR",
"REQUIRED_LOGGER_BEHAVIOR",
"AsyncBoundaryLogger",
"GCSLiteralAIHarness",
"GenericAPIExportHarness",
"MutatingFailingLogger",
"OtelHarness",
"PurviewHarness",
"RecordingAsyncClient",
"RecordingVertexInstance",
"ReviewGuardrail",
"Route",
"RunObservation",
"gcs_literalai_harness",
"metric_value",
"provider_response",
"purview_harness",
"route_id",
"wait_for_audits",
"wait_for_callback",
)

View file

@ -1,193 +0,0 @@
from collections.abc import Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, Literal
DependencyProfile = Literal["required", "enterprise"]
OSS_LOGGER_NAMES: Final = frozenset(
{
"agentops",
"anthropic_cache_control_hook",
"argilla",
"arize",
"arize_phoenix",
"aws_sqs",
"azure_sentinel",
"azure_storage",
"bitbucket",
"braintrust",
"cloudzero",
"datadog",
"datadog_llm_observability",
"datadog_metrics",
"deepeval",
"dotprompt",
"dynamic_rate_limiter",
"dynamic_rate_limiter_v3",
"focus",
"galileo",
"gcs_bucket",
"gcs_pubsub",
"gitlab",
"humanloop",
"lago",
"langfuse",
"langfuse_otel",
"langsmith",
"langtrace",
"levo",
"literalai",
"litellm_agent",
"logfire",
"mavvrik",
"mlflow",
"newrelic",
"opentelemetry",
"openmeter",
"opik",
"otel",
"posthog",
"prometheus",
"s3_v2",
"vantage",
"vector_store_pre_call_hook",
"weave_otel",
}
)
ENTERPRISE_LOGGER_NAMES: Final = frozenset({"generic_api", "pagerduty", "resend_email", "sendgrid_email", "smtp_email"})
GUARDRAIL_NAMES: Final = frozenset(
{
"aim",
"akto",
"alice",
"aporia",
"azure/prompt_shield",
"azure/text_moderations",
"bedrock",
"block_code_execution",
"cato_networks",
"cisco_ai_defense",
"compresr",
"crowdstrike_aidr",
"custom_code",
"deepkeep",
"dynamoai",
"enkryptai",
"generic_guardrail_api",
"grayswan",
"guardrails_ai",
"headroom",
"hiddenlayer",
"hide-secrets",
"ibm_guardrails",
"javelin",
"lakera",
"lakera_v2",
"lasso",
"litellm_content_filter",
"llm_as_a_judge",
"mcp_end_user_permission",
"mcp_jwt_signer",
"mcp_security",
"microsoft_purview",
"model_armor",
"noma",
"noma_v2",
"onyx",
"openai_moderation",
"ovalix",
"pangea",
"panw_prisma_airs",
"pillar",
"presidio",
"prompt_security",
"promptguard",
"qostodian_nexus",
"qualifire",
"repelloai",
"rubrik",
"semantic_guard",
"singulr",
"straiker",
"tool_permission",
"vigil_guard",
"xecguard",
"zscaler_ai_guard",
}
)
DISCOVERED_ONLY_GUARDRAIL_NAMES: Final = frozenset({"tool_policy"})
@dataclass(frozen=True, slots=True)
class CoverageObligation:
stable_name: str
registration_names: tuple[str, ...]
dependency_profile: DependencyProfile
behavioral_case_labels: tuple[str, ...]
REQUIRED_LOGGER_BEHAVIOR: Final = frozenset({"generic_api", "gcs_bucket", "literalai", "prometheus", "opentelemetry"})
REQUIRED_GUARDRAIL_BEHAVIOR: Final = frozenset(
{
"azure/text_moderations",
"crowdstrike_aidr",
"litellm_content_filter",
"microsoft_purview",
"rubrik",
}
)
LOGGER_BEHAVIORAL_CASES: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType(
{
"generic_api": ("generic-api-success",),
"gcs_bucket": ("gcs-literalai-scheduling",),
"literalai": ("gcs-literalai-scheduling",),
"prometheus": ("prometheus-string-registration",),
"opentelemetry": ("otel-export",),
}
)
GUARDRAIL_BEHAVIORAL_CASES: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType(
{
"azure/text_moderations": ("azure-text-moderation",),
"crowdstrike_aidr": ("crowdstrike-redaction-native-chat",),
"rubrik": ("rubrik-block-native-chat",),
"microsoft_purview": ("purview-audit-native-chat",),
"litellm_content_filter": ("content-filter-block",),
}
)
def _logger_obligations() -> Mapping[str, CoverageObligation]:
from litellm.litellm_core_utils.custom_logger_registry import CustomLoggerRegistry
registry: Final = CustomLoggerRegistry.CALLBACK_CLASS_STR_TO_CLASS_TYPE
expected_names: Final = OSS_LOGGER_NAMES | ENTERPRISE_LOGGER_NAMES
grouped: Final[dict[type[object], tuple[str, ...]]] = {
implementation: tuple(sorted(name for name in expected_names if registry.get(name) is implementation))
for implementation in frozenset(registry.values())
}
obligations: Final = {
names[0]: CoverageObligation(
stable_name=names[0],
registration_names=names,
dependency_profile="enterprise" if set(names) & ENTERPRISE_LOGGER_NAMES else "required",
behavioral_case_labels=next(
(LOGGER_BEHAVIORAL_CASES[name] for name in names if name in LOGGER_BEHAVIORAL_CASES), ()
),
)
for names in grouped.values()
if names
}
missing_optional: Final = ENTERPRISE_LOGGER_NAMES - registry.keys()
unavailable: Final = {name: CoverageObligation(name, (name,), "enterprise", ()) for name in missing_optional}
return MappingProxyType({**obligations, **unavailable})
LOGGER_OBLIGATIONS: Final = _logger_obligations()
GUARDRAIL_OBLIGATIONS: Final = MappingProxyType(
{
name: CoverageObligation(name, (name,), "required", GUARDRAIL_BEHAVIORAL_CASES.get(name, ()))
for name in GUARDRAIL_NAMES | DISCOVERED_ONLY_GUARDRAIL_NAMES
}
)

View file

@ -1,43 +0,0 @@
from contextlib import ExitStack
from typing import Final
import pytest
import pytest_asyncio
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from litellm.integrations.generic_api.generic_api_callback import GenericAPILogger
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
from litellm.integrations.prometheus import PrometheusLogger
from tests.test_litellm_rust.integrations import GenericAPIExportHarness, OtelHarness, Route, provider_response
from tests.test_litellm_rust.support.recording_server import RecordingServer, recording_service
@pytest.fixture
def provider(recording_server: RecordingServer, route: Route) -> RecordingServer:
recording_server.default_response = provider_response(route)
return recording_server
@pytest.fixture
def otel(isolate_rust_state: ExitStack) -> OtelHarness:
exporter: Final = InMemorySpanExporter()
tracer_provider: Final = TracerProvider()
isolate_rust_state.callback(tracer_provider.shutdown)
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))
logger: Final = OpenTelemetry(config=OpenTelemetryConfig(exporter=exporter), tracer_provider=tracer_provider)
return OtelHarness(logger=logger, exporter=exporter)
@pytest.fixture
def prometheus() -> PrometheusLogger:
return PrometheusLogger()
@pytest_asyncio.fixture
async def generic_api_export(isolate_rust_state: ExitStack) -> GenericAPIExportHarness:
server: Final = isolate_rust_state.enter_context(recording_service())
server.expected_requests = None
logger: Final = GenericAPILogger(endpoint=f"{server.base_url}/logs", batch_size=1, log_format="single")
return GenericAPIExportHarness(server=server, logger=logger)

View file

@ -1,203 +0,0 @@
import asyncio
import threading
import time
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Final
import httpx
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from prometheus_client import REGISTRY
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.gcs_bucket.gcs_bucket import GCSBucketLogger
from litellm.integrations.gcs_bucket.gcs_bucket_base import IAM_AUTH_KEY
from litellm.integrations.generic_api.generic_api_callback import GenericAPILogger
from litellm.integrations.literal_ai import LiteralAILogger
from litellm.integrations.opentelemetry import LITELLM_REQUEST_SPAN_NAME, OpenTelemetry
from litellm.proxy.guardrails.guardrail_hooks.microsoft_purview.purview_dlp import MicrosoftPurviewDLPGuardrail
from litellm.types.guardrails import GuardrailEventHooks
from tests.test_litellm_rust.support.callback_recorder import drain_logging
from tests.test_litellm_rust.support.recording_server import RecordedRequest, RecordingServer
@dataclass(frozen=True, slots=True)
class RunObservation:
call_type: str
model: str
response_cost: float
response_text: str
provider_body: Mapping[str, object]
rust_dispatch: bool
@dataclass(frozen=True, slots=True)
class RecordedPost:
url: str
headers: Mapping[str, str]
body: object
class RecordingAsyncClient:
def __init__(
self, responses: tuple[Mapping[str, object], ...] = (), blocked_url_fragment: str | None = None
) -> None:
self.posts: list[RecordedPost] = []
self._responses = list(responses)
self._blocked_url_fragment = blocked_url_fragment
self.accepted = threading.Event()
self.release = threading.Event()
self.release.set()
async def post(self, url: str, **kwargs: object) -> httpx.Response:
data: Final = kwargs.get("data")
json_body: Final = kwargs.get("json")
body: Final = json_body if json_body is not None else data
headers_value: Final = kwargs.get("headers")
headers: Final = headers_value if isinstance(headers_value, Mapping) else {}
self.posts.append(RecordedPost(url=url, headers=headers, body=body))
should_block: Final = self._blocked_url_fragment is None or self._blocked_url_fragment in url
if should_block:
self.accepted.set()
released: Final = await asyncio.to_thread(self.release.wait, 10)
if not released:
raise TimeoutError(f"Timed out releasing POST {url}")
payload: Final = self._responses.pop(0) if self._responses else {}
response_headers: Final = {"etag": '"test-scope"'} if "protectionScopes/compute" in url else {}
return httpx.Response(200, json=payload, headers=response_headers, request=httpx.Request("POST", url))
class RecordingVertexInstance:
async def _ensure_access_token_async(self, **kwargs: object) -> tuple[str, str]:
return "test-access-token", "test-project"
def _get_token_and_url(self, **kwargs: object) -> tuple[str, str]:
return "test-access-token", ""
class AsyncBoundaryLogger(CustomLogger):
def __init__(self, action: Callable[[], Awaitable[None]]) -> None:
self._action = action
async def async_log_success_event(
self, kwargs: object, response_obj: object, start_time: object, end_time: object
) -> None:
await self._action()
@dataclass(frozen=True, slots=True)
class OtelHarness:
logger: OpenTelemetry
exporter: InMemorySpanExporter
def spans(self, name: str = LITELLM_REQUEST_SPAN_NAME) -> tuple[ReadableSpan, ...]:
return tuple(span for span in self.exporter.get_finished_spans() if span.name == name)
async def wait_for_spans(
self, name: str = LITELLM_REQUEST_SPAN_NAME, count: int = 1, timeout: float = 10
) -> tuple[ReadableSpan, ...]:
deadline: Final = time.monotonic() + timeout
while len(self.spans(name)) < count:
if time.monotonic() >= deadline:
raise TimeoutError(f"Timed out waiting for {count} {name} spans; saw {self.spans(name)}")
await asyncio.sleep(0.01)
await drain_logging()
return self.spans(name)
@dataclass(frozen=True, slots=True)
class GCSLiteralAIHarness:
gcs: GCSBucketLogger
literal: LiteralAILogger
storage: RecordingAsyncClient
literal_sink: RecordingAsyncClient
@dataclass(frozen=True, slots=True)
class GenericAPIExportHarness:
server: RecordingServer
logger: GenericAPILogger
@property
def exports(self) -> tuple[RecordedRequest, ...]:
return tuple(request for request in self.server.requests if request.path == "/logs")
@asynccontextmanager
async def gcs_literalai_harness() -> AsyncIterator[GCSLiteralAIHarness]:
storage: Final = RecordingAsyncClient()
literal_sink: Final = RecordingAsyncClient()
gcs: Final = GCSBucketLogger(bucket_name="composition-bucket")
gcs.async_httpx_client = storage
gcs.vertex_instances[IAM_AUTH_KEY] = RecordingVertexInstance()
literal: Final = LiteralAILogger(literalai_api_key="test-key")
literal.async_httpx_client = literal_sink
try:
yield GCSLiteralAIHarness(gcs=gcs, literal=literal, storage=storage, literal_sink=literal_sink)
finally:
await gcs.aclose()
@dataclass(frozen=True, slots=True)
class PurviewHarness:
graph: RecordingAsyncClient
guardrail: MicrosoftPurviewDLPGuardrail
def purview_harness(name: str) -> PurviewHarness:
graph: Final = RecordingAsyncClient(
responses=({"access_token": "test-token", "expires_in": 3600}, {}, {}, {}),
blocked_url_fragment="processContent",
)
graph.release.clear()
guardrail: Final = MicrosoftPurviewDLPGuardrail(
guardrail_name=name,
tenant_id="test-tenant",
client_id="test-client",
client_secret="test-secret",
event_hook=GuardrailEventHooks.logging_only,
default_on=True,
)
guardrail.async_handler = graph
return PurviewHarness(graph=graph, guardrail=guardrail)
async def wait_for_audits(graph: RecordingAsyncClient, count: int = 2, timeout: float = 3) -> tuple[RecordedPost, ...]:
async with asyncio.timeout(timeout):
while len(tuple(post for post in graph.posts if "processContent" in post.url)) < count:
await asyncio.sleep(0.01)
return tuple(post for post in graph.posts if "processContent" in post.url)
def metric_value(name: str, **labels: str) -> float:
for metric in REGISTRY.collect():
for sample in metric.samples:
if sample.name == name and all(sample.labels.get(key) == value for key, value in labels.items()):
return sample.value
return 0.0
class ReviewGuardrail(CustomGuardrail):
def __init__(self, review: Callable[[object], Awaitable[object]]) -> None:
super().__init__(guardrail_name="rust-review", event_hook=GuardrailEventHooks.post_call, default_on=True)
self._review = review
self.call_types: list[object] = []
async def async_post_call_success_deployment_hook(self, request_data, response, call_type):
self.call_types.append(call_type)
return await self._review(response)
class MutatingFailingLogger(CustomLogger):
def log_success_event(self, kwargs, response_obj, start_time, end_time):
payload: Final = kwargs["standard_logging_object"]
payload["metadata"]["composition_marker"] = "visible-before-failure"
raise RuntimeError("synthetic callback failure")
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
payload: Final = kwargs["standard_logging_object"]
payload["metadata"]["composition_marker"] = "visible-before-failure"
raise RuntimeError("synthetic callback failure")

View file

@ -1,92 +0,0 @@
from typing import Final
import pytest
import litellm
from tests.test_litellm_rust.conftest import Backend, isolated_backend
from tests.test_litellm_rust.integrations import (
MESSAGES_ROUTE,
OCR_ASYNC,
OCR_SYNC,
Route,
RunObservation,
provider_response,
route_id,
wait_for_callback,
)
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger, drain_logging
from tests.test_litellm_rust.support.provenance import has_rust_response_marker
from tests.test_litellm_rust.support.recording_server import ResponseSpec, recording_service
from tests.test_litellm_rust.support.requests import CHAT_MESSAGES, CHAT_MODEL, CHAT_RESPONSE
pytestmark = pytest.mark.requires_rust_extension
async def observe_route(backend: Backend, route: Route) -> RunObservation:
async with isolated_backend(backend):
with recording_service() as provider:
provider.default_response = provider_response(route)
recorder: Final = RecordingLogger()
response: Final = await route.invoke(provider, callbacks=[recorder])
event: Final = (await wait_for_callback(route, recorder))[0]
payload: Final = event.kwargs["standard_logging_object"]
provider_body: Final = provider.requests[0].body
if not isinstance(provider_body, dict):
raise TypeError(f"Expected provider object body, got {type(provider_body).__name__}")
return RunObservation(
call_type=payload["call_type"],
model=payload["model"],
response_cost=payload["response_cost"],
response_text=route.response_text(response),
provider_body=provider_body,
rust_dispatch=has_rust_response_marker(response),
)
@pytest.mark.asyncio
@pytest.mark.parametrize("route", (OCR_SYNC, OCR_ASYNC, MESSAGES_ROUTE), ids=route_id)
async def test_python_and_rust_match_public_route_outputs_and_logging(route: Route) -> None:
python: Final = await observe_route("python", route)
rust: Final = await observe_route("rust", route)
python_body: Final = {**python.provider_body, "stream": python.provider_body.get("stream", False)}
rust_body: Final = {**rust.provider_body, "stream": rust.provider_body.get("stream", False)}
assert python.call_type == rust.call_type == route.call_type
assert python.model == rust.model == route.provider_model
assert python.response_cost == pytest.approx(rust.response_cost)
assert python.response_text == rust.response_text == route.expected_text
assert python_body == rust_body
assert python.rust_dispatch is False
assert rust.rust_dispatch is True
async def observe_retry(backend: Backend) -> tuple[tuple[str, ...], bool]:
async with isolated_backend(backend):
with recording_service() as provider:
provider.expected_requests = 2
provider.enqueue(ResponseSpec(body={"message": "retry this attempt"}, status=500))
provider.default_response = ResponseSpec(body=CHAT_RESPONSE)
recorder: Final = RecordingLogger()
response: Final = await litellm.acompletion(
model=CHAT_MODEL,
messages=CHAT_MESSAGES,
api_key="test-key",
api_base=provider.base_url,
callbacks=[recorder],
num_retries=1,
)
await drain_logging()
return recorder.names, has_rust_response_marker(response)
@pytest.mark.asyncio
async def test_python_and_rust_emit_the_same_retry_callback_sequence() -> None:
python_names, python_used_rust = await observe_retry("python")
rust_names, rust_used_rust = await observe_retry("rust")
assert rust_names == python_names
assert rust_names.count("log_pre_api_call") == 2
assert rust_names.count("async_log_failure_event") == 1
assert rust_names.count("async_log_success_event") == 0
assert python_used_rust is False
assert rust_used_rust is True

View file

@ -1,281 +0,0 @@
import asyncio
import gc
import json
import threading
import weakref
from collections.abc import Mapping
from datetime import datetime
from typing import Final
import pytest
import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.types.utils import CallTypes
from tests.test_litellm_rust.conftest import Backend, isolated_backend
from tests.test_litellm_rust.integrations import MESSAGES_ROUTE, MESSAGES_STREAM, OCR_ASYNC, Route, provider_response
from tests.test_litellm_rust.support.callback_recorder import (
LiveReferenceLogger,
RecordingLogger,
SecondaryLiveReferenceLogger,
drain_logging,
)
from tests.test_litellm_rust.support.provenance import has_rust_response_marker
from tests.test_litellm_rust.support.recording_server import ResponseSpec, recording_service
from tests.test_litellm_rust.support.requests import MESSAGES, MESSAGES_EVENTS
pytestmark = pytest.mark.requires_rust_extension
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ("python", "rust"))
async def test_terminal_callbacks_receive_the_same_live_kwargs_and_response(backend: Backend) -> None:
async with isolated_backend(backend):
with recording_service() as provider:
provider.default_response = provider_response(MESSAGES_ROUTE)
first: Final = LiveReferenceLogger()
second: Final = SecondaryLiveReferenceLogger()
response: Final = await MESSAGES_ROUTE.invoke(provider, callbacks=[first, second])
first_event: Final = (await first.wait_for_async())[0]
second_event: Final = (await second.wait_for_async())[0]
assert first_event.kwargs is second_event.kwargs
assert first_event.response is second_event.response
assert has_rust_response_marker(response) is (backend == "rust")
first.release()
second.release()
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ("python", "rust"))
async def test_redacted_and_plain_loggers_receive_separate_payloads_with_shared_metadata(backend: Backend) -> None:
class Capture(CustomLogger):
def __init__(self, redact: bool) -> None:
super().__init__(turn_off_message_logging=redact)
self.kwargs: object = None
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
self.kwargs = kwargs
async with isolated_backend(backend):
with recording_service() as provider:
provider.default_response = provider_response(MESSAGES_ROUTE)
redacted: Final = Capture(True)
plain: Final = Capture(False)
response: Final = await MESSAGES_ROUTE.invoke(provider, callbacks=[redacted, plain])
await drain_logging()
redacted_kwargs: Final = redacted.kwargs
plain_kwargs: Final = plain.kwargs
assert isinstance(redacted_kwargs, dict)
assert isinstance(plain_kwargs, dict)
redacted_payload: Final = redacted_kwargs["standard_logging_object"]
plain_payload: Final = plain_kwargs["standard_logging_object"]
assert redacted_kwargs is not plain_kwargs
assert redacted_payload is not plain_payload
assert redacted_payload["metadata"] is plain_payload["metadata"]
assert "redacted-by-litellm" in json.dumps(redacted_payload["messages"])
assert MESSAGES_ROUTE.expected_text in json.dumps(plain_payload["response"])
assert "redacted-by-litellm" not in json.dumps(plain_payload)
assert has_rust_response_marker(response) is (backend == "rust")
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ("python", "rust"))
@pytest.mark.parametrize("accepted", (True, False), ids=("accepted", "rejected"))
async def test_deferred_terminal_callback_runs_only_after_proxy_completion_decision(
backend: Backend, accepted: bool
) -> None:
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
async with isolated_backend(backend):
with recording_service() as provider:
provider.default_response = provider_response(MESSAGES_ROUTE)
recorder: Final = RecordingLogger()
logger: Final = Logging(
model=MESSAGES_ROUTE.provider_model,
messages=MESSAGES,
stream=False,
call_type=MESSAGES_ROUTE.call_type,
start_time=datetime.now(),
litellm_call_id="deferred-completion",
function_id="deferred-completion",
dynamic_async_success_callbacks=[recorder],
dynamic_async_failure_callbacks=[recorder],
)
logger._defer_async_logging = True
response: Final = await MESSAGES_ROUTE.invoke(provider, litellm_logging_obj=logger)
await asyncio.sleep(0)
assert "async_log_success_event" not in recorder.names
ProxyBaseLLMRequestProcessing._flush_deferred_async_logging(
logging_obj=logger,
exception_raised=not accepted,
)
if accepted:
accepted_events: Final = await recorder.wait_for_async("async_log_success_event")
assert len(accepted_events) == 1
assert "async_log_failure_event" not in recorder.names
else:
error: Final = RuntimeError("post-call guardrail rejected response")
await logger.async_failure_handler(error, str(error))
rejected_events: Final = await recorder.wait_for_async("async_log_failure_event")
assert len(rejected_events) == 1
assert "async_log_success_event" not in recorder.names
assert has_rust_response_marker(response) is (backend == "rust")
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ("python", "rust"))
async def test_suspended_terminal_callback_retains_metadata_until_completion(backend: Backend) -> None:
class Root:
pass
class Suspended(CustomLogger):
def __init__(self) -> None:
super().__init__()
self.started = asyncio.Event()
self.release = asyncio.Event()
self.saw_retained_root = False
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
self.saw_retained_root = kwargs["litellm_params"]["metadata"]["retained"] is not None
self.started.set()
await self.release.wait()
async def invoke() -> weakref.ReferenceType[Root]:
async with isolated_backend(backend):
with recording_service() as provider:
provider.default_response = provider_response(MESSAGES_ROUTE)
callback: Final = Suspended()
root = Root()
reference: Final = weakref.ref(root)
response: Final = await MESSAGES_ROUTE.invoke(
provider,
callbacks=[callback],
metadata={"retained": root},
)
del root
await asyncio.wait_for(callback.started.wait(), timeout=2)
assert callback.saw_retained_root is True
gc.collect()
assert reference() is not None
assert has_rust_response_marker(response) is (backend == "rust")
callback.release.set()
await drain_logging()
del response
return reference
reference: Final = await invoke()
await asyncio.sleep(0)
gc.collect()
assert reference() is None
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ("python", "rust"))
@pytest.mark.parametrize(
("route", "phase"),
(
(OCR_ASYNC, "pre"),
(MESSAGES_ROUTE, "pre"),
(MESSAGES_STREAM, "pre"),
(OCR_ASYNC, "success"),
(MESSAGES_ROUTE, "success"),
),
ids=("ocr-pre", "messages-pre", "messages-stream-pre", "ocr-success", "messages-success"),
)
async def test_deployment_rejection_logs_terminal_failure_without_calling_deployment_failure_hook(
backend: Backend, route: Route, phase: str
) -> None:
class Reject(CustomLogger):
async def async_pre_call_deployment_hook(
self, kwargs: dict[str, object], call_type: CallTypes | None
) -> dict[str, object]:
if phase == "pre":
raise litellm.BadRequestError("deployment rejected", "test-provider", route.provider_model)
return kwargs
async def async_post_call_success_deployment_hook(
self, request_data: Mapping[str, object], response: object, call_type: CallTypes | None
) -> object:
raise litellm.BadRequestError("deployment rejected", "test-provider", route.provider_model)
async with isolated_backend(backend):
with recording_service() as provider:
provider.expected_requests = 0 if phase == "pre" else 1
provider.default_response = provider_response(route)
recorder: Final = RecordingLogger()
litellm.callbacks.extend((Reject(), recorder))
with pytest.raises(litellm.BadRequestError, match="deployment rejected"):
await route.invoke(provider)
await recorder.wait_for_async("async_log_failure_event")
assert len(provider.requests) == provider.expected_requests
assert "async_log_success_event" not in recorder.names
assert recorder.names.count("async_post_call_failure_deployment_hook") == 0
@pytest.mark.asyncio
@pytest.mark.parametrize("ending", ("provider_error", "truncated", "close"))
async def test_established_rust_stream_finishes_once_without_deployment_failure_hook(ending: str) -> None:
with recording_service() as provider:
provider.default_response = ResponseSpec(
body=None,
events=(
(
*MESSAGES_EVENTS[:1],
(
"error",
{"type": "error", "error": {"type": "overloaded_error", "message": "upstream overloaded"}},
),
)
if ending == "provider_error"
else MESSAGES_EVENTS[:-1]
if ending == "truncated"
else MESSAGES_EVENTS
),
)
release: Final = threading.Event()
if ending == "close":
provider.enqueue(
ResponseSpec(
body=None,
chunks=tuple(
f"event: {event}\ndata: {json.dumps(data)}\n\n".encode() for event, data in MESSAGES_EVENTS
),
release=release,
)
)
recorder: Final = RecordingLogger()
litellm.callbacks.append(recorder)
try:
stream: Final = await MESSAGES_STREAM.open_stream(provider)
assert has_rust_response_marker(stream)
assert recorder.names.count("async_pre_call_deployment_hook") == 1
assert "async_post_call_failure_deployment_hook" not in recorder.names
if ending == "close":
assert await anext(stream)
await asyncio.wait_for(stream.aclose(), timeout=1)
await stream.aclose()
assert "async_log_success_event" not in recorder.names
assert "async_log_failure_event" not in recorder.names
release.set()
await recorder.wait_for_async("async_log_success_event")
else:
try:
async for _ in stream:
pass
except litellm.APIError:
pass
await drain_logging()
finally:
release.set()
assert len(provider.requests) == 1
assert recorder.names.count("async_log_failure_event") == (0 if ending == "close" else 1), recorder.names
assert recorder.names.count("async_log_success_event") == (1 if ending == "close" else 0), recorder.names
assert recorder.names.count("async_post_call_failure_deployment_hook") == 0

View file

@ -1,54 +0,0 @@
from typing import Final
import pytest
from litellm.litellm_core_utils.custom_logger_registry import CustomLoggerRegistry
from litellm.proxy.guardrails.guardrail_registry import guardrail_initializer_registry
from litellm.types.guardrails import SupportedGuardrailIntegrations
from tests.test_litellm_rust.integrations import (
DISCOVERED_ONLY_GUARDRAIL_NAMES,
ENTERPRISE_LOGGER_NAMES,
GUARDRAIL_NAMES,
GUARDRAIL_OBLIGATIONS,
LOGGER_OBLIGATIONS,
OSS_LOGGER_NAMES,
REQUIRED_GUARDRAIL_BEHAVIOR,
REQUIRED_LOGGER_BEHAVIOR,
)
pytestmark = pytest.mark.requires_rust_extension
def test_logger_catalogue_matches_registered_integration_names() -> None:
registered_names: Final = frozenset(CustomLoggerRegistry.CALLBACK_CLASS_STR_TO_CLASS_TYPE)
catalogued_names: Final = frozenset(
name for obligation in LOGGER_OBLIGATIONS.values() for name in obligation.registration_names
)
assert OSS_LOGGER_NAMES <= registered_names
assert registered_names <= OSS_LOGGER_NAMES | ENTERPRISE_LOGGER_NAMES
assert catalogued_names == OSS_LOGGER_NAMES | ENTERPRISE_LOGGER_NAMES
def test_guardrail_catalogue_matches_registered_integration_names() -> None:
enum_names: Final = frozenset(integration.value for integration in SupportedGuardrailIntegrations)
initializer_names: Final = frozenset(guardrail_initializer_registry)
assert enum_names == GUARDRAIL_NAMES
assert initializer_names == GUARDRAIL_NAMES | DISCOVERED_ONLY_GUARDRAIL_NAMES
assert frozenset(GUARDRAIL_OBLIGATIONS) == initializer_names
def test_required_integrations_have_behavioral_case_labels() -> None:
labelled_loggers: Final = frozenset(
name
for obligation in LOGGER_OBLIGATIONS.values()
if obligation.behavioral_case_labels
for name in obligation.registration_names
)
labelled_guardrails: Final = frozenset(
name for name, obligation in GUARDRAIL_OBLIGATIONS.items() if obligation.behavioral_case_labels
)
assert REQUIRED_LOGGER_BEHAVIOR <= labelled_loggers
assert REQUIRED_GUARDRAIL_BEHAVIOR <= labelled_guardrails

View file

@ -1,149 +0,0 @@
import json
from dataclasses import dataclass
from typing import Final
import pytest
from opentelemetry.trace import StatusCode
from litellm.integrations.generic_api.generic_api_callback import GenericAPILogger
from litellm.integrations.prometheus import PrometheusLogger
from tests.test_litellm_rust.conftest import Backend, isolated_backend
from tests.test_litellm_rust.integrations import (
ASYNC_ROUTES,
MESSAGES_ROUTE,
OCR_ASYNC,
AsyncBoundaryLogger,
MutatingFailingLogger,
OtelHarness,
Route,
gcs_literalai_harness,
metric_value,
provider_response,
route_id,
wait_for_callback,
)
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger
from tests.test_litellm_rust.support.provenance import has_rust_response_marker
from tests.test_litellm_rust.support.recording_server import RecordingServer, recording_service
pytestmark = pytest.mark.requires_rust_extension
MESSAGES_TOOLS: Final = [
{
"name": "get_weather",
"description": "Get current weather",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
}
]
@pytest.mark.asyncio
@pytest.mark.parametrize("route", ASYNC_ROUTES, ids=lambda route: f"otel-prometheus-generic-api-{route.name}-success")
async def test_otel_prometheus_and_generic_api_each_export_one_success(
route: Route, recording_server: RecordingServer, otel: OtelHarness, prometheus: PrometheusLogger
) -> None:
recording_server.default_response = provider_response(route)
before: Final = metric_value("litellm_requests_metric_total", model=route.provider_model)
recorder: Final = RecordingLogger()
with recording_service() as sink:
logger: Final = GenericAPILogger(endpoint=f"{sink.base_url}/logs", batch_size=1, log_format="single")
response: Final = await route.invoke(recording_server, callbacks=[otel.logger, prometheus, logger, recorder])
await wait_for_callback(route, recorder)
exports: Final = tuple(request for request in sink.requests if request.path == "/logs")
spans: Final = await otel.wait_for_spans()
assert len(exports) == 1
assert exports[0].body["call_type"] == route.call_type
assert exports[0].body["response_cost"] == pytest.approx(route.expected_cost)
assert len(spans) == 1
assert spans[0].status.status_code is StatusCode.OK
assert metric_value("litellm_requests_metric_total", model=route.provider_model) == before + 1
assert has_rust_response_marker(response)
@pytest.mark.asyncio
async def test_failing_callback_preserves_prior_mutation_and_later_exporters() -> None:
with recording_service() as provider, recording_service() as sink:
provider.default_response = provider_response(OCR_ASYNC)
logger: Final = GenericAPILogger(endpoint=f"{sink.base_url}/logs", batch_size=1, log_format="single")
recorder: Final = RecordingLogger()
response: Final = await OCR_ASYNC.invoke(provider, callbacks=[MutatingFailingLogger(), logger, recorder])
events: Final = await wait_for_callback(OCR_ASYNC, recorder)
exports: Final = tuple(request for request in sink.requests if request.path == "/logs")
assert response.pages[0].markdown == OCR_ASYNC.expected_text
assert events[0].kwargs["standard_logging_object"]["metadata"]["composition_marker"] == "visible-before-failure"
assert len(exports) == 1
assert exports[0].body["metadata"]["composition_marker"] == "visible-before-failure"
assert has_rust_response_marker(response)
@dataclass(frozen=True, slots=True)
class SerializationSchedule:
callback_order: tuple[str, str]
flush_immediately: bool
gcs_has_tools: bool
SERIALIZATION_SCHEDULES: Final = (
pytest.param(
SerializationSchedule(("gcs", "literalai"), True, True), id="gcs-serializes-before-literalai-mutation"
),
pytest.param(
SerializationSchedule(("gcs", "literalai"), False, False), id="gcs-serializes-after-literalai-mutation"
),
pytest.param(SerializationSchedule(("literalai", "gcs"), False, False), id="literalai-mutates-before-gcs-enqueue"),
)
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ("python", "rust"))
@pytest.mark.parametrize("schedule", SERIALIZATION_SCHEDULES)
async def test_gcs_literalai_serialization_schedule(
backend: Backend, schedule: SerializationSchedule, monkeypatch: pytest.MonkeyPatch
) -> None:
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "premium_user", True)
monkeypatch.setenv("GCS_BATCH_SIZE", "1" if schedule.flush_immediately else "100")
monkeypatch.setenv("GCS_BUCKET_NAME", "composition-bucket")
monkeypatch.setenv("GCS_FLUSH_INTERVAL", "3600")
monkeypatch.setenv("GCS_USE_BATCHED_LOGGING", "true")
monkeypatch.setenv("LITERAL_BATCH_SIZE", "1")
async with isolated_backend(backend):
with recording_service() as provider:
provider.default_response = provider_response(MESSAGES_ROUTE)
async with gcs_literalai_harness() as harness:
callbacks_by_name: Final = {"gcs": harness.gcs, "literalai": harness.literal}
ordered_callbacks: Final = [callbacks_by_name[name] for name in schedule.callback_order]
callbacks: Final = (
[harness.gcs, AsyncBoundaryLogger(harness.gcs.flush_queue), harness.literal]
if schedule.flush_immediately
else ordered_callbacks
)
recorder: Final = RecordingLogger()
response: Final = await MESSAGES_ROUTE.invoke(
provider, tools=MESSAGES_TOOLS, callbacks=[*callbacks, recorder]
)
await wait_for_callback(MESSAGES_ROUTE, recorder)
if not schedule.flush_immediately:
await harness.gcs.flush_queue()
gcs_payload_value: Final = harness.storage.posts[0].body
if not isinstance(gcs_payload_value, str):
raise TypeError(f"Expected serialized GCS payload, got {type(gcs_payload_value).__name__}")
gcs_payload: Final = json.loads(gcs_payload_value)
literal_body: Final = harness.literal_sink.posts[0].body
if not isinstance(literal_body, dict):
raise TypeError(f"Expected LiteralAI request body, got {type(literal_body).__name__}")
generation: Final = literal_body["variables"]["generation_0"]
assert ("tools" in gcs_payload["model_parameters"]) is schedule.gcs_has_tools
assert generation["tools"] == MESSAGES_TOOLS
assert len(harness.storage.posts) == 1
assert len(harness.literal_sink.posts) == 1
assert has_rust_response_marker(response) is (backend == "rust")

View file

@ -1,253 +0,0 @@
import json
from typing import Final
import pytest
from opentelemetry.trace import StatusCode
from prometheus_client import CollectorRegistry, Counter
import litellm
from litellm.integrations.prometheus import PrometheusLogger
from litellm.litellm_core_utils import litellm_logging
from tests._prometheus_helpers import isolated_prometheus_registry
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger, drain_logging
from tests.test_litellm_rust.support.provenance import has_rust_response_marker
from tests.test_litellm_rust.support.requests import MESSAGES_EVENTS
from tests.test_litellm_rust.integrations import (
ALL_ROUTES,
ASYNC_ROUTES,
MESSAGES_ROUTE,
MESSAGES_STREAM,
NON_STREAM_ASYNC_ROUTES,
OCR_ASYNC,
OCR_SYNC,
GenericAPIExportHarness,
OtelHarness,
Route,
metric_value,
route_id,
wait_for_callback,
)
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec
pytestmark = pytest.mark.requires_rust_extension
FAILURE_RESPONSE: Final = ResponseSpec(body={"message": "provider unavailable"}, status=500)
async def invoke_native(route: Route, provider: RecordingServer, **kwargs: object) -> object:
if route.name == "messages-stream":
stream: Final = await route.open_stream(provider, **kwargs)
assert has_rust_response_marker(stream)
return [chunk async for chunk in stream]
response: Final = await route.invoke(provider, **kwargs)
assert has_rust_response_marker(response)
return response
@pytest.mark.asyncio
@pytest.mark.parametrize("route", ASYNC_ROUTES, ids=route_id)
async def test_generic_api_logger_exports_success_over_http(
route: Route,
provider: RecordingServer,
generic_api_export: GenericAPIExportHarness,
) -> None:
recorder: Final = RecordingLogger()
await invoke_native(route, provider, callbacks=[generic_api_export.logger, recorder])
await wait_for_callback(route, recorder)
assert len(generic_api_export.exports) == 1
payload: Final = generic_api_export.exports[0].body
assert payload["status"] == "success"
assert payload["call_type"] == route.call_type
assert payload["model"] == route.provider_model
assert payload["response_cost"] == pytest.approx(route.expected_cost)
assert route.expected_text in json.dumps(payload["response"])
@pytest.mark.asyncio
@pytest.mark.parametrize("route", NON_STREAM_ASYNC_ROUTES, ids=route_id)
async def test_generic_api_logger_exports_provider_failure_over_http(
route: Route,
provider: RecordingServer,
generic_api_export: GenericAPIExportHarness,
) -> None:
provider.enqueue(FAILURE_RESPONSE)
recorder: Final = RecordingLogger()
try:
with pytest.raises(litellm.InternalServerError):
await route.invoke(provider, callbacks=[generic_api_export.logger, recorder], num_retries=0)
finally:
await drain_logging()
await wait_for_callback(route, recorder, outcome="failure")
assert len(provider.requests) == 1
assert len(generic_api_export.exports) == 1
payload: Final = generic_api_export.exports[0].body
assert payload["status"] == "failure"
assert payload["call_type"] == route.call_type
assert payload["error_information"]["error_class"] == "InternalServerError"
assert payload["error_information"]["error_code"] == "500"
def test_prometheus_registry_restores_collectors_after_failure() -> None:
registry: Final = CollectorRegistry()
original: Final = Counter("original", "Original collector", registry=registry)
original.inc(2)
def failing_test() -> None:
with isolated_prometheus_registry(registry):
assert registry.get_sample_value("original_total") is None
Counter("original", "Temporary replacement", registry=registry).inc(7)
Counter("temporary", "Temporary collector", registry=registry).inc()
raise RuntimeError("test failed")
with pytest.raises(RuntimeError, match="test failed"):
failing_test()
assert registry.get_sample_value("original_total") == 2
assert registry.get_sample_value("temporary_total") is None
registry.unregister(original)
assert registry.get_sample_value("original_total") is None
@pytest.mark.asyncio
@pytest.mark.parametrize("route", ALL_ROUTES, ids=route_id)
async def test_otel_emits_one_request_span_on_success(
route: Route, provider: RecordingServer, otel: OtelHarness
) -> None:
await invoke_native(route, provider, callbacks=[otel.logger])
spans: Final = await otel.wait_for_spans()
assert len(spans) == 1
span: Final = spans[0]
assert span.status.status_code is StatusCode.OK
assert span.attributes["llm.request.type"] == route.call_type
assert span.attributes["gen_ai.request.model"] == route.provider_model
assert json.loads(span.attributes["hidden_params"])["response_cost"] == pytest.approx(route.expected_cost)
@pytest.mark.asyncio
async def test_otel_stream_span_appears_only_after_exhaustion(
otel: OtelHarness, recording_server: RecordingServer
) -> None:
recording_server.default_response = ResponseSpec(body=None, events=MESSAGES_EVENTS)
stream: Final = await MESSAGES_STREAM.open_stream(recording_server, callbacks=[otel.logger])
await drain_logging()
assert otel.spans() == ()
chunks: Final = [chunk async for chunk in stream]
assert chunks
assert len(await otel.wait_for_spans()) == 1
@pytest.mark.asyncio
async def test_otel_stream_span_appears_after_an_interrupted_consumer_closes(
otel: OtelHarness, recording_server: RecordingServer
) -> None:
recording_server.default_response = ResponseSpec(body=None, events=MESSAGES_EVENTS)
stream: Final = await MESSAGES_STREAM.open_stream(recording_server, callbacks=[otel.logger])
first_chunk: Final = await anext(stream)
await drain_logging()
assert otel.spans() == ()
assert has_rust_response_marker(stream)
await stream.aclose()
await drain_logging()
assert first_chunk is not None
assert len(otel.spans()) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("route", (OCR_SYNC, OCR_ASYNC), ids=route_id)
async def test_otel_emits_one_error_span_on_provider_failure(
route: Route, provider: RecordingServer, otel: OtelHarness
) -> None:
provider.enqueue(FAILURE_RESPONSE)
with pytest.raises(litellm.InternalServerError):
await route.invoke(provider, callbacks=[otel.logger])
spans: Final = await otel.wait_for_spans()
assert len(spans) == 1
assert spans[0].status.status_code is StatusCode.ERROR
exception_events: Final = [event for event in spans[0].events if event.name == "exception"]
assert len(exception_events) == 1
assert "InternalServerError" in exception_events[0].attributes["exception.type"]
assert spans[0].attributes["llm.request.type"] == route.call_type
@pytest.mark.asyncio
@pytest.mark.parametrize("route", ALL_ROUTES, ids=route_id)
async def test_otel_and_custom_logger_export_matching_standard_logging_values(
route: Route, provider: RecordingServer, otel: OtelHarness
) -> None:
recorder: Final = RecordingLogger()
await invoke_native(route, provider, callbacks=[otel.logger, recorder])
payload: Final = (await wait_for_callback(route, recorder))[0].kwargs["standard_logging_object"]
span: Final = (await otel.wait_for_spans())[0]
assert payload["call_type"] == route.call_type
assert payload["model"] == route.provider_model
assert json.loads(span.attributes["hidden_params"]) == payload["hidden_params"]
assert span.attributes["llm.request.type"] == payload["call_type"]
assert span.attributes["litellm.provider.model"] == payload["model"]
@pytest.mark.asyncio
@pytest.mark.parametrize("route", ASYNC_ROUTES, ids=route_id)
async def test_prometheus_counts_one_successful_request(
route: Route, provider: RecordingServer, prometheus: PrometheusLogger
) -> None:
before: Final = metric_value("litellm_requests_metric_total", model=route.provider_model)
recorder: Final = RecordingLogger()
await invoke_native(route, provider, callbacks=[prometheus, recorder])
await wait_for_callback(route, recorder)
assert metric_value("litellm_requests_metric_total", model=route.provider_model) == before + 1
assert metric_value("litellm_llm_api_failed_requests_metric_total", model=route.provider_model) == 0
@pytest.mark.asyncio
async def test_prometheus_counts_tokens_from_messages_usage(
recording_server: RecordingServer, prometheus: PrometheusLogger
) -> None:
recording_server.default_response = ResponseSpec(body=MESSAGES_ROUTE.provider_response)
recorder: Final = RecordingLogger()
await invoke_native(MESSAGES_ROUTE, recording_server, callbacks=[prometheus, recorder])
await wait_for_callback(MESSAGES_ROUTE, recorder)
assert metric_value("litellm_input_tokens_metric_total", model=MESSAGES_ROUTE.provider_model) == 5
assert metric_value("litellm_output_tokens_metric_total", model=MESSAGES_ROUTE.provider_model) == 4
@pytest.mark.asyncio
async def test_prometheus_counts_one_failed_request(
otel: OtelHarness, prometheus: PrometheusLogger, recording_server: RecordingServer
) -> None:
recording_server.enqueue(FAILURE_RESPONSE)
with pytest.raises(litellm.InternalServerError):
await OCR_ASYNC.invoke(recording_server, callbacks=[prometheus, otel.logger])
assert len(await otel.wait_for_spans()) == 1
assert metric_value("litellm_llm_api_failed_requests_metric_total", model=OCR_ASYNC.provider_model) == 1
assert metric_value("litellm_requests_metric_total", model=OCR_ASYNC.provider_model) == 0
@pytest.mark.asyncio
@pytest.mark.parametrize("route", NON_STREAM_ASYNC_ROUTES, ids=route_id)
async def test_prometheus_by_string_name_is_initialized_once(route: Route, provider: RecordingServer) -> None:
provider.expected_requests = 2
litellm.success_callback = ["prometheus"] # test-quality-ok: public registration; fixture restores globals
recorder: Final = RecordingLogger()
await invoke_native(route, provider, callbacks=[recorder])
await invoke_native(route, provider, callbacks=[recorder])
await wait_for_callback(route, recorder, count=2)
instances: Final = [cb for cb in litellm_logging._in_memory_loggers if isinstance(cb, PrometheusLogger)] # pyright: ignore[reportPrivateUsage] # string-name cache has no public accessor
assert len(instances) == 1
assert metric_value("litellm_requests_metric_total", model=route.provider_model) == 2
assert "prometheus" not in litellm.success_callback
assert instances[0] in litellm._async_success_callback # pyright: ignore[reportPrivateUsage] # callback registry has no public accessor
@pytest.mark.asyncio
async def test_sync_ocr_reaches_sync_hooks_only(
recording_server: RecordingServer, otel: OtelHarness, prometheus: PrometheusLogger
) -> None:
recording_server.default_response = ResponseSpec(body=OCR_SYNC.provider_response)
await invoke_native(OCR_SYNC, recording_server, callbacks=[otel.logger, prometheus])
assert len(await otel.wait_for_spans()) == 1
assert metric_value("litellm_requests_metric_total", model=OCR_SYNC.provider_model) == 0

View file

@ -1,697 +0,0 @@
import asyncio
import gc
import json
import threading
import weakref
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Final
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
import litellm
from litellm.integrations.custom_guardrail import CustomGuardrail, ModifyResponseException
from litellm.integrations.literal_ai import LiteralAILogger
from litellm.integrations.rubrik import RubrikLogger
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
from litellm.proxy.guardrails.guardrail_hooks.azure.text_moderation import AzureContentSafetyTextModerationGuardrail
from litellm.proxy.guardrails.guardrail_hooks.crowdstrike_aidr.crowdstrike_aidr import CrowdStrikeAIDRHandler
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import ContentFilterGuardrail
from litellm.proxy.utils import ProxyLogging
from litellm.types.guardrails import BlockedWord, ContentFilterAction, GuardrailEventHooks
from litellm.types.utils import CallTypes
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger, drain_logging
from tests.test_litellm_rust.support.provenance import has_rust_response_marker
from tests.test_litellm_rust.conftest import Backend, isolated_backend
from tests.test_litellm_rust.support.requests import CHAT_MESSAGES, CHAT_MODEL, CHAT_RESPONSE, MESSAGES
from tests.test_litellm_rust.integrations import (
MESSAGES_ROUTE,
MESSAGES_STREAM,
NON_STREAM_ASYNC_ROUTES,
GenericAPIExportHarness,
OtelHarness,
ReviewGuardrail,
Route,
provider_response,
purview_harness,
route_id,
wait_for_audits,
wait_for_callback,
)
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec, recording_service
pytestmark = pytest.mark.requires_rust_extension
AZURE_MODERATION_ALLOW_RESPONSE: Final = {
"blocklistsMatch": [],
"categoriesAnalysis": [
{"category": "Hate", "severity": 0},
{"category": "Sexual", "severity": 0},
{"category": "SelfHarm", "severity": 0},
{"category": "Violence", "severity": 0},
],
}
AZURE_MODERATION_BLOCK_RESPONSE: Final = {
**AZURE_MODERATION_ALLOW_RESPONSE,
"categoriesAnalysis": [{"category": "Violence", "severity": 6}],
}
STREAM_SECRET: Final = "078-05-1120"
STREAM_MASK: Final = "[MASKED]"
class BufferedMaskingGuardrail(CustomGuardrail):
def __init__(self) -> None:
super().__init__(guardrail_name="stream-mask", event_hook=GuardrailEventHooks.post_call, default_on=True)
async def async_post_call_streaming_iterator_hook(
self,
user_api_key_dict: UserAPIKeyAuth,
response: AsyncIterator[object],
request_data: dict,
) -> AsyncIterator[bytes]:
chunks: Final = tuple([chunk async for chunk in response])
body: Final = b"".join(chunk for chunk in chunks if isinstance(chunk, bytes))
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={"masked": True},
request_data=request_data,
guardrail_status="success",
masked_entity_count={"TEST_IDENTIFIER": 1},
)
yield body.replace(STREAM_SECRET.encode(), STREAM_MASK.encode())
class CleanupFailureGuardrail(CustomGuardrail):
def __init__(self) -> None:
super().__init__(guardrail_name="cleanup-failure", event_hook=GuardrailEventHooks.post_call, default_on=True)
async def async_post_call_streaming_iterator_hook(
self,
user_api_key_dict: UserAPIKeyAuth,
response: AsyncIterator[object],
request_data: dict,
) -> AsyncIterator[object]:
try:
async for chunk in response:
yield chunk
finally:
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response="guardrail cleanup failed",
request_data=request_data,
guardrail_status="guardrail_failed_to_respond",
)
raise RuntimeError("guardrail cleanup failed")
class FailingMaskingGuardrail(CustomGuardrail):
def __init__(self) -> None:
super().__init__(guardrail_name="mask-failure", event_hook=GuardrailEventHooks.post_call, default_on=True)
async def async_post_call_streaming_iterator_hook(
self,
user_api_key_dict: UserAPIKeyAuth,
response: AsyncIterator[object],
request_data: dict,
) -> AsyncIterator[object]:
tuple([chunk async for chunk in response])
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response="stream masking failed",
request_data=request_data,
guardrail_status="guardrail_failed_to_respond",
)
raise HTTPException(
status_code=500,
detail={"error": "stream masking failed", "guardrail": self.guardrail_name},
)
yield
def stream_events(text: str) -> tuple[tuple[str, object], ...]:
return tuple(
(event, {**data, "delta": {**data["delta"], "text": text}})
if event == "content_block_delta" and isinstance(data, dict)
else (event, data)
for event, data in provider_response(MESSAGES_STREAM).events
)
def streaming_logger(recorder: RecordingLogger, callbacks: list[object] | None = None) -> Logging:
terminal_callbacks: Final = [recorder, *(callbacks or [])]
return Logging(
model=MESSAGES_STREAM.provider_model,
messages=[{"role": "user", "content": "mask the response"}],
stream=True,
call_type="anthropic_messages",
start_time=datetime.now(),
litellm_call_id="stream-mask",
function_id="stream-mask",
dynamic_async_success_callbacks=terminal_callbacks,
dynamic_async_failure_callbacks=terminal_callbacks,
)
def arm_guarded_stream(stream: AsyncIterator[object], request_data: dict, logger: Logging) -> AsyncIterator[object]:
processor: Final = object.__new__(ProxyBaseLLMRequestProcessing)
processor.data = request_data
user: Final = UserAPIKeyAuth(request_route="/v1/messages")
processor._arm_deferred_stream_dispatch(stream, "anthropic_messages", user, logger)
return ProxyLogging(user_api_key_cache=MagicMock()).async_post_call_streaming_iterator_hook(
response=stream,
user_api_key_dict=user,
request_data=request_data,
)
def azure_text_moderation(server: RecordingServer) -> AzureContentSafetyTextModerationGuardrail:
return AzureContentSafetyTextModerationGuardrail(
guardrail_name="azure-text-review",
api_key="test-azure-key",
api_base=server.base_url,
event_hook=GuardrailEventHooks.post_call,
)
async def _observed_stream(source: AsyncIterator[object], first_chunk: asyncio.Event) -> AsyncIterator[object]:
async for chunk in source:
first_chunk.set()
yield chunk
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ("python", "rust"))
async def test_stream_masking_reaches_client_loggers_and_exporters(
backend: Backend,
otel: OtelHarness,
generic_api_export: GenericAPIExportHarness,
) -> None:
async with isolated_backend(backend):
with recording_service() as provider:
wire: Final = b"".join(
f"event: {event}\ndata: {json.dumps(data)}\n\n".encode() for event, data in stream_events(STREAM_SECRET)
)
split: Final = wire.index(STREAM_SECRET.encode()) + 4
provider.default_response = ResponseSpec(body=None, chunks=(wire[:split], wire[split:]))
guardrail: Final = BufferedMaskingGuardrail()
recorder: Final = RecordingLogger()
logger: Final = streaming_logger(recorder, [otel.logger, generic_api_export.logger])
request_data: Final = {
"model": MESSAGES_STREAM.provider_model,
"messages": MESSAGES,
"guardrails": [guardrail.guardrail_name],
"metadata": {},
"litellm_logging_obj": logger,
}
litellm.callbacks.append(guardrail)
source: Final = await MESSAGES_STREAM.open_stream(
provider,
litellm_logging_obj=logger,
guardrails=[guardrail.guardrail_name],
)
guarded: Final = arm_guarded_stream(source, request_data, logger)
assert "async_log_success_event" not in recorder.names
payload: Final = b"".join([chunk async for chunk in guarded if isinstance(chunk, bytes)])
events: Final = await recorder.wait_for_async("async_log_success_event")
spans: Final = await otel.wait_for_spans()
assert STREAM_MASK.encode() in payload
assert STREAM_SECRET.encode() not in payload
assert payload.count(b"event: message_stop") == 1
assert len(events) == 1
assert events[0].response.choices[0].message.content == STREAM_MASK
assert STREAM_SECRET not in json.dumps(events[0].kwargs, default=str)
assert STREAM_SECRET not in json.dumps(dict(spans[0].attributes), default=str)
assert STREAM_MASK in json.dumps(dict(spans[0].attributes), default=str)
async with asyncio.timeout(10):
while not generic_api_export.exports:
await asyncio.sleep(0.01)
exported: Final = json.dumps(generic_api_export.exports[0].body, default=str)
assert STREAM_SECRET not in exported
assert STREAM_MASK in exported
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ("python", "rust"))
async def test_upstream_stream_failure_is_not_masked_by_guardrail_cleanup(
backend: Backend,
) -> None:
async with isolated_backend(backend):
with recording_service() as provider:
provider.default_response = ResponseSpec(
body=None,
events=(
provider_response(MESSAGES_STREAM).events[0],
(
"error",
{
"type": "error",
"error": {"type": "overloaded_error", "message": "upstream overloaded"},
},
),
),
)
guardrail: Final = CleanupFailureGuardrail()
recorder: Final = RecordingLogger()
logger: Final = streaming_logger(recorder)
request_data: Final = {
"model": MESSAGES_STREAM.provider_model,
"messages": MESSAGES,
"guardrails": [guardrail.guardrail_name],
"metadata": {},
"litellm_logging_obj": logger,
}
litellm.callbacks.append(guardrail)
source: Final = await MESSAGES_STREAM.open_stream(
provider,
litellm_logging_obj=logger,
guardrails=[guardrail.guardrail_name],
)
guarded: Final = arm_guarded_stream(source, request_data, logger)
with pytest.raises(litellm.APIError) as caught:
async for _ in guarded:
pass
guardrail_info: Final = request_data["metadata"]["standard_logging_guardrail_information"]
assert "guardrail cleanup failed" not in str(caught.value)
assert request_data["metadata"]["stream_guardrail_cleanup_error"] == {
"type": "RuntimeError",
"message": "guardrail cleanup failed",
}
if backend == "rust":
events: Final = await recorder.wait_for_async("async_log_failure_event")
assert len(events) == 1
assert "guardrail cleanup failed" not in str(events[0].kwargs["exception"])
else:
assert "async_log_success_event" not in recorder.names
assert guardrail_info[-1]["guardrail_status"] == "guardrail_failed_to_respond"
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ("python", "rust"))
async def test_stream_masking_failure_uses_guardrail_error_and_logs_once(backend: Backend) -> None:
async with isolated_backend(backend):
with recording_service() as provider:
provider.default_response = ResponseSpec(body=None, events=stream_events(STREAM_SECRET))
guardrail: Final = FailingMaskingGuardrail()
recorder: Final = RecordingLogger()
logger: Final = streaming_logger(recorder)
request_data: Final = {
"model": MESSAGES_STREAM.provider_model,
"messages": MESSAGES,
"guardrails": [guardrail.guardrail_name],
"metadata": {},
"litellm_logging_obj": logger,
}
litellm.callbacks.append(guardrail)
source: Final = await MESSAGES_STREAM.open_stream(
provider,
litellm_logging_obj=logger,
guardrails=[guardrail.guardrail_name],
)
with pytest.raises(HTTPException) as caught:
async for _ in arm_guarded_stream(source, request_data, logger):
pass
await logger.async_failure_handler(caught.value, "stream masking failed")
events: Final = await recorder.wait_for_async("async_log_failure_event")
assert caught.value.status_code == 500
assert caught.value.detail["error"] == "stream masking failed"
assert caught.value.detail["guardrail"] == guardrail.guardrail_name
assert len(events) == 1
assert events[0].kwargs["exception"] is caught.value
assert "async_log_success_event" not in recorder.names
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ("python", "rust"))
async def test_guarded_stream_cancel_drains_and_releases_roots(backend: Backend) -> None:
class NamesOnlyLogger(RecordingLogger):
def _record(self, name: str, kwargs: object = None, response: object = None) -> None:
super()._record(name)
class Root:
pass
async with isolated_backend(backend):
with recording_service() as provider:
release: Final = threading.Event()
wire: Final = tuple(
f"event: {event}\ndata: {json.dumps(data)}\n\n".encode() for event, data in stream_events(STREAM_SECRET)
)
provider.enqueue(ResponseSpec(body=None, chunks=wire, release=release))
guardrail: Final = BufferedMaskingGuardrail()
recorder: Final = NamesOnlyLogger()
logger: Final = streaming_logger(recorder)
root = Root()
reference: Final = weakref.ref(root)
request_data: Final = {
"model": MESSAGES_STREAM.provider_model,
"messages": MESSAGES,
"guardrails": [guardrail.guardrail_name],
"metadata": {},
"litellm_logging_obj": logger,
}
litellm.callbacks.append(guardrail)
source: Final = await MESSAGES_STREAM.open_stream(
provider,
litellm_logging_obj=logger,
guardrails=[guardrail.guardrail_name],
metadata={"retained": root},
)
first_chunk: Final = asyncio.Event()
guarded = arm_guarded_stream(_observed_stream(source, first_chunk), request_data, logger)
del root
task: Final = asyncio.create_task(anext(guarded))
await provider.wait_for_requests(1)
await asyncio.wait_for(first_chunk.wait(), 5)
task.cancel()
try:
with pytest.raises(asyncio.CancelledError):
await task
await guarded.aclose()
release.set()
events: Final = await recorder.wait_for_async("async_log_success_event")
del guarded
del source
gc.collect()
assert len(events) == 1
assert "async_log_failure_event" not in recorder.names
del events
del task
del request_data
del logger
gc.collect()
assert reference() is None
finally:
release.set()
@pytest.mark.asyncio
async def test_crowdstrike_redaction_reaches_native_chat_provider_and_exporter(
generic_api_export: GenericAPIExportHarness,
) -> None:
with recording_service() as provider, recording_service() as guard:
provider.default_response = ResponseSpec(body=CHAT_RESPONSE)
guard.default_response = ResponseSpec(
body={
"result": {
"blocked": False,
"transformed": True,
"guard_output": {
"messages": [
{"role": "system", "content": "Keep the answer short"},
{"role": "user", "content": "Employee SSN: <US_SSN>"},
]
},
}
}
)
guardrail: Final = CrowdStrikeAIDRHandler(
guardrail_name="crowdstrike-redaction",
api_key="test-crowdstrike-key",
api_base=guard.base_url,
event_hook=GuardrailEventHooks.pre_call,
)
recorder: Final = RecordingLogger()
litellm.callbacks.append(guardrail)
response: Final = await litellm.acompletion(
model=CHAT_MODEL,
messages=CHAT_MESSAGES,
api_key="test-key",
api_base=provider.base_url,
callbacks=[generic_api_export.logger, recorder],
guardrails=[guardrail.guardrail_name],
)
await recorder.wait_for_async("async_log_success_event")
provider_messages: Final = provider.requests[0].body["messages"]
guard_messages: Final = guard.requests[0].body["guard_input"]["messages"]
exported_messages: Final = generic_api_export.exports[0].body["messages"]
assert response.choices[0].message.content == "Handled safely"
assert guard_messages == [CHAT_MESSAGES[0], CHAT_MESSAGES[3]]
assert provider_messages == [
{"role": "user", "content": [{"type": "text", "text": "Earlier safe question"}]},
{"role": "assistant", "content": [{"type": "text", "text": "Earlier safe answer"}]},
{"role": "user", "content": [{"type": "text", "text": "Employee SSN: <US_SSN>"}]},
]
assert provider.requests[0].body["system"] == [{"type": "text", "text": "Keep the answer short"}]
assert exported_messages == CHAT_MESSAGES
assert has_rust_response_marker(response)
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ("python", "rust"))
async def test_rubrik_block_preserves_context_for_error_exporters(
backend: Backend,
monkeypatch: pytest.MonkeyPatch,
generic_api_export: GenericAPIExportHarness,
) -> None:
monkeypatch.setenv("RUBRIK_BATCH_SIZE", "1")
async with isolated_backend(backend):
with recording_service() as provider, recording_service() as rubrik_service:
provider.default_response = ResponseSpec(body=CHAT_RESPONSE)
rubrik_service.expected_requests = None
rubrik_service.default_response = ResponseSpec(
body={
"choices": [
{
"message": {
"role": "assistant",
"content": "Response blocked by policy",
"tool_calls": [],
}
}
]
}
)
rubrik: Final = RubrikLogger(
api_key="test-rubrik-key",
api_base=rubrik_service.base_url,
guardrail_name="rubrik-block",
event_hook=GuardrailEventHooks.post_call,
default_on=True,
)
recorder: Final = RecordingLogger()
litellm.callbacks.append(rubrik)
try:
with pytest.raises(ModifyResponseException, match="Response blocked by policy") as raised:
await litellm.acompletion(
model=CHAT_MODEL,
messages=CHAT_MESSAGES,
api_key="test-key",
api_base=provider.base_url,
callbacks=[generic_api_export.logger, recorder],
guardrails=[rubrik.guardrail_name],
)
assert has_rust_response_marker(raised.value.original_response) is (backend == "rust")
await drain_logging()
await rubrik.flush_queue()
moderation_requests: Final = tuple(
request for request in rubrik_service.requests if request.path == "/v1/after_completion/openai/v1"
)
batch_requests: Final = tuple(
request for request in rubrik_service.requests if request.path == "/v1/litellm/batch"
)
assert len(provider.requests) == 1
assert len(moderation_requests) == 1
assert moderation_requests[0].body["request"]["messages"] == CHAT_MESSAGES
assert moderation_requests[0].body["response"]["choices"][0]["message"]["content"] == "Handled safely"
assert len(batch_requests) == 1
assert "Response blocked by policy" in json.dumps(batch_requests[0].body)
assert len(generic_api_export.exports) == 1
exported: Final = generic_api_export.exports[0].body
assert exported["status"] == "failure"
assert exported["error_information"]["error_class"] == "ModifyResponseException"
assert "Response blocked by policy" in exported["error_information"]["error_message"]
finally:
await rubrik.aclose()
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ("python", "rust"))
async def test_purview_audit_retains_payload_after_sdk_response(
backend: Backend,
generic_api_export: GenericAPIExportHarness,
) -> None:
async with isolated_backend(backend):
with recording_service() as provider:
provider.default_response = ResponseSpec(body=CHAT_RESPONSE)
purview: Final = purview_harness("purview-audit")
recorder: Final = RecordingLogger()
litellm.callbacks.append(purview.guardrail)
request_task: Final = asyncio.create_task(
litellm.acompletion(
model=CHAT_MODEL,
messages=CHAT_MESSAGES,
api_key="test-key",
api_base=provider.base_url,
metadata={"user_api_key_user_id": "audit-user"},
callbacks=[generic_api_export.logger, recorder],
guardrails=[purview.guardrail.guardrail_name],
)
)
try:
accepted: Final = await asyncio.to_thread(purview.graph.accepted.wait, 3)
assert accepted
response: Final = await asyncio.wait_for(asyncio.shield(request_task), 2)
assert response.choices[0].message.content == "Handled safely"
assert len(await wait_for_audits(purview.graph, count=1)) == 1
purview.graph.release.set()
await recorder.wait_for_async("async_log_success_event")
audits: Final = await wait_for_audits(purview.graph)
activities: Final = tuple(
post.body["contentToProcess"]["activityMetadata"]["activity"] for post in audits
)
assert activities == ("uploadText", "downloadText")
assert CHAT_MESSAGES[-1]["content"] in json.dumps(audits[0].body)
assert "Handled safely" in json.dumps(audits[1].body)
assert generic_api_export.exports[0].body["status"] == "success"
assert has_rust_response_marker(response) is (backend == "rust")
finally:
purview.graph.release.set()
if not request_task.done():
request_task.cancel()
await asyncio.gather(request_task, return_exceptions=True)
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ("python", "rust"))
async def test_purview_sync_audit_runs_without_caller_event_loop(
backend: Backend,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("LITERAL_BATCH_SIZE", "1")
async with isolated_backend(backend):
with recording_service() as provider, recording_service() as sink:
provider.default_response = ResponseSpec(body=CHAT_RESPONSE)
purview: Final = purview_harness("purview-sync-audit")
exporter: Final = LiteralAILogger(literalai_api_key="test-key", literalai_api_url=sink.base_url)
litellm.callbacks.append(purview.guardrail)
try:
response: Final = await asyncio.to_thread(
litellm.completion,
model=CHAT_MODEL,
messages=CHAT_MESSAGES,
api_key="test-key",
api_base=provider.base_url,
metadata={"user_api_key_user_id": "audit-user"},
callbacks=[exporter],
guardrails=[purview.guardrail.guardrail_name],
)
accepted: Final = await asyncio.to_thread(purview.graph.accepted.wait, 3)
assert accepted
assert response.choices[0].message.content == "Handled safely"
assert len(await wait_for_audits(purview.graph, count=1)) == 1
purview.graph.release.set()
audits: Final = await wait_for_audits(purview.graph)
assert CHAT_MESSAGES[-1]["content"] in json.dumps(audits[0].body)
assert "Handled safely" in json.dumps(audits[1].body)
assert sink.requests[0].path == "/api/graphql"
assert "Handled safely" in json.dumps(sink.requests[0].body["variables"]["generation_0"])
assert has_rust_response_marker(response) is (backend == "rust")
finally:
purview.graph.release.set()
@pytest.mark.asyncio
@pytest.mark.parametrize("route", NON_STREAM_ASYNC_ROUTES, ids=route_id)
async def test_content_filter_post_call_blocks_provider_response(route: Route, provider: RecordingServer) -> None:
guardrail: Final = ContentFilterGuardrail(
guardrail_name="enforced-content-review",
event_hook=GuardrailEventHooks.post_call,
blocked_words=[BlockedWord(keyword=route.expected_text, action=ContentFilterAction.BLOCK)],
)
litellm.callbacks.append(guardrail)
with pytest.raises(HTTPException, match="Content blocked") as blocked:
await route.invoke(provider, guardrails=["enforced-content-review"])
assert blocked.value.status_code == 400
@pytest.mark.asyncio
async def test_azure_text_moderation_allows_messages_response_over_http(
recording_server: RecordingServer, otel: OtelHarness
) -> None:
recording_server.expected_requests = None
recording_server.default_response = ResponseSpec(body=AZURE_MODERATION_ALLOW_RESPONSE)
recording_server.enqueue(provider_response(MESSAGES_ROUTE))
guardrail: Final = azure_text_moderation(recording_server)
recorder: Final = RecordingLogger()
litellm.callbacks.append(guardrail)
response: Final = await MESSAGES_ROUTE.invoke(
recording_server, callbacks=[otel.logger, recorder], guardrails=[guardrail.guardrail_name]
)
await wait_for_callback(MESSAGES_ROUTE, recorder)
assert len(recording_server.requests) == 2
moderation_request: Final = recording_server.requests[1]
assert moderation_request.path == "/contentsafety/text:analyze?api-version=2024-09-01"
assert moderation_request.headers["ocp-apim-subscription-key"] == "test-azure-key"
assert moderation_request.body == {
"text": MESSAGES_ROUTE.expected_text,
"categories": ["Hate", "Sexual", "SelfHarm", "Violence"],
"blocklistNames": None,
"haltOnBlocklistHit": False,
"outputType": "FourSeverityLevels",
}
assert response["content"][0]["text"] == MESSAGES_ROUTE.expected_text
assert len(await otel.wait_for_spans()) == 1
@pytest.mark.asyncio
async def test_azure_text_moderation_blocks_messages_response_over_http(recording_server: RecordingServer) -> None:
recording_server.expected_requests = None
recording_server.default_response = ResponseSpec(body=AZURE_MODERATION_BLOCK_RESPONSE)
recording_server.enqueue(provider_response(MESSAGES_ROUTE))
guardrail: Final = azure_text_moderation(recording_server)
litellm.callbacks.append(guardrail)
with pytest.raises(HTTPException, match="Violence crossed severity 2") as blocked:
await MESSAGES_ROUTE.invoke(recording_server, guardrails=[guardrail.guardrail_name])
assert blocked.value.status_code == 400
assert recording_server.requests[1].body["text"] == MESSAGES_ROUTE.expected_text
@pytest.mark.asyncio
@pytest.mark.parametrize("route", NON_STREAM_ASYNC_ROUTES, ids=route_id)
async def test_post_call_guardrail_replacement_is_what_loggers_see(
route: Route, provider: RecordingServer, otel: OtelHarness
) -> None:
async def review(response: object) -> object:
match route.name:
case "ocr-async":
return response.model_copy(
update={"pages": [response.pages[0].model_copy(update={"markdown": "Reviewed OCR"})]}
)
case _:
return {**response, "content": [{"type": "text", "text": "Reviewed Messages"}]}
guardrail: Final = ReviewGuardrail(review)
recorder: Final = RecordingLogger()
litellm.callbacks.append(guardrail)
response: Final = await route.invoke(provider, callbacks=[otel.logger, recorder], guardrails=["rust-review"])
assert guardrail.call_types == [CallTypes(route.call_type)]
event: Final = (await wait_for_callback(route, recorder))[0]
span: Final = (await otel.wait_for_spans())[0]
match route.name:
case "ocr-async":
assert response.pages[0].markdown == "Reviewed OCR"
assert event.response.pages[0].markdown == "Reviewed OCR"
case _:
assert response["content"][0]["text"] == "Reviewed Messages"
assert event.response.choices[0].message.content == "Reviewed Messages"
assert "Reviewed Messages" in json.dumps(dict(span.attributes))
assert "guardrails" not in provider.requests[0].body

View file

@ -1,688 +0,0 @@
import asyncio
import copy
import json
import threading
from collections.abc import Mapping
from typing import Final
import pytest
import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.types.utils import CallTypes
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger, drain_logging
from tests.test_litellm_rust.support.provenance import has_rust_response_marker
from tests.test_litellm_rust.support.requests import (
MESSAGES,
MESSAGES_EVENTS,
MESSAGES_MODEL,
MESSAGES_RESPONSE,
request_body,
request_headers,
)
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec
pytestmark = pytest.mark.requires_rust_extension
@pytest.fixture
def messages_server(recording_server: RecordingServer) -> RecordingServer:
recording_server.default_response = ResponseSpec(body=MESSAGES_RESPONSE)
return recording_server
async def call_messages(server: RecordingServer, callbacks: list[CustomLogger], **kwargs: object):
return await litellm.anthropic.messages.acreate(
model=MESSAGES_MODEL,
messages=MESSAGES,
max_tokens=64,
api_key="test-key",
api_base=server.base_url,
callbacks=callbacks,
**kwargs,
)
@pytest.mark.asyncio
async def test_messages_pre_call_receives_expected_provider_request(messages_server: RecordingServer) -> None:
observations: Final = []
class Observe(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
observations.append((model, messages, copy.deepcopy(kwargs["additional_args"])))
await call_messages(messages_server, [Observe()])
assert len(observations) == 1
model, messages, additional_args = observations[0]
assert model == "claude-sonnet-4-5-20250929"
assert messages == MESSAGES
assert additional_args["api_base"] == f"{messages_server.base_url}/v1/messages"
assert additional_args["complete_input_dict"] == {
"model": "claude-sonnet-4-5-20250929",
"messages": MESSAGES,
"max_tokens": 64,
"stream": False,
}
assert additional_args["headers"]["x-api-key"] == "test-key"
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True], ids=["response", "stream"])
@pytest.mark.parametrize("raise_after_edit", [False, True], ids=["edit-returns", "edit-raises"])
@pytest.mark.parametrize("native", [False, True], ids=["python", "rust"])
async def test_messages_pre_call_edits_reach_later_callbacks_but_only_headers_reach_provider(
messages_server: RecordingServer, raise_after_edit: bool, native: bool, stream: bool
) -> None:
litellm.rust(native)
observed: Final = []
class Edit(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
request_body(kwargs)["temperature"] = 0.25
request_headers(kwargs)["x-audit-tag"] = "reviewed"
if raise_after_edit:
raise RuntimeError("audit exporter unavailable")
class Observe(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
observed.append((copy.deepcopy(request_body(kwargs)), dict(request_headers(kwargs))))
if stream:
messages_server.enqueue(ResponseSpec(body=None, events=MESSAGES_EVENTS))
response: Final = await call_messages(messages_server, [Edit(), Observe()], stream=stream)
if stream:
async for _ in response:
pass
assert observed[0][0]["temperature"] == 0.25
assert observed[0][1]["x-audit-tag"] == "reviewed"
assert "temperature" not in messages_server.requests[0].body
assert messages_server.requests[0].headers["x-audit-tag"] == "reviewed"
assert has_rust_response_marker(response) is native
@pytest.mark.asyncio
async def test_messages_pre_call_rebinding_does_not_replace_inflight_request(
messages_server: RecordingServer,
) -> None:
observed: Final = []
class Rebind(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
kwargs["additional_args"]["complete_input_dict"] = {"replacement": True}
class Observe(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
observed.append(request_body(kwargs))
await call_messages(messages_server, [Rebind(), Observe()])
assert observed == [{"replacement": True}]
assert messages_server.requests[0].body["messages"] == MESSAGES
@pytest.mark.asyncio
async def test_messages_pre_call_state_reaches_terminal_callbacks(messages_server: RecordingServer) -> None:
token: Final = object()
observed: Final = []
finished: Final = asyncio.Event()
class Stash(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
kwargs["test-token"] = token
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
observed.append(kwargs["test-token"])
finished.set()
await call_messages(messages_server, [Stash()])
await asyncio.wait_for(finished.wait(), timeout=10)
assert observed == [token]
@pytest.mark.asyncio
@pytest.mark.parametrize("native", [False, True], ids=["python", "rust"])
@pytest.mark.parametrize("provider", ["anthropic", "azure_ai"])
@pytest.mark.parametrize("raise_after_edit", [False, True], ids=["edit-returns", "edit-raises"])
async def test_messages_retained_aliases_preserve_identity_and_snapshot_timing(
messages_server: RecordingServer, native: bool, provider: str, raise_after_edit: bool
) -> None:
litellm.rust(native)
retained: Final = []
observed: Final = []
aliases: Final = []
class Retain(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
body = request_body(kwargs)
message = kwargs["messages"][0]
aliases.append(
(
body["messages"][0] is message,
body["messages"][0]["content"] is message["content"],
body["messages"][0]["content"][0] is message["content"][0],
)
)
retained.append(message["content"][0])
class Edit(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
retained[0]["text"] = "changed through retained reference"
if raise_after_edit:
raise RuntimeError("export failed after mutation")
class Observe(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
block = request_body(kwargs)["messages"][0]["content"][0]
observed.append((block is retained[0], block["text"]))
response: Final = await litellm.anthropic.messages.acreate(
model=MESSAGES_MODEL.replace("anthropic/", f"{provider}/"),
messages=[{"role": "user", "content": [{"type": "text", "text": "original"}]}],
max_tokens=64,
api_key="test-key",
api_base=messages_server.base_url,
callbacks=[Retain(), Edit(), Observe()],
)
assert aliases == [(True, True, True)]
assert observed == [(True, "changed through retained reference")]
assert retained[0]["text"] == "changed through retained reference"
assert messages_server.requests[0].body["messages"][0]["content"][0]["text"] == "original"
assert has_rust_response_marker(response) is native
@pytest.mark.asyncio
async def test_messages_callbacks_run_once(messages_server: RecordingServer) -> None:
recorder: Final = RecordingLogger()
await call_messages(messages_server, [recorder])
await recorder.wait_for_async("async_log_success_event")
assert recorder.names.count("log_pre_api_call") == 1
assert recorder.names.count("async_logging_hook") == 1
assert recorder.names.count("async_log_success_event") == 1
assert "log_failure_event" not in recorder.names
assert "async_log_failure_event" not in recorder.names
@pytest.mark.asyncio
async def test_messages_unconsumed_stream_close_drains_and_logs_success_once(messages_server: RecordingServer) -> None:
messages_server.enqueue(ResponseSpec(body=None, events=MESSAGES_EVENTS))
recorder: Final = RecordingLogger()
stream: Final = await call_messages(messages_server, [recorder], stream=True)
assert "async_log_success_event" not in recorder.names
await stream.aclose()
await stream.aclose()
await recorder.wait_for_async("async_log_success_event")
assert recorder.names.count("async_log_success_event") == 1
assert "async_log_failure_event" not in recorder.names
@pytest.mark.asyncio
async def test_messages_logging_drain_waits_for_suspended_callback(messages_server: RecordingServer) -> None:
started: Final = asyncio.Event()
release: Final = asyncio.Event()
finished: Final = asyncio.Event()
class SuspendedLogger(CustomLogger):
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
started.set()
await release.wait()
finished.set()
await call_messages(messages_server, [SuspendedLogger()])
draining: Final = asyncio.create_task(drain_logging())
try:
await asyncio.wait_for(started.wait(), timeout=10)
await asyncio.sleep(0)
assert not draining.done()
assert not finished.is_set()
finally:
release.set()
await asyncio.wait_for(draining, timeout=10)
assert finished.is_set()
@pytest.mark.asyncio
async def test_messages_failure_callbacks_receive_original_provider_error(messages_server: RecordingServer) -> None:
messages_server.default_response = ResponseSpec(body={"error": {"message": "provider unavailable"}}, status=500)
messages_server.expected_requests = None
recorder: Final = RecordingLogger()
with pytest.raises(litellm.InternalServerError) as caught:
await call_messages(messages_server, [recorder])
events: Final = await recorder.wait_for_async("async_log_failure_event")
assert len(events) == 1
assert events[0].call_type == "anthropic_messages"
assert events[0].kwargs["exception"] is caught.value
assert len(messages_server.requests) == 1
assert "async_log_success_event" not in recorder.names
@pytest.mark.asyncio
async def test_messages_success_callbacks_receive_expected_context(messages_server: RecordingServer) -> None:
recorder: Final = RecordingLogger()
await call_messages(
messages_server,
[recorder],
litellm_call_id="messages-success",
metadata={"source": "callback-test"},
)
async_events: Final = await recorder.wait_for_async("async_log_success_event")
assert len(async_events) == 1
event: Final = async_events[0]
assert event.call_type == "anthropic_messages"
assert event.kwargs["litellm_call_id"] == "messages-success"
assert event.kwargs["litellm_params"]["metadata"]["source"] == "callback-test"
assert event.response.choices[0].message.content == "Hello from native Messages"
@pytest.mark.asyncio
async def test_messages_pre_call_runs_in_callers_execution_context(messages_server: RecordingServer) -> None:
caller_thread: Final = threading.current_thread()
observations: Final = []
class Observe(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
observations.append((asyncio.get_running_loop(), threading.current_thread()))
caller_loop: Final = asyncio.get_running_loop()
await call_messages(messages_server, [Observe()])
assert observations == [(caller_loop, caller_thread)]
@pytest.mark.asyncio
async def test_messages_stream_logs_success_after_exhaustion(messages_server: RecordingServer) -> None:
messages_server.enqueue(ResponseSpec(body=None, events=MESSAGES_EVENTS))
recorder: Final = RecordingLogger()
stream: Final = await call_messages(messages_server, [recorder], stream=True)
assert "async_log_success_event" not in recorder.names
chunks: Final = [chunk async for chunk in stream]
events: Final = await recorder.wait_for_async("async_log_success_event")
assert chunks
assert len(events) == 1
assert "async_log_stream_event" not in recorder.names
assert events[0].kwargs["complete_streaming_response"] is not None
@pytest.mark.asyncio
@pytest.mark.parametrize("native", [False, True])
@pytest.mark.parametrize("stream", [False, True])
async def test_messages_compression_hook_replaces_messages_sent_to_provider(
messages_server: RecordingServer, native: bool, stream: bool
) -> None:
litellm.rust(native)
compressed_messages: Final = [{"role": "user", "content": "Compressed context"}]
call_types: Final = []
recorder: Final = RecordingLogger()
class CompressMessages(CustomLogger):
async def async_pre_call_deployment_hook(self, kwargs, call_type):
assert not messages_server.requests
call_types.append(call_type)
return {**kwargs, "messages": compressed_messages}
litellm.callbacks.append(CompressMessages())
if stream:
messages_server.enqueue(ResponseSpec(body=None, events=MESSAGES_EVENTS))
response: Final = await call_messages(messages_server, [recorder], stream=stream)
assert has_rust_response_marker(response) is native
if stream:
assert [chunk async for chunk in response]
await recorder.wait_for_async("async_log_success_event")
assert call_types == [CallTypes.anthropic_messages]
assert messages_server.requests[0].body["messages"] == compressed_messages
pre_calls: Final = tuple(event for event in recorder.events if event.name == "log_pre_api_call")
assert len(pre_calls) == 1
assert request_body(pre_calls[0].kwargs)["messages"] == compressed_messages
@pytest.mark.asyncio
@pytest.mark.parametrize("native", [False, True])
@pytest.mark.parametrize("stream", [False, True])
async def test_messages_deployment_rejection_prevents_provider_call(
messages_server: RecordingServer, native: bool, stream: bool
) -> None:
litellm.rust(native)
messages_server.expected_requests = 0
calls: Final[list[CallTypes | None]] = []
recorder: Final = RecordingLogger()
class Reject(CustomLogger):
async def async_pre_call_deployment_hook(
self, kwargs: dict[str, object], call_type: CallTypes | None
) -> dict[str, object]:
calls.append(call_type)
raise litellm.BadRequestError(
message="deployment policy rejected request", model=MESSAGES_MODEL, llm_provider="anthropic"
)
litellm.callbacks.append(Reject())
with pytest.raises(litellm.BadRequestError, match="deployment policy rejected request"):
await call_messages(messages_server, [recorder], stream=stream)
assert calls == [CallTypes.anthropic_messages]
assert not messages_server.requests
assert "log_pre_api_call" not in recorder.names
@pytest.mark.asyncio
@pytest.mark.parametrize("native", [False, True])
@pytest.mark.parametrize("stream", [False, True])
async def test_messages_provider_open_failure_notifies_deployment_once_and_preserves_error(
messages_server: RecordingServer, native: bool, stream: bool
) -> None:
litellm.rust(native)
messages_server.enqueue(ResponseSpec(body={"error": {"message": "provider unavailable"}}, status=500))
calls: Final[list[tuple[CallTypes | None, Exception, int]]] = []
class FailingObserver(CustomLogger):
async def async_post_call_failure_deployment_hook(
self,
request_data: Mapping[str, object],
exception: Exception,
call_type: CallTypes | None,
fallback_depth: int | None = None,
) -> None:
calls.append((call_type, exception, len(messages_server.requests)))
raise RuntimeError("deployment observer unavailable")
litellm.callbacks.append(FailingObserver())
recorder: Final = RecordingLogger()
with pytest.raises(litellm.InternalServerError) as raised:
await call_messages(messages_server, [recorder], stream=stream)
await recorder.wait_for_async("async_log_failure_event")
assert len(calls) == 1
call_type, exception, request_count = calls[0]
assert call_type == CallTypes.anthropic_messages
assert isinstance(exception, litellm.InternalServerError)
assert exception.status_code == raised.value.status_code == 500
assert request_count == len(messages_server.requests) == 1
assert recorder.names.count("async_log_failure_event") == 1
assert "async_log_success_event" not in recorder.names
@pytest.mark.asyncio
async def test_messages_post_call_guardrail_replacement_reaches_caller_and_logging(
messages_server: RecordingServer,
) -> None:
recorder: Final = RecordingLogger()
call_types: Final = []
class ReviewResponse(CustomLogger):
async def async_post_call_success_deployment_hook(self, request_data, response, call_type):
call_types.append(call_type)
response["content"][0]["text"] = "Reviewed response"
return response
litellm.callbacks.append(ReviewResponse())
response: Final = await call_messages(messages_server, [recorder])
events: Final = await recorder.wait_for_async("async_log_success_event")
assert call_types == [CallTypes.anthropic_messages]
assert response["content"][0]["text"] == "Reviewed response"
assert events[0].response.choices[0].message.content == "Reviewed response"
@pytest.mark.asyncio
async def test_messages_logging_hook_replacement_reaches_later_loggers_only(messages_server: RecordingServer) -> None:
observations: Final = []
exported: Final = asyncio.Event()
class RecordGuardrailVerdict(CustomLogger):
async def async_logging_hook(self, kwargs, result, call_type):
observations.append("guardrail")
return {**kwargs, "guardrail-verdict": "allowed"}, result
class ExportLog(CustomLogger):
async def async_logging_hook(self, kwargs, result, call_type):
return kwargs, result
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
observations.append(("export", kwargs["guardrail-verdict"]))
exported.set()
response: Final = await call_messages(messages_server, [RecordGuardrailVerdict(), ExportLog()])
await asyncio.wait_for(exported.wait(), timeout=10)
assert observations == ["guardrail", ("export", "allowed")]
assert response["content"][0]["text"] == "Hello from native Messages"
@pytest.mark.asyncio
async def test_messages_success_callback_failure_does_not_skip_later_loggers(
messages_server: RecordingServer,
) -> None:
recorder: Final = RecordingLogger()
class UnavailableExporter(CustomLogger):
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
raise RuntimeError("exporter unavailable")
response: Final = await call_messages(messages_server, [UnavailableExporter(), recorder])
events: Final = await recorder.wait_for_async("async_log_success_event")
assert response["content"][0]["text"] == "Hello from native Messages"
assert len(events) == 1
assert "async_log_failure_event" not in recorder.names
@pytest.mark.asyncio
async def test_messages_stream_success_callback_failure_does_not_skip_later_loggers(
messages_server: RecordingServer,
) -> None:
messages_server.enqueue(ResponseSpec(body=None, events=MESSAGES_EVENTS))
recorder: Final = RecordingLogger()
class UnavailableExporter(CustomLogger):
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
raise RuntimeError("exporter unavailable")
stream: Final = await call_messages(messages_server, [UnavailableExporter(), recorder], stream=True)
chunks: Final = [chunk async for chunk in stream]
events: Final = await recorder.wait_for_async("async_log_success_event")
assert chunks
assert len(events) == 1
assert events[0].response.choices[0].message.content == "Hello from native Messages"
assert "async_log_failure_event" not in recorder.names
@pytest.mark.asyncio
async def test_messages_stream_failure_callback_failure_does_not_skip_later_loggers(
messages_server: RecordingServer,
) -> None:
messages_server.enqueue(
ResponseSpec(
body=None,
events=(
MESSAGES_EVENTS[0],
(
"error",
{"type": "error", "error": {"type": "overloaded_error", "message": "upstream overloaded"}},
),
),
)
)
recorder: Final = RecordingLogger()
class UnavailableExporter(CustomLogger):
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
raise RuntimeError("exporter unavailable")
stream: Final = await call_messages(messages_server, [UnavailableExporter(), recorder], stream=True)
with pytest.raises(litellm.APIError) as caught:
async for _ in stream:
pass
events: Final = await recorder.wait_for_async("async_log_failure_event")
assert len(events) == 1
assert isinstance(events[0].kwargs["exception"], litellm.APIError)
assert type(events[0].kwargs["exception"]) is type(caught.value)
assert "async_log_success_event" not in recorder.names
@pytest.mark.asyncio
async def test_messages_concurrent_calls_keep_callback_state_isolated(messages_server: RecordingServer) -> None:
messages_server.expected_requests = 4
tokens: Final = {f"messages-{index}": object() for index in range(4)}
terminal_state: Final = []
class CorrelateCallState(RecordingLogger):
def log_pre_api_call(self, model, messages, kwargs):
kwargs["correlation-token"] = tokens[kwargs["litellm_call_id"]]
super().log_pre_api_call(model, messages, kwargs)
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
terminal_state.append((kwargs["litellm_call_id"], kwargs["correlation-token"]))
await super().async_log_success_event(kwargs, response_obj, start_time, end_time)
correlate: Final = CorrelateCallState()
await asyncio.gather(*(call_messages(messages_server, [correlate], litellm_call_id=call_id) for call_id in tokens))
await correlate.wait_for_async("async_log_success_event", count=4)
assert len(terminal_state) == 4
assert all(token is tokens[call_id] for call_id, token in terminal_state)
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True])
async def test_messages_cancelled_call_runs_no_terminal_callbacks(
messages_server: RecordingServer, stream: bool
) -> None:
messages_server.default_response = ResponseSpec(
body=MESSAGES_RESPONSE,
delay=0.5,
events=MESSAGES_EVENTS if stream else (),
)
recorder: Final = RecordingLogger()
task: Final = asyncio.create_task(call_messages(messages_server, [recorder], stream=stream))
async with asyncio.timeout(10):
while not messages_server.requests:
await asyncio.sleep(0.01)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=10)
assert recorder.names.count("log_pre_api_call") == 1
assert "log_success_event" not in recorder.names
assert "async_log_success_event" not in recorder.names
assert "log_failure_event" not in recorder.names
assert "async_log_failure_event" not in recorder.names
@pytest.mark.asyncio
@pytest.mark.parametrize("status", [200, 500])
async def test_whole_call_with_supplied_logger_still_owns_terminal_callbacks(
messages_server: RecordingServer, status: int
) -> None:
from litellm.rust_bridge import messages as bridge
events: Final = []
class SuppliedLogger:
def pre_call(self, **kwargs):
events.append(("pre", kwargs))
async def async_success_handler(self, response, start, end):
events.append(("success", response))
def _should_run_sync_callbacks_for_async_calls(self):
return False
def failure_handler(self, error, trace, start, end):
events.append(("sync_failure", error))
async def async_failure_handler(self, error, trace, start, end):
events.append(("async_failure", error))
messages_server.default_response = ResponseSpec(body=MESSAGES_RESPONSE, status=status)
arguments: Final = {
"model": "claude-opus-5",
"body": {"model": "claude-opus-5", "messages": MESSAGES, "max_tokens": 64},
"api_key": "test",
"api_base": messages_server.base_url,
"custom_llm_provider": "anthropic",
"extra_headers": {},
"timeout": 5.0,
"logging_obj": SuppliedLogger(),
}
if status == 200:
response: Final = await bridge.amessages(**arguments)
await drain_logging()
assert [name for name, value in events] == ["pre", "success"]
assert events[1][1] is response
return
with pytest.raises(litellm.InternalServerError) as raised:
await bridge.amessages(**arguments)
assert [name for name, value in events] == ["pre", "sync_failure", "async_failure"]
assert events[1][1] is raised.value
assert events[2][1] is raised.value
@pytest.mark.asyncio
@pytest.mark.parametrize("detach", (False, True))
async def test_direct_bridge_stream_owns_callbacks(messages_server: RecordingServer, detach: bool) -> None:
from litellm.rust_bridge import messages as bridge
recorder: Final = RecordingLogger()
release: Final = threading.Event()
if not detach:
release.set()
messages_server.enqueue(
ResponseSpec(
body=None,
chunks=tuple(f"event: {event}\ndata: {json.dumps(data)}\n\n".encode() for event, data in MESSAGES_EVENTS),
release=release,
)
)
stream: Final = await bridge.amessages(
model=MESSAGES_MODEL.split("/", 1)[-1],
body={"model": MESSAGES_MODEL.split("/", 1)[-1], "messages": MESSAGES, "max_tokens": 64, "stream": True},
api_key="test-key",
api_base=messages_server.base_url,
custom_llm_provider="anthropic",
extra_headers=None,
timeout=5.0,
request_arguments={"callbacks": [recorder]},
)
assert "async_log_success_event" not in recorder.names
try:
if detach:
assert await anext(stream)
await asyncio.wait_for(stream.aclose(), timeout=1)
await stream.aclose()
assert "async_log_success_event" not in recorder.names
assert "async_log_failure_event" not in recorder.names
else:
assert b"message_stop" in b"".join([chunk async for chunk in stream])
finally:
release.set()
events: Final = await recorder.wait_for_async("async_log_success_event")
assert len(events) == 1
assert events[0].stream is True
assert events[0].response.usage.total_tokens == 9
assert events[0].response.choices[0].message.content == "Hello from native Messages"
assert recorder.names.count("log_pre_api_call") == 1
await stream.aclose()

View file

@ -1,275 +0,0 @@
import json
from collections.abc import AsyncIterator
from typing import Final, cast
import pytest
import litellm
from tests.test_litellm_rust.support.requests import MESSAGES, MESSAGES_EVENTS, MESSAGES_MODEL, MESSAGES_RESPONSE
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec
pytestmark = pytest.mark.requires_rust_extension
@pytest.fixture
def messages_server(recording_server: RecordingServer) -> RecordingServer:
recording_server.default_response = ResponseSpec(body=MESSAGES_RESPONSE)
return recording_server
async def call_messages(server: RecordingServer, **kwargs: object):
return await litellm.anthropic.messages.acreate(
model=MESSAGES_MODEL,
messages=MESSAGES,
max_tokens=64,
api_key="test-key",
api_base=server.base_url,
**kwargs,
)
def assert_native_request(server: RecordingServer) -> None:
assert len(server.requests) == 1
assert "accept-encoding" not in server.requests[0].headers
def assert_native_response(response: object) -> None:
assert isinstance(response, dict)
assert response["_hidden_params"]["additional_headers"] == {"x-litellm-rust": "true"}
@pytest.mark.asyncio
async def test_messages_sends_expected_provider_request(messages_server: RecordingServer) -> None:
response: Final = await call_messages(messages_server)
assert response["content"] == [{"type": "text", "text": "Hello from native Messages"}]
assert_native_response(response)
assert_native_request(messages_server)
request: Final = messages_server.requests[0]
assert request.path == "/v1/messages"
assert request.headers["x-api-key"] == "test-key"
assert request.headers["anthropic-version"] == "2023-06-01"
assert request.body == {
"model": "claude-sonnet-4-5-20250929",
"messages": MESSAGES,
"max_tokens": 64,
}
@pytest.mark.asyncio
async def test_messages_sends_custom_headers(messages_server: RecordingServer) -> None:
response: Final = await call_messages(messages_server, extra_headers={"x-trace-id": "trace-1"})
assert_native_response(response)
assert messages_server.requests[0].headers["x-trace-id"] == "trace-1"
@pytest.mark.asyncio
async def test_messages_resolves_provider_credentials(
messages_server: RecordingServer, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("ANTHROPIC_API_KEY", "environment-key")
response: Final = await litellm.anthropic.messages.acreate(
model=MESSAGES_MODEL,
messages=MESSAGES,
max_tokens=64,
api_base=messages_server.base_url,
)
assert_native_response(response)
assert messages_server.requests[0].headers["x-api-key"] == "environment-key"
@pytest.mark.asyncio
async def test_messages_explicit_credentials_override_defaults(
messages_server: RecordingServer, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("ANTHROPIC_API_KEY", "environment-key")
response: Final = await call_messages(messages_server)
assert_native_response(response)
assert messages_server.requests[0].headers["x-api-key"] == "test-key"
@pytest.mark.asyncio
async def test_azure_messages_uses_foundry_endpoint_and_credentials(messages_server: RecordingServer) -> None:
response: Final = await litellm.anthropic.messages.acreate(
model="azure_ai/claude-opus-4.5",
messages=MESSAGES,
max_tokens=64,
api_key="azure-key",
api_base=f"{messages_server.base_url}/anthropic",
)
assert_native_response(response)
assert_native_request(messages_server)
assert messages_server.requests[0].path == "/anthropic/v1/messages"
assert messages_server.requests[0].headers["x-api-key"] == "azure-key"
@pytest.mark.asyncio
async def test_messages_stream_yields_anthropic_events(messages_server: RecordingServer) -> None:
messages_server.enqueue(ResponseSpec(body=None, events=MESSAGES_EVENTS))
stream: Final = cast(AsyncIterator[bytes], await call_messages(messages_server, stream=True))
payload: Final = b"".join([chunk async for chunk in stream])
assert_native_request(messages_server)
assert messages_server.requests[0].body["stream"] is True
assert b"event: message_start" in payload
assert b"event: content_block_delta" in payload
assert b"Hello from native Messages" in payload
assert b"event: message_stop" in payload
message_delta: Final = next(
json.loads(block.split(b"data: ", 1)[1])
for block in payload.split(b"\n\n")
if block.startswith(b"event: message_delta")
)
assert message_delta["usage"] == {"input_tokens": 5, "output_tokens": 4}
@pytest.mark.asyncio
@pytest.mark.parametrize("provider", ["anthropic", "azure_ai"])
async def test_messages_delivers_before_upstream_finishes(messages_server: RecordingServer, provider: str) -> None:
import asyncio
import threading
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger
release: Final = threading.Event()
recorder: Final = RecordingLogger()
chunks: Final = tuple(f"event: {event}\ndata: {json.dumps(data)}\n\n".encode() for event, data in MESSAGES_EVENTS)
messages_server.enqueue(ResponseSpec(body=None, chunks=chunks, release=release))
try:
stream: Final = await asyncio.wait_for(
litellm.anthropic.messages.acreate(
model=MESSAGES_MODEL.split("/", 1)[-1],
custom_llm_provider=provider,
messages=MESSAGES,
max_tokens=64,
api_key="test-key",
api_base=messages_server.base_url,
stream=True,
callbacks=[recorder],
),
timeout=5,
)
first: Final = await asyncio.wait_for(anext(stream), timeout=2)
assert b"message_start" in first
assert not release.is_set()
assert "async_log_success_event" not in recorder.names
assert messages_server.requests[0].body["stream"] is True
assert stream._hidden_params["additional_headers"]["x-litellm-rust"] == "true"
assert messages_server.requests[0].path == (
"/v1/messages" if provider == "anthropic" else "/anthropic/v1/messages"
)
release.set()
remaining: Final = b"".join([chunk async for chunk in stream])
assert first + remaining == b"".join(chunks)
await stream.aclose()
events: Final = await recorder.wait_for_async("async_log_success_event")
assert len(events) == 1
assert "async_log_failure_event" not in recorder.names
assert events[0].response.choices[0].message.content == "Hello from native Messages"
assert events[0].response.usage.completion_tokens == 4
assert len(messages_server.requests) == 1
finally:
release.set()
@pytest.mark.asyncio
@pytest.mark.parametrize("ending", ["truncated", "provider_error", "http_error"])
async def test_messages_stream_failures_do_not_replay(messages_server: RecordingServer, ending: str) -> None:
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger
recorder: Final = RecordingLogger()
events: Final = (
MESSAGES_EVENTS[:-1]
if ending == "truncated"
else (
*MESSAGES_EVENTS[:1],
("error", {"type": "error", "error": {"type": "overloaded_error", "message": "overloaded"}}),
)
)
messages_server.enqueue(
ResponseSpec(
body={"error": "rejected"},
status=429 if ending == "http_error" else 200,
events=() if ending == "http_error" else events,
)
)
async def consume() -> None:
stream: Final = await call_messages(messages_server, stream=True, callbacks=[recorder])
async for _ in stream:
pass
with pytest.raises(litellm.APIError):
await consume()
await recorder.wait_for_async("async_log_failure_event")
assert recorder.names.count("async_log_failure_event") == 1
assert "async_log_success_event" not in recorder.names
assert len(messages_server.requests) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("abandon", ["close", "cancel", "drop", "exhaust"])
async def test_messages_stream_abandonment_releases_roots(messages_server: RecordingServer, abandon: str) -> None:
import asyncio
import gc
import threading
import weakref
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger
class NamesOnlyLogger(RecordingLogger):
def _record(self, name: str, kwargs: object = None, response: object = None) -> None:
super()._record(name)
class Root:
pass
root = Root()
reference: Final = weakref.ref(root)
release: Final = threading.Event()
recorder: Final = NamesOnlyLogger()
chunks: Final = tuple(f"event: {event}\ndata: {json.dumps(data)}\n\n".encode() for event, data in MESSAGES_EVENTS)
messages_server.enqueue(ResponseSpec(body=None, chunks=chunks, release=release))
try:
stream = await call_messages(messages_server, stream=True, callbacks=[recorder], metadata={"retained": root})
del root
assert reference() is not None
assert b"message_start" in await anext(stream)
if abandon == "exhaust":
release.set()
async for _ in stream:
pass
await recorder.wait_for_async("async_log_success_event")
gc.collect()
assert reference() is None
assert recorder.names.count("async_log_success_event") == 1
return
if abandon == "cancel":
task: Final = asyncio.create_task(anext(stream))
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
elif abandon == "close":
await stream.aclose()
await stream.aclose()
del stream
gc.collect()
assert reference() is not None
assert "async_log_success_event" not in recorder.names
assert "async_log_failure_event" not in recorder.names
release.set()
await recorder.wait_for_async("async_log_success_event")
assert recorder.names.count("async_log_success_event") == 1
assert "async_log_failure_event" not in recorder.names
assert len(messages_server.requests) == 1
gc.collect()
assert reference() is None
finally:
release.set()

View file

@ -9,7 +9,6 @@ import pytest
import litellm
from litellm.integrations.custom_logger import CustomLogger
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger
from tests.test_litellm_rust.support.provenance import has_rust_response_marker
from tests.test_litellm_rust.support.requests import (
OCR_DOCUMENT,
OCR_RESPONSE,
@ -18,6 +17,7 @@ from tests.test_litellm_rust.support.requests import (
request_body,
request_headers,
)
from tests.test_litellm_rust.support.response_marker import has_rust_response_marker
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec
pytestmark = pytest.mark.requires_rust_extension
@ -37,19 +37,18 @@ async def call_aocr(server: RecordingServer, callbacks: list[CustomLogger], **kw
return await call_native_aocr(server, callbacks=callbacks, **kwargs)
def test_pre_call_receives_expected_provider_request(ocr_server: RecordingServer) -> None:
def test_native_ocr_pre_call_callback_receives_transformed_provider_request(ocr_server: RecordingServer) -> None:
observations: Final = []
class Observe(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
observations.append((model, messages, copy.deepcopy(kwargs["additional_args"])))
def log_pre_api_call(self, model, _messages, kwargs):
observations.append((model, copy.deepcopy(kwargs["additional_args"])))
call_ocr(ocr_server, [Observe()], pages=[0])
assert len(observations) == 1
model, messages, additional_args = observations[0]
model, additional_args = observations[0]
assert model == "mistral-ocr-latest"
assert messages == [{"role": "user", "content": "default-message-value"}]
assert additional_args["api_base"] == f"{ocr_server.base_url}/v1/ocr"
assert additional_args["complete_input_dict"] == {
"model": "mistral-ocr-latest",
@ -58,20 +57,20 @@ def test_pre_call_receives_expected_provider_request(ocr_server: RecordingServer
}
@pytest.mark.parametrize("raise_after_edit", [False, True])
def test_pre_call_body_edits_reach_later_callbacks_and_provider(
@pytest.mark.parametrize("raise_after_edit", [False, True], ids=["callback-returns", "callback-raises"])
def test_native_ocr_pre_call_body_edit_reaches_next_callback_and_provider(
ocr_server: RecordingServer, raise_after_edit: bool
) -> None:
observed: Final = []
class Edit(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
def log_pre_api_call(self, model, _messages, kwargs):
request_body(kwargs)["include_image_base64"] = True
if raise_after_edit:
raise RuntimeError("audit exporter unavailable")
raise RuntimeError("pre-call callback failed")
class Observe(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
def log_pre_api_call(self, model, _messages, kwargs):
observed.append(copy.deepcopy(request_body(kwargs)))
call_ocr(ocr_server, [Edit(), Observe()], include_image_base64=False)
@ -80,15 +79,15 @@ def test_pre_call_body_edits_reach_later_callbacks_and_provider(
assert ocr_server.requests[0].body["include_image_base64"] is True
def test_pre_call_header_edits_reach_later_callbacks_and_provider(ocr_server: RecordingServer) -> None:
def test_native_ocr_pre_call_header_edit_reaches_next_callback_and_provider(ocr_server: RecordingServer) -> None:
observed: Final = []
class Edit(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
def log_pre_api_call(self, model, _messages, kwargs):
request_headers(kwargs)["x-audit-tag"] = "reviewed"
class Observe(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
def log_pre_api_call(self, model, _messages, kwargs):
observed.append(dict(request_headers(kwargs)))
call_ocr(ocr_server, [Edit(), Observe()])
@ -98,9 +97,9 @@ def test_pre_call_header_edits_reach_later_callbacks_and_provider(ocr_server: Re
@pytest.mark.asyncio
@pytest.mark.parametrize("rust_enabled", [False, True], ids=["python", "rust"])
@pytest.mark.parametrize("rust_enabled", [False, True], ids=["python-backend", "rust-backend"])
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
async def test_pre_call_nested_mutation_updates_retained_references(
async def test_ocr_pre_call_nested_document_edit_updates_caller_callback_and_provider_references(
ocr_server: RecordingServer, rust_enabled: bool, asynchronous: bool
) -> None:
litellm.rust(rust_enabled)
@ -110,12 +109,12 @@ async def test_pre_call_nested_mutation_updates_retained_references(
aliases: Final = []
class Retain(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
def log_pre_api_call(self, model, _messages, kwargs):
aliases.append(request_body(kwargs)["document"] is original)
retained.append(request_body(kwargs)["document"])
class Edit(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
def log_pre_api_call(self, model, _messages, kwargs):
original["document_url"] = replacement_url
arguments: Final = {
@ -134,13 +133,15 @@ async def test_pre_call_nested_mutation_updates_retained_references(
assert has_rust_response_marker(response) is rust_enabled
def test_pre_call_field_replacement_preserves_original_references(ocr_server: RecordingServer) -> None:
def test_native_ocr_pre_call_document_replacement_does_not_mutate_original_document(
ocr_server: RecordingServer,
) -> None:
original: Final = dict(OCR_DOCUMENT)
replacement: Final = {"type": "document_url", "document_url": "data:application/pdf;base64,ZGVm"}
retained: Final = []
class RetainAndReplace(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
def log_pre_api_call(self, model, _messages, kwargs):
body = request_body(kwargs)
retained.append(body["document"])
body["document"] = replacement
@ -156,15 +157,17 @@ def test_pre_call_field_replacement_preserves_original_references(ocr_server: Re
assert ocr_server.requests[0].body["document"] == replacement
def test_pre_call_body_rebinding_does_not_replace_inflight_request(ocr_server: RecordingServer) -> None:
def test_native_ocr_pre_call_body_rebinding_is_visible_to_callbacks_but_not_provider(
ocr_server: RecordingServer,
) -> None:
observed: Final = []
class Rebind(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
def log_pre_api_call(self, model, _messages, kwargs):
kwargs["additional_args"]["complete_input_dict"] = {"replacement": True}
class Observe(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
def log_pre_api_call(self, model, _messages, kwargs):
observed.append(request_body(kwargs))
call_ocr(ocr_server, [Rebind(), Observe()])
@ -173,15 +176,15 @@ def test_pre_call_body_rebinding_does_not_replace_inflight_request(ocr_server: R
assert ocr_server.requests[0].body == {"model": "mistral-ocr-latest", "document": OCR_DOCUMENT}
def test_queued_payload_observes_later_callback_mutations(ocr_server: RecordingServer) -> None:
def test_native_ocr_callback_retained_body_observes_later_callback_mutation(ocr_server: RecordingServer) -> None:
queued: Final = []
class QueuePayload(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
def log_pre_api_call(self, model, _messages, kwargs):
queued.append(request_body(kwargs))
class Edit(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
def log_pre_api_call(self, model, _messages, kwargs):
request_body(kwargs)["queued-edit"] = True
call_ocr(ocr_server, [QueuePayload(), Edit()])
@ -189,13 +192,13 @@ def test_queued_payload_observes_later_callback_mutations(ocr_server: RecordingS
assert queued[0]["queued-edit"] is True
def test_pre_call_state_reaches_terminal_callbacks(ocr_server: RecordingServer) -> None:
def test_native_ocr_success_callback_receives_state_added_by_pre_call_callback(ocr_server: RecordingServer) -> None:
token: Final = object()
terminal_tokens: queue.SimpleQueue[object] = queue.SimpleQueue()
finished: Final = threading.Event()
class Stash(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
def log_pre_api_call(self, model, _messages, kwargs):
kwargs["test-token"] = token
def log_success_event(self, kwargs, response_obj, start_time, end_time):
@ -209,7 +212,9 @@ def test_pre_call_state_reaches_terminal_callbacks(ocr_server: RecordingServer)
@pytest.mark.asyncio
async def test_success_callbacks_receive_expected_context_and_response(ocr_server: RecordingServer) -> None:
async def test_native_aocr_success_callback_receives_call_id_metadata_and_response(
ocr_server: RecordingServer,
) -> None:
recorder: Final = RecordingLogger()
await call_aocr(
@ -228,7 +233,9 @@ async def test_success_callbacks_receive_expected_context_and_response(ocr_serve
@pytest.mark.asyncio
async def test_failure_callbacks_receive_expected_context_and_error(ocr_server: RecordingServer) -> None:
async def test_native_aocr_failure_callbacks_receive_call_type_error_and_no_response(
ocr_server: RecordingServer,
) -> None:
ocr_server.enqueue(ResponseSpec(body={"message": "provider unavailable"}, status=500))
observations: Final = []
@ -249,7 +256,7 @@ async def test_failure_callbacks_receive_expected_context_and_error(ocr_server:
@pytest.mark.asyncio
async def test_pre_call_runs_in_callers_execution_context(ocr_server: RecordingServer) -> None:
async def test_native_aocr_pre_call_callback_runs_on_caller_loop_and_thread(ocr_server: RecordingServer) -> None:
caller_loop: Final = asyncio.get_running_loop()
caller_thread: Final = threading.current_thread()
recorder: Final = RecordingLogger()
@ -263,13 +270,15 @@ async def test_pre_call_runs_in_callers_execution_context(ocr_server: RecordingS
@pytest.mark.asyncio
async def test_ocr_failure_callbacks_receive_pre_call_state(ocr_server: RecordingServer) -> None:
async def test_native_aocr_failure_callbacks_receive_state_added_by_pre_call_callback(
ocr_server: RecordingServer,
) -> None:
ocr_server.enqueue(ResponseSpec(body={"message": "provider unavailable"}, status=500))
token: Final = object()
observed: Final = []
class TrackInFlightRequest(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
def log_pre_api_call(self, model, _messages, kwargs):
kwargs["request-token"] = token
def log_failure_event(self, kwargs, response_obj, start_time, end_time):
@ -286,21 +295,21 @@ async def test_ocr_failure_callbacks_receive_pre_call_state(ocr_server: Recordin
@pytest.mark.asyncio
async def test_ocr_failure_callback_error_does_not_mask_provider_error_or_later_callbacks(
async def test_native_aocr_callback_error_does_not_mask_provider_error_or_skip_later_failure_callbacks(
ocr_server: RecordingServer,
) -> None:
ocr_server.enqueue(ResponseSpec(body={"message": "provider unavailable"}, status=500))
recorder: Final = RecordingLogger()
class UnavailableExporter(CustomLogger):
class FailingCallback(CustomLogger):
def log_failure_event(self, kwargs, response_obj, start_time, end_time):
raise RuntimeError("exporter unavailable")
raise RuntimeError("failure callback failed")
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
raise RuntimeError("exporter unavailable")
raise RuntimeError("failure callback failed")
with pytest.raises(litellm.InternalServerError) as caught:
await call_aocr(ocr_server, [UnavailableExporter(), recorder])
await call_aocr(ocr_server, [FailingCallback(), recorder])
sync_events: Final = tuple(event for event in recorder.events if event.name == "log_failure_event")
async_events: Final = tuple(event for event in recorder.events if event.name == "async_log_failure_event")
@ -311,7 +320,9 @@ async def test_ocr_failure_callback_error_does_not_mask_provider_error_or_later_
assert "async_log_success_event" not in recorder.names
def test_ocr_duplicate_callback_registration_dispatches_once(ocr_server: RecordingServer) -> None:
def test_native_ocr_dispatches_each_callback_phase_once_when_logger_is_registered_multiple_times(
ocr_server: RecordingServer,
) -> None:
recorder: Final = RecordingLogger()
call_ocr(
@ -329,8 +340,8 @@ def test_ocr_duplicate_callback_registration_dispatches_once(ocr_server: Recordi
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True])
async def test_azure_token_callback_precedes_logger_and_preserves_context(
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
async def test_public_azure_ocr_resolves_token_before_pre_call_on_caller_context(
ocr_server: RecordingServer,
isolated_azure_auth: None,
asynchronous: bool,
@ -353,7 +364,7 @@ async def test_azure_token_callback_precedes_logger_and_preserves_context(
return "caller-token"
class Edit(CustomLogger):
def log_pre_api_call(self, model, messages, kwargs):
def log_pre_api_call(self, model, _messages, kwargs):
assert request_headers(kwargs)["Authorization"] == "Bearer caller-token"
observations.append("pre_call")
request_headers(kwargs)["Authorization"] = "Bearer edited"
@ -374,8 +385,8 @@ async def test_azure_token_callback_precedes_logger_and_preserves_context(
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True])
async def test_azure_token_callback_can_reenter_native_sdk(
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
async def test_public_azure_ocr_token_provider_can_make_nested_native_ocr_call(
ocr_server: RecordingServer,
isolated_azure_auth: None,
asynchronous: bool,
@ -408,7 +419,7 @@ async def test_azure_token_callback_can_reenter_native_sdk(
@pytest.mark.asyncio
async def test_concurrent_azure_token_callbacks_keep_results_and_errors_separate(
async def test_concurrent_public_azure_ocr_calls_isolate_token_results_and_error(
ocr_server: RecordingServer,
isolated_azure_auth: None,
) -> None:
@ -447,7 +458,7 @@ async def test_concurrent_azure_token_callbacks_keep_results_and_errors_separate
@pytest.mark.asyncio
@pytest.mark.parametrize("outcome", ["success", "failure", "cancellation"])
async def test_azure_token_provider_is_released_after_request(
async def test_public_azure_ocr_releases_token_provider_after_terminal_outcome(
ocr_server: RecordingServer,
isolated_azure_auth: None,
outcome: str,

View file

@ -4,7 +4,7 @@ import pytest
import litellm
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec
from tests.test_litellm_rust.support.provenance import has_rust_response_marker
from tests.test_litellm_rust.support.response_marker import has_rust_response_marker
from tests.test_litellm_rust.support.requests import OCR_DOCUMENT, OCR_MODEL, OCR_RESPONSE
pytestmark = pytest.mark.requires_rust_extension
@ -16,7 +16,7 @@ def ocr_server(recording_server: RecordingServer) -> RecordingServer:
return recording_server
def test_public_ocr_entrypoint_uses_native_transport_when_enabled(ocr_server: RecordingServer) -> None:
def test_sync_ocr_response_is_marked_as_rust_when_native_dispatch_is_enabled(ocr_server: RecordingServer) -> None:
response: Final = litellm.ocr(
model=OCR_MODEL,
document=OCR_DOCUMENT,
@ -29,7 +29,9 @@ def test_public_ocr_entrypoint_uses_native_transport_when_enabled(ocr_server: Re
@pytest.mark.asyncio
async def test_public_aocr_entrypoint_uses_native_transport_when_enabled(ocr_server: RecordingServer) -> None:
async def test_async_ocr_response_is_marked_as_rust_when_native_dispatch_is_enabled(
ocr_server: RecordingServer,
) -> None:
response: Final = await litellm.aocr(
model=OCR_MODEL,
document=OCR_DOCUMENT,
@ -41,7 +43,7 @@ async def test_public_aocr_entrypoint_uses_native_transport_when_enabled(ocr_ser
assert has_rust_response_marker(response)
def test_public_ocr_falls_back_when_native_transport_declines(ocr_server: RecordingServer) -> None:
def test_public_ocr_falls_back_to_python_for_unsupported_file_document(ocr_server: RecordingServer) -> None:
response: Final = litellm.ocr(
model=OCR_MODEL,
document={"type": "file", "file": b"%PDF-1.4", "mime_type": "application/pdf"},
@ -53,7 +55,9 @@ def test_public_ocr_falls_back_when_native_transport_declines(ocr_server: Record
assert not has_rust_response_marker(response)
def test_public_ocr_uses_python_transport_when_disabled(ocr_server: RecordingServer) -> None:
def test_public_ocr_response_has_no_rust_marker_when_native_dispatch_is_disabled(
ocr_server: RecordingServer,
) -> None:
litellm.rust(False)
response: Final = litellm.ocr(
@ -67,7 +71,7 @@ def test_public_ocr_uses_python_transport_when_disabled(ocr_server: RecordingSer
assert not has_rust_response_marker(response)
def test_native_ocr_requests_an_uncompressed_response(ocr_server: RecordingServer) -> None:
def test_native_ocr_sends_identity_accept_encoding_header(ocr_server: RecordingServer) -> None:
litellm.ocr(
model=OCR_MODEL,
document=OCR_DOCUMENT,

View file

@ -4,24 +4,23 @@ import pytest
import litellm
from litellm.llms.base_llm.ocr.transformation import OCRResponse
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger
from tests.test_litellm_rust.support.requests import (
OCR_DOCUMENT,
OCR_MODEL,
OCR_RESPONSE,
call_native_ocr,
call_aocr,
call_native_ocr,
call_ocr as call_public_ocr,
)
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger
from tests.test_litellm_rust.support.provenance import has_rust_response_marker
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec
from tests.test_litellm_rust.support.response_marker import has_rust_response_marker
pytestmark = pytest.mark.requires_rust_extension
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True])
async def test_azure_ocr_calls_python_token_provider(
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
async def test_public_azure_ocr_uses_token_provider_result_as_bearer_token(
ocr_server: RecordingServer, isolated_azure_auth: None, asynchronous: bool
) -> None:
calls: Final = []
@ -61,7 +60,7 @@ def assert_native_request(server: RecordingServer) -> None:
assert not server.requests[0].headers.get("user-agent", "").startswith("python-httpx")
def test_ocr_sends_expected_provider_request(ocr_server: RecordingServer) -> None:
def test_native_ocr_sends_model_and_document_to_mistral_ocr_path(ocr_server: RecordingServer) -> None:
response: Final = call_ocr(ocr_server)
assert response.pages[0].markdown == "native OCR response"
@ -70,7 +69,7 @@ def test_ocr_sends_expected_provider_request(ocr_server: RecordingServer) -> Non
assert ocr_server.requests[0].body == {"model": "mistral-ocr-latest", "document": OCR_DOCUMENT}
def test_ocr_rejects_unsupported_file_document_before_callbacks(ocr_server: RecordingServer) -> None:
def test_native_ocr_rejects_file_document_before_callbacks_or_provider_request(ocr_server: RecordingServer) -> None:
ocr_server.expected_requests = 0
recorder: Final = RecordingLogger()
@ -85,21 +84,23 @@ def test_ocr_rejects_unsupported_file_document_before_callbacks(ocr_server: Reco
assert recorder.events == ()
def test_ocr_sends_optional_parameters(ocr_server: RecordingServer) -> None:
def test_native_ocr_sends_pages_and_image_options(ocr_server: RecordingServer) -> None:
call_ocr(ocr_server, pages=[0, 2], include_image_base64=True)
assert ocr_server.requests[0].body["pages"] == [0, 2]
assert ocr_server.requests[0].body["include_image_base64"] is True
def test_ocr_sends_custom_headers(ocr_server: RecordingServer) -> None:
def test_native_ocr_merges_custom_headers_with_authorization(ocr_server: RecordingServer) -> None:
call_ocr(ocr_server, extra_headers={"x-trace-id": "trace-1"})
assert ocr_server.requests[0].headers["authorization"] == "Bearer test-key"
assert ocr_server.requests[0].headers["x-trace-id"] == "trace-1"
def test_ocr_resolves_provider_credentials(ocr_server: RecordingServer, monkeypatch: pytest.MonkeyPatch) -> None:
def test_native_mistral_ocr_uses_environment_api_key_when_argument_is_missing(
ocr_server: RecordingServer, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "environment-key")
call_native_ocr(ocr_server, api_key=None)
@ -107,7 +108,7 @@ def test_ocr_resolves_provider_credentials(ocr_server: RecordingServer, monkeypa
assert ocr_server.requests[0].headers["authorization"] == "Bearer environment-key"
def test_ocr_explicit_credentials_override_defaults(
def test_native_mistral_ocr_prefers_explicit_api_key_over_environment(
ocr_server: RecordingServer, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "environment-key")
@ -117,7 +118,9 @@ def test_ocr_explicit_credentials_override_defaults(
assert ocr_server.requests[0].headers["authorization"] == "Bearer test-key"
def test_ocr_resolves_provider_endpoint(ocr_server: RecordingServer, monkeypatch: pytest.MonkeyPatch) -> None:
def test_native_azure_ocr_uses_environment_endpoint_and_api_key(
ocr_server: RecordingServer, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("AZURE_AI_API_KEY", "azure-key")
monkeypatch.setenv("AZURE_AI_API_BASE", ocr_server.base_url)
@ -128,7 +131,7 @@ def test_ocr_resolves_provider_endpoint(ocr_server: RecordingServer, monkeypatch
assert ocr_server.requests[0].headers["authorization"] == "Bearer azure-key"
def test_ocr_resolves_vertex_project_and_location(ocr_server: RecordingServer) -> None:
def test_native_vertex_ocr_builds_path_from_project_and_location(ocr_server: RecordingServer) -> None:
call_native_ocr(
ocr_server,
model="vertex_ai/mistral-ocr-2505",
@ -143,7 +146,7 @@ def test_ocr_resolves_vertex_project_and_location(ocr_server: RecordingServer) -
)
def test_ocr_returns_normalized_response(ocr_server: RecordingServer) -> None:
def test_native_ocr_normalizes_provider_response_model_and_usage(ocr_server: RecordingServer) -> None:
response: Final = call_ocr(ocr_server)
assert isinstance(response, OCRResponse)
@ -151,7 +154,7 @@ def test_ocr_returns_normalized_response(ocr_server: RecordingServer) -> None:
assert response.usage_info.pages_processed == 1
def test_ocr_provider_error_preserves_status_and_context(ocr_server: RecordingServer) -> None:
def test_native_ocr_maps_provider_400_without_exposing_response_body(ocr_server: RecordingServer) -> None:
ocr_server.enqueue(ResponseSpec(body={"message": "invalid OCR request"}, status=400))
with pytest.raises(litellm.BadRequestError) as caught:
@ -163,7 +166,7 @@ def test_ocr_provider_error_preserves_status_and_context(ocr_server: RecordingSe
assert "invalid OCR request" not in str(caught.value)
def test_ocr_honors_request_timeout(ocr_server: RecordingServer) -> None:
def test_native_ocr_raises_transport_error_when_request_exceeds_timeout(ocr_server: RecordingServer) -> None:
ocr_server.enqueue(ResponseSpec(body=OCR_RESPONSE, delay=0.2))
with pytest.raises(RuntimeError, match="OCR transport failed"):
@ -173,8 +176,8 @@ def test_ocr_honors_request_timeout(ocr_server: RecordingServer) -> None:
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("backend", ["python", "rust"])
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("backend", ["python", "rust"], ids=["python-backend", "rust-backend"])
@pytest.mark.parametrize(
"credentials, expected_token, expected_calls",
[
@ -182,8 +185,9 @@ def test_ocr_honors_request_timeout(ocr_server: RecordingServer) -> None:
({"azure_ad_token": "static-token"}, "callback-1", 1),
({"extra_headers": {"Authorization": "Bearer override"}}, "override", 1),
],
ids=["api-key-skips-provider", "provider-overrides-static-token", "header-overrides-provider"],
)
async def test_azure_ocr_token_provider_precedence(
async def test_public_azure_ocr_applies_same_credential_precedence_on_python_and_rust(
ocr_server: RecordingServer,
isolated_azure_auth: None,
asynchronous: bool,
@ -215,8 +219,8 @@ async def test_azure_ocr_token_provider_precedence(
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True])
async def test_azure_ocr_does_not_cache_caller_tokens(
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
async def test_public_azure_ocr_calls_token_provider_for_each_request(
ocr_server: RecordingServer,
isolated_azure_auth: None,
asynchronous: bool,
@ -250,10 +254,14 @@ class TokenAbort(BaseException):
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True])
@pytest.mark.parametrize("backend", ["python", "rust"])
@pytest.mark.parametrize("failure", ["non_string", "type_error", "ordinary", "abort"])
async def test_azure_ocr_token_failure_stops_execution(
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("backend", ["python", "rust"], ids=["python-backend", "rust-backend"])
@pytest.mark.parametrize(
"failure",
["non_string", "type_error", "ordinary", "abort"],
ids=["non-string-result", "type-error", "value-error", "base-exception"],
)
async def test_public_azure_ocr_token_provider_failure_prevents_pre_call_callback_and_request(
ocr_server: RecordingServer,
isolated_azure_auth: None,
asynchronous: bool,
@ -307,8 +315,9 @@ async def test_azure_ocr_token_failure_stops_execution(
{"model": "azure_ai/doc-intelligence/prebuilt-read"},
{"document": {"type": "file", "file": b"pdf"}},
],
ids=["oidc-assertion", "document-intelligence-model", "file-document"],
)
def test_azure_unsupported_native_auth_does_not_invoke_provider(
def test_native_azure_ocr_rejects_unsupported_configuration_before_token_or_callbacks(
ocr_server: RecordingServer,
isolated_azure_auth: None,
configuration: dict[str, object],
@ -336,8 +345,8 @@ def test_azure_unsupported_native_auth_does_not_invoke_provider(
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["python", "rust"])
async def test_azure_missing_endpoint_prevents_token_callback(
@pytest.mark.parametrize("backend", ["python", "rust"], ids=["python-backend", "rust-backend"])
async def test_public_azure_ocr_validates_endpoint_before_calling_token_provider(
ocr_server: RecordingServer,
isolated_azure_auth: None,
backend: str,
@ -363,8 +372,8 @@ async def test_azure_missing_endpoint_prevents_token_callback(
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["python", "rust"])
async def test_azure_empty_callback_token_does_not_restore_static_token(
@pytest.mark.parametrize("backend", ["python", "rust"], ids=["python-backend", "rust-backend"])
async def test_public_azure_ocr_does_not_fall_back_to_static_token_after_empty_provider_result(
ocr_server: RecordingServer,
isolated_azure_auth: None,
backend: str,
@ -387,8 +396,8 @@ async def test_azure_empty_callback_token_does_not_restore_static_token(
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["python", "rust"])
async def test_azure_falsey_callable_leaves_static_token_unchanged(
@pytest.mark.parametrize("backend", ["python", "rust"], ids=["python-backend", "rust-backend"])
async def test_public_azure_ocr_ignores_falsey_token_provider_and_uses_static_token(
ocr_server: RecordingServer,
isolated_azure_auth: None,
backend: str,
@ -417,8 +426,8 @@ async def test_azure_falsey_callable_leaves_static_token_unchanged(
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["python", "rust"])
async def test_azure_token_callback_does_not_await_coroutine_result(
@pytest.mark.parametrize("backend", ["python", "rust"], ids=["python-backend", "rust-backend"])
async def test_public_azure_ocr_rejects_coroutine_returned_by_sync_token_provider(
ocr_server: RecordingServer,
isolated_azure_auth: None,
backend: str,

View file

@ -10,7 +10,6 @@ from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER, Log
async def drain_logging(worker: LoggingWorker = GLOBAL_LOGGING_WORKER) -> None:
# The wrapper's scheduled helper enqueues without awaiting; let it run before joining the queue.
await asyncio.sleep(0)
worker.start()
await asyncio.wait_for(worker.flush(), timeout=10)
@ -20,10 +19,8 @@ async def drain_logging(worker: LoggingWorker = GLOBAL_LOGGING_WORKER) -> None:
class HookEvent:
name: str
call_type: str | None
stream: bool | None
thread: threading.Thread
loop: asyncio.AbstractEventLoop | None
has_running_loop: bool
kwargs: object
response: object
@ -53,17 +50,13 @@ class RecordingLogger(CustomLogger):
snapshot["exception"] = details["exception"]
try:
loop: Final = asyncio.get_running_loop()
has_running_loop: Final = True
except RuntimeError:
loop = None
has_running_loop = False
event: Final = HookEvent(
name=name,
call_type=details.get("call_type"),
stream=details.get("stream"),
thread=threading.current_thread(),
loop=loop,
has_running_loop=has_running_loop,
kwargs=snapshot,
response=response,
)
@ -88,7 +81,7 @@ class RecordingLogger(CustomLogger):
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=timeout)
return tuple(event for event in self.events if event.name == name)
def log_pre_api_call(self, model, messages, kwargs):
def log_pre_api_call(self, model, _messages, kwargs):
self._record("log_pre_api_call", kwargs)
def log_success_event(self, kwargs, response_obj, start_time, end_time):
@ -97,12 +90,6 @@ class RecordingLogger(CustomLogger):
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
self._record("async_log_success_event", kwargs, response_obj)
def log_stream_event(self, kwargs, response_obj, start_time, end_time):
self._record("log_stream_event", kwargs, response_obj)
async def async_log_stream_event(self, kwargs, response_obj, start_time, end_time):
self._record("async_log_stream_event", kwargs, response_obj)
def log_failure_event(self, kwargs, response_obj, start_time, end_time):
self._record("log_failure_event", kwargs, response_obj)
@ -112,67 +99,3 @@ class RecordingLogger(CustomLogger):
def logging_hook(self, kwargs, result, call_type):
self._record("logging_hook", kwargs, result)
return kwargs, result
async def async_logging_hook(self, kwargs, result, call_type):
self._record("async_logging_hook", kwargs, result)
return kwargs, result
async def async_pre_call_deployment_hook(self, kwargs, call_type):
self._record("async_pre_call_deployment_hook", kwargs)
async def async_post_call_success_deployment_hook(self, request_data, response, call_type):
self._record("async_post_call_success_deployment_hook", request_data, response)
return response
async def async_post_call_failure_deployment_hook(self, request_data, exception, call_type, fallback_depth=None):
self._record("async_post_call_failure_deployment_hook", request_data, exception)
@dataclass(frozen=True, slots=True)
class LiveReferenceEvent:
kwargs: object
response: object
class LiveReferenceLogger(CustomLogger):
def __init__(self) -> None:
super().__init__()
self._events: list[LiveReferenceEvent] = []
self._condition = threading.Condition()
@property
def events(self) -> tuple[LiveReferenceEvent, ...]:
with self._condition:
return tuple(self._events)
def _record(self, kwargs: object, response: object) -> None:
with self._condition:
self._events.append(LiveReferenceEvent(kwargs=kwargs, response=response))
self._condition.notify_all()
def wait_for(self, count: int = 1, timeout: float = 10) -> tuple[LiveReferenceEvent, ...]:
deadline: Final = time.monotonic() + timeout
with self._condition:
while len(self._events) < count:
remaining: Final = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(f"Timed out waiting for {count} live-reference events")
self._condition.wait(remaining)
return tuple(self._events)
async def wait_for_async(self, count: int = 1, timeout: float = 10) -> tuple[LiveReferenceEvent, ...]:
return await asyncio.wait_for(asyncio.to_thread(self.wait_for, count, timeout), timeout=timeout + 1)
def release(self) -> None:
with self._condition:
self._events.clear()
def log_success_event(self, kwargs, response_obj, start_time, end_time):
self._record(kwargs, response_obj)
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
self._record(kwargs, response_obj)
class SecondaryLiveReferenceLogger(LiveReferenceLogger):
pass

View file

@ -9,8 +9,6 @@ from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Final
import pytest
@dataclass
class RecordedRequest:
@ -27,11 +25,6 @@ class ResponseSpec:
status: int = 200
headers: dict[str, str] = field(default_factory=dict)
delay: float = 0
events: tuple[tuple[str, object], ...] = ()
chunks: tuple[bytes, ...] = ()
accepted: threading.Event | None = None
release_before_response: threading.Event | None = None
release: threading.Event | None = None
@dataclass
@ -76,44 +69,20 @@ def recording_service() -> Iterator[RecordingServer]:
)
)
response: Final = responses.pop(0) if responses else copy.deepcopy(recording_server.default_response)
if response.accepted is not None:
response.accepted.set()
if response.release_before_response is not None:
response.release_before_response.wait(timeout=10)
if response.delay:
time.sleep(response.delay)
payload: Final = (
b"".join(response.chunks)
if response.chunks
else (
b"".join(
f"event: {event}\ndata: {json.dumps(data)}\n\n".encode() for event, data in response.events
)
if response.events
else json.dumps(response.body).encode()
)
)
payload: Final = json.dumps(response.body).encode()
self.send_response(response.status)
self.send_header(
"Content-Type", "text/event-stream" if response.events or response.chunks else "application/json"
)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
for name, value in response.headers.items():
self.send_header(name, value)
self.end_headers()
try:
if response.chunks:
for index, chunk in enumerate(response.chunks):
if index == 1 and response.release is not None:
response.release.wait(timeout=10)
self.wfile.write(chunk)
self.wfile.flush()
else:
self.wfile.write(payload)
self.wfile.write(payload)
except (BrokenPipeError, ConnectionResetError):
pass
do_GET = _handle
do_POST = _handle
def log_message(self, format: str, *args: object) -> None:
@ -137,9 +106,3 @@ def recording_service() -> Iterator[RecordingServer]:
if recording_server.expected_requests is not None:
assert len(recording_server.requests) == recording_server.expected_requests
assert recording_server.responses == []
@pytest.fixture
def recording_server() -> Iterator[RecordingServer]:
with recording_service() as server:
yield server

View file

@ -13,36 +13,6 @@ OCR_RESPONSE: Final = {
"usage_info": {"pages_processed": 1, "doc_size_bytes": 3},
}
MESSAGES_MODEL: Final = "anthropic/claude-sonnet-4-5-20250929"
MESSAGES: Final = [{"role": "user", "content": "Hello"}]
MESSAGES_RESPONSE: Final = {
"id": "msg_native",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5-20250929",
"content": [{"type": "text", "text": "Hello from native Messages"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 5, "output_tokens": 4},
}
CHAT_MODEL: Final = "anthropic/claude-opus-5"
CHAT_MESSAGES: Final = [
{"role": "system", "content": "Keep the answer short"},
{"role": "user", "content": "Earlier safe question"},
{"role": "assistant", "content": "Earlier safe answer"},
{"role": "user", "content": "Employee SSN: 078-05-1120"},
]
CHAT_RESPONSE: Final = {
"id": "msg_chat_native",
"type": "message",
"role": "assistant",
"model": "claude-opus-5",
"content": [{"type": "text", "text": "Handled safely"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 8, "output_tokens": 2},
}
def ocr_arguments(server: RecordingServer, **kwargs: object) -> dict[str, object]:
return {
@ -87,27 +57,3 @@ def request_headers(kwargs: dict[str, object]) -> dict[str, object]:
headers = additional_args["headers"]
assert isinstance(headers, dict)
return headers
MESSAGES_EVENTS: Final = (
("message_start", {"type": "message_start", "message": {**MESSAGES_RESPONSE, "content": [], "stop_reason": None}}),
("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
(
"content_block_delta",
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "Hello from native Messages"},
},
),
("content_block_stop", {"type": "content_block_stop", "index": 0}),
(
"message_delta",
{
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {"input_tokens": 5, "output_tokens": 4},
},
),
("message_stop", {"type": "message_stop"}),
)

View file

@ -1,135 +0,0 @@
import asyncio
from collections.abc import AsyncIterator, Mapping
from dataclasses import dataclass
from typing import Final, Literal
import litellm
from tests.test_litellm_rust.support.callback_recorder import HookEvent, RecordingLogger
from tests.test_litellm_rust.support.requests import (
MESSAGES,
MESSAGES_EVENTS,
MESSAGES_MODEL,
MESSAGES_RESPONSE,
OCR_RESPONSE,
call_aocr,
call_ocr,
)
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec
RouteName = Literal["ocr-sync", "ocr-async", "messages", "messages-stream"]
@dataclass(frozen=True, slots=True)
class Route:
name: RouteName
call_type: str
provider_model: str
provider_response: Mapping[str, object]
expected_text: str
expected_cost: float
fires_async_hooks: bool
async def invoke(self, server: RecordingServer, **kwargs: object) -> object:
match self.name:
case "ocr-sync":
return await asyncio.to_thread(call_ocr, server, **kwargs)
case "ocr-async":
return await call_aocr(server, **kwargs)
case "messages":
return await _call_messages(server, **kwargs)
case "messages-stream":
stream: Final = await self.open_stream(server, **kwargs)
return [chunk async for chunk in stream]
async def open_stream(self, server: RecordingServer, **kwargs: object) -> AsyncIterator[object]:
if self.name != "messages-stream":
raise ValueError(f"{self.name} is not a streaming route")
stream: Final = await _call_messages(server, stream=True, **kwargs)
if not isinstance(stream, AsyncIterator):
raise TypeError(f"Expected async stream, got {type(stream).__name__}")
return stream
def response_text(self, response: object) -> str:
match self.name:
case "ocr-sync" | "ocr-async":
pages: Final = getattr(response, "pages", None)
if not isinstance(pages, list) or not pages:
raise TypeError(f"Expected OCR response pages, got {type(response).__name__}")
markdown: Final = getattr(pages[0], "markdown", None)
if not isinstance(markdown, str):
raise TypeError(f"Expected OCR markdown, got {type(markdown).__name__}")
return markdown
case "messages":
if not isinstance(response, Mapping):
raise TypeError(f"Expected Messages mapping, got {type(response).__name__}")
content: Final = response.get("content")
if not isinstance(content, list) or not content or not isinstance(content[0], Mapping):
raise TypeError("Expected Messages text content")
text: Final = content[0].get("text")
if not isinstance(text, str):
raise TypeError(f"Expected Messages text, got {type(text).__name__}")
return text
case "messages-stream":
raise ValueError("Streaming response text is assembled by the stream consumer")
async def _call_messages(server: RecordingServer, **kwargs: object) -> object:
return await litellm.anthropic.messages.acreate(
model=MESSAGES_MODEL,
messages=MESSAGES,
max_tokens=64,
api_key="test-key",
api_base=server.base_url,
**kwargs,
)
MESSAGES_COST: Final = 5 * 3e-06 + 4 * 1.5e-05
OCR_COST: Final = 0.004
OCR_SYNC: Final = Route("ocr-sync", "ocr", "mistral-ocr-latest", OCR_RESPONSE, "native OCR response", OCR_COST, False)
OCR_ASYNC: Final = Route("ocr-async", "aocr", "mistral-ocr-latest", OCR_RESPONSE, "native OCR response", OCR_COST, True)
MESSAGES_ROUTE: Final = Route(
"messages",
"anthropic_messages",
"claude-sonnet-4-5-20250929",
MESSAGES_RESPONSE,
"Hello from native Messages",
MESSAGES_COST,
True,
)
MESSAGES_STREAM: Final = Route(
"messages-stream",
"anthropic_messages",
"claude-sonnet-4-5-20250929",
MESSAGES_RESPONSE,
"Hello from native Messages",
MESSAGES_COST,
True,
)
ALL_ROUTES: Final = (OCR_SYNC, OCR_ASYNC, MESSAGES_ROUTE, MESSAGES_STREAM)
ASYNC_ROUTES: Final = tuple(route for route in ALL_ROUTES if route.fires_async_hooks)
NON_STREAM_ASYNC_ROUTES: Final = (OCR_ASYNC, MESSAGES_ROUTE)
def route_id(route: Route) -> str:
return route.name
def provider_response(route: Route) -> ResponseSpec:
return ResponseSpec(
body=route.provider_response,
events=MESSAGES_EVENTS if route.name == "messages-stream" else (),
)
async def wait_for_callback(
route: Route,
recorder: RecordingLogger,
outcome: Literal["success", "failure"] = "success",
count: int = 1,
) -> tuple[HookEvent, ...]:
event: Final = f"{'async_' if route.fires_async_hooks else ''}log_{outcome}_event"
if route.fires_async_hooks:
return await recorder.wait_for_async(event, count=count)
return recorder.wait_for(event, count=count)

View file

@ -1,6 +1,7 @@
import json
import threading
from collections.abc import Generator
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Final
@ -11,17 +12,23 @@ import litellm
pytestmark = pytest.mark.requires_rust_extension
@dataclass(frozen=True, slots=True)
class RecordedOCRRequest:
headers: dict[str, str]
body: object
@pytest.fixture
def native_only_ocr_server() -> Generator[tuple[ThreadingHTTPServer, list[dict[str, object]]]]:
requests: Final[list[dict[str, object]]] = []
def native_only_ocr_server() -> Generator[tuple[ThreadingHTTPServer, list[RecordedOCRRequest]]]:
requests: Final[list[RecordedOCRRequest]] = []
class Handler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
requests.append(
{
"headers": {name.lower(): value for name, value in self.headers.items()},
"body": json.loads(self.rfile.read(int(self.headers["Content-Length"]))),
}
RecordedOCRRequest(
headers={name.lower(): value for name, value in self.headers.items()},
body=json.loads(self.rfile.read(int(self.headers["Content-Length"]))),
)
)
if self.headers.get("User-Agent", "").startswith("python-httpx"):
self.send_response(418)
@ -55,7 +62,7 @@ def native_only_ocr_server() -> Generator[tuple[ThreadingHTTPServer, list[dict[s
def test_public_ocr_executes_the_compiled_extension_without_python_fallback(
native_only_ocr_server: tuple[ThreadingHTTPServer, list[dict[str, object]]],
native_only_ocr_server: tuple[ThreadingHTTPServer, list[RecordedOCRRequest]],
) -> None:
server, requests = native_only_ocr_server
address: Final = server.server_address
@ -71,10 +78,8 @@ def test_public_ocr_executes_the_compiled_extension_without_python_fallback(
assert response.pages[0].markdown == "native OCR response"
assert len(requests) == 1
headers: Final = requests[0]["headers"]
assert isinstance(headers, dict)
assert not str(headers.get("user-agent", "")).startswith("python-httpx")
assert requests[0]["body"] == {
assert not requests[0].headers.get("user-agent", "").startswith("python-httpx")
assert requests[0].body == {
"model": "mistral-ocr-latest",
"document": {"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"},
}

View file

@ -1,20 +0,0 @@
from typing import Final
import pytest
from litellm.rust_bridge.loader import get_native_bridge
pytestmark = pytest.mark.requires_rust_extension
def test_native_route_catalogue_matches_extension_exports() -> None:
from litellm.rust_bridge.provenance import (
RUST_NON_RESPONSE_ENTRYPOINTS,
RUST_RESPONSE_ENTRYPOINTS,
)
native: Final = get_native_bridge()
assert native is not None
route_exports: Final = frozenset(name for name in dir(native) if not name.startswith("_") and name[0].islower())
assert route_exports == RUST_RESPONSE_ENTRYPOINTS | RUST_NON_RESPONSE_ENTRYPOINTS

View file

@ -1,115 +0,0 @@
import asyncio
from pathlib import Path
from types import ModuleType
from typing import Final
import pytest
import litellm
from litellm.rust_bridge import configuration
from tests.test_litellm_rust import conftest
from tests.test_litellm_rust.conftest import _isolated_list # pyright: ignore[reportPrivateUsage] # exercise fixture restoration without mutating SDK state
pytestmark = pytest.mark.requires_rust_extension
@pytest.mark.asyncio
@pytest.mark.parametrize("override", (None, False, True))
@pytest.mark.parametrize("exception_type", (RuntimeError, asyncio.CancelledError))
async def test_backend_restores_override_after_nested_failure(
monkeypatch: pytest.MonkeyPatch,
override: bool | None,
exception_type: type[RuntimeError] | type[asyncio.CancelledError],
) -> None:
monkeypatch.setenv("LITELLM_RUST", "1")
configuration.reset_rust_configuration()
if override is not None:
litellm.rust(override)
async def fail_in_nested_backend() -> None:
async with conftest.isolated_backend("python"):
assert configuration.rust_enabled() is False
with pytest.raises(exception_type, match="scope failed"):
await fail_in_rust_backend()
assert configuration.rust_enabled() is False
raise exception_type("scope failed")
async def fail_in_rust_backend() -> None:
async with conftest.isolated_backend("rust"):
assert configuration.rust_enabled() is True
raise exception_type("scope failed")
with pytest.raises(exception_type, match="scope failed"):
await fail_in_nested_backend()
assert configuration.rust_enabled() is (True if override is None else override)
monkeypatch.setenv("LITELLM_RUST", "0")
assert configuration.rust_enabled() is (False if override is None else override)
@pytest.mark.asyncio
async def test_backend_rejects_overlapping_tasks_without_changing_state() -> None:
async def competing_scope() -> None:
async with conftest.isolated_backend("rust"):
pytest.fail("overlapping backend scope was accepted")
async with conftest.isolated_backend("python"):
callback: Final = object()
litellm.callbacks.append(callback)
with pytest.raises(RuntimeError, match="isolated_backend scopes cannot overlap across tasks"):
await asyncio.create_task(competing_scope())
assert configuration.rust_enabled() is False
assert litellm.callbacks == [callback]
async with conftest.isolated_backend("rust"):
assert configuration.rust_enabled() is True
assert litellm.callbacks == []
assert configuration.rust_enabled() is False
assert litellm.callbacks == [callback]
with pytest.raises(RuntimeError, match="isolated_backend scopes cannot overlap across tasks"):
await asyncio.create_task(competing_scope())
async def subsequent_scope() -> None:
async with conftest.isolated_backend("rust"):
assert configuration.rust_enabled() is True
await asyncio.create_task(subsequent_scope())
def test_callback_list_restores_identity_after_rebinding_and_failure() -> None:
container: Final = ModuleType("callback_registry")
callback: Final = object()
original: Final = [callback]
setattr(container, "callbacks", original)
def failing_test() -> None:
with _isolated_list(container, "callbacks"):
assert getattr(container, "callbacks") is original
assert original == []
original.append(object())
setattr(container, "callbacks", [object()])
raise RuntimeError("test failed")
with pytest.raises(RuntimeError, match="test failed"):
failing_test()
assert getattr(container, "callbacks") is original
assert original == [callback]
def test_rust_extension_gate_skips_only_rust_suite_items(monkeypatch: pytest.MonkeyPatch) -> None:
class Item:
def __init__(self, path: Path) -> None:
self.path = path
self.markers: list[object] = []
def add_marker(self, marker: object) -> None:
self.markers.append(marker)
monkeypatch.setattr(conftest, "_parse_env_bool", lambda value: False)
rust_item: Final = Item(Path("tests/test_litellm_rust/ocr/test_dispatch.py"))
other_item: Final = Item(Path("tests/test_litellm/test_completion.py"))
conftest.pytest_collection_modifyitems([rust_item, other_item]) # pyright: ignore[reportArgumentType] # lightweight items isolate hook selection
assert rust_item.markers
assert not other_item.markers