mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-28 01:32:17 +00:00
* refactor(rust): align the cache crates with Python and activate every backend The cache port had drifted: lifecycle and Redis-only operations sat on `BaseCache`, counters were pinned to `f64`, each semantic backend defined its own embedder and prompt handling, and only the in-memory backend could be selected natively. - Split `disconnect` and `test_connection` out of `BaseCache` into optional capabilities, implemented only where the Python class defines them, and give every Redis-only operation its own capability trait. - Decouple counters from the stored value type, so one backend can serve both responses and counters as Python's `RedisCache` does. - Share one `Embedder` and prompt contract in `litellm_cache::semantic`, and make the Redis and Valkey semantic backends generic over their codec. - Port the Python operations that were missing: `async_refresh_ttl`, `async_rpush_and_trim`, `async_set_cache_pipeline_with_ttls`, the DualCache pipeline, sadd, bulk delete and TTL reads, and the semantic-similarity write-back. - Take the HTTP client from the host pool in the GCS, S3 and Azure backends. - Activate all nine backends through the Rust catalog, whose rules all stay `PYTHON_ONLY`, and route the `Cache` facade's storage calls to the native runtime when one is selected. - Give every crate the same layout, move all tests to `tests/` on rstest, and add the shared `litellm-cache-testing` contract suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: freeze native cache request kwargs and batch entries for type discipline Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: declare semantic lookup methods in the native stub Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(rust): align the cache crates with Python and activate every backend The cache port had drifted: lifecycle and Redis-only operations sat on `BaseCache`, counters were pinned to `f64`, each semantic backend defined its own embedder and prompt handling, and only the in-memory backend could be selected natively. - Split `disconnect` and `test_connection` out of `BaseCache` into optional capabilities, implemented only where the Python class defines them, and give every Redis-only operation its own capability trait. - Decouple counters from the stored value type, so one backend can serve both responses and counters as Python's `RedisCache` does. - Share one `Embedder` and prompt contract in `litellm_cache::semantic`, and make the Redis and Valkey semantic backends generic over their codec. - Port the Python operations that were missing: `async_refresh_ttl`, `async_rpush_and_trim`, `async_set_cache_pipeline_with_ttls`, the DualCache pipeline, sadd, bulk delete and TTL reads, and the semantic-similarity write-back. - Take the HTTP client from the host pool in the GCS, S3 and Azure backends. - Activate all nine backends through the Rust catalog, whose rules all stay `PYTHON_ONLY`, and route the `Cache` facade's storage calls to the native runtime when one is selected. - Give every crate the same layout, move all tests to `tests/` on rstest, and add the shared `litellm-cache-testing` contract suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: freeze native cache request kwargs and batch entries for type discipline Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: declare semantic lookup methods in the native stub Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(rust): opt the native Messages and tokenizer suites into Rust explicitly #42517 made the Messages, token counter and tokenizer routes Python-only, so tests/test_litellm_rust silently exercised the Python path or failed outright. Each suite now prepends a RUST_OPT_IN rule for its route, keeping native coverage without changing the shipped default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(rust): pop one at a time in the Redis 6 lpop pipeline and drop explanatory comments Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Yujong Lee <yujong@berri.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
185 lines
6.9 KiB
Python
185 lines
6.9 KiB
Python
from collections.abc import AsyncIterator, Iterator
|
|
from typing import Final
|
|
|
|
import pytest
|
|
|
|
import litellm
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm.rust_bridge import catalog
|
|
from litellm.rust_bridge.catalog import Route, RouteRule
|
|
from litellm.rust_bridge.configuration import Rollout
|
|
from tests.test_litellm_rust.support.callback_recorder import RecordingLogger, drain_logging
|
|
from tests.test_litellm_rust.support.isolation import rebound
|
|
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec
|
|
from tests.test_litellm_rust.support.requests import (
|
|
MESSAGES,
|
|
MESSAGES_EVENTS,
|
|
MESSAGES_MODEL,
|
|
MESSAGES_RESPONSE,
|
|
request_body,
|
|
)
|
|
|
|
pytestmark = pytest.mark.requires_rust_extension
|
|
|
|
STREAM: Final = ResponseSpec(body=None, events=MESSAGES_EVENTS)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def opt_messages_into_rust() -> Iterator[None]:
|
|
with rebound(catalog, "RULES", (RouteRule(Route.MESSAGES, Rollout.RUST_OPT_IN), *catalog.RULES)):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def messages_server(recording_server: RecordingServer) -> RecordingServer:
|
|
recording_server.default_response = ResponseSpec(body=MESSAGES_RESPONSE)
|
|
return recording_server
|
|
|
|
|
|
def arguments(server: RecordingServer, **kwargs: object) -> dict[str, object]:
|
|
return {
|
|
"model": MESSAGES_MODEL,
|
|
"messages": [dict(message) for message in MESSAGES],
|
|
"max_tokens": 64,
|
|
"api_key": "test-key",
|
|
"api_base": server.base_url,
|
|
**kwargs,
|
|
}
|
|
|
|
|
|
def assert_served_natively(server: RecordingServer) -> None:
|
|
assert len(server.requests) == 1
|
|
assert not server.requests[0].headers.get("user-agent", "").startswith("python-httpx")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_native_messages_callbacks_see_the_provider_request_and_the_public_response(
|
|
messages_server: RecordingServer,
|
|
) -> None:
|
|
recorder: Final = RecordingLogger()
|
|
|
|
response: Final = await litellm.anthropic.messages.acreate(
|
|
**arguments(messages_server, callbacks=[recorder], litellm_call_id="messages-success")
|
|
)
|
|
|
|
assert_served_natively(messages_server)
|
|
assert response["content"] == MESSAGES_RESPONSE["content"]
|
|
sent: Final = messages_server.requests[0]
|
|
assert sent.path == "/v1/messages"
|
|
assert sent.body == {"model": "claude-sonnet-5", "messages": list(MESSAGES), "max_tokens": 64, "stream": False}
|
|
pre_call: Final = recorder.wait_for("log_pre_api_call")
|
|
assert request_body(pre_call[0].kwargs) == sent.body
|
|
success: Final = await recorder.wait_for_async("async_log_success_event")
|
|
assert len(success) == 1
|
|
assert success[0].call_type == "anthropic_messages"
|
|
assert success[0].kwargs["litellm_call_id"] == "messages-success"
|
|
assert success[0].response.choices[0].message.content == "Hello from native Messages"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_native_messages_pre_call_body_edit_reaches_the_provider(messages_server: RecordingServer) -> None:
|
|
class Edit(CustomLogger):
|
|
def log_pre_api_call(self, model, messages, kwargs):
|
|
request_body(kwargs)["temperature"] = 0.25
|
|
|
|
await litellm.anthropic.messages.acreate(**arguments(messages_server, callbacks=[Edit()]))
|
|
|
|
assert messages_server.requests[0].body["temperature"] == 0.25
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_native_messages_provider_error_reaches_caller_and_failure_callbacks_as_one_public_error(
|
|
messages_server: RecordingServer,
|
|
) -> None:
|
|
messages_server.enqueue(
|
|
ResponseSpec(body={"type": "error", "error": {"type": "invalid_request_error", "message": "bad"}}, status=400)
|
|
)
|
|
observed: Final = []
|
|
|
|
class Observe(CustomLogger):
|
|
def log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
|
observed.append(("sync", kwargs["exception"]))
|
|
|
|
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
|
observed.append(("async", kwargs["exception"]))
|
|
|
|
with pytest.raises(litellm.BadRequestError) as raised:
|
|
await litellm.anthropic.messages.acreate(**arguments(messages_server, callbacks=[Observe()]))
|
|
|
|
assert_served_natively(messages_server)
|
|
assert [phase for phase, _ in observed] == ["sync", "async"]
|
|
assert all(error is raised.value for _, error in observed)
|
|
|
|
|
|
def sse_payload() -> bytes:
|
|
return b"".join(STREAM.payloads())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_native_messages_stream_relays_provider_events_and_logs_success_once_after_the_last_chunk(
|
|
messages_server: RecordingServer,
|
|
) -> None:
|
|
messages_server.enqueue(STREAM)
|
|
recorder: Final = RecordingLogger()
|
|
|
|
stream: Final = await litellm.anthropic.messages.acreate(
|
|
**arguments(messages_server, stream=True, callbacks=[recorder])
|
|
)
|
|
assert isinstance(stream, AsyncIterator)
|
|
first: Final = await anext(stream)
|
|
await drain_logging()
|
|
assert "async_log_success_event" not in recorder.names
|
|
rest: Final = [chunk async for chunk in stream]
|
|
|
|
assert first + b"".join(rest) == sse_payload()
|
|
assert_served_natively(messages_server)
|
|
assert messages_server.requests[0].body["stream"] is True
|
|
success: Final = await recorder.wait_for_async("async_log_success_event")
|
|
assert len(success) == 1
|
|
assert success[0].kwargs["stream"] is True
|
|
assert success[0].kwargs["completion_start_time"] is not None
|
|
assert "log_failure_event" not in recorder.names
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_native_messages_stream_closed_early_logs_success_once_for_what_was_delivered(
|
|
messages_server: RecordingServer,
|
|
) -> None:
|
|
messages_server.enqueue(STREAM)
|
|
recorder: Final = RecordingLogger()
|
|
|
|
stream: Final = await litellm.anthropic.messages.acreate(
|
|
**arguments(messages_server, stream=True, callbacks=[recorder])
|
|
)
|
|
assert isinstance(stream, AsyncIterator)
|
|
await anext(stream)
|
|
await stream.aclose()
|
|
|
|
success: Final = await recorder.wait_for_async("async_log_success_event")
|
|
assert len(success) == 1
|
|
with pytest.raises(StopAsyncIteration):
|
|
await anext(stream)
|
|
|
|
|
|
def test_native_sync_messages_stream_relays_provider_events_and_logs_success_once(
|
|
messages_server: RecordingServer,
|
|
) -> None:
|
|
messages_server.enqueue(STREAM)
|
|
recorder: Final = RecordingLogger()
|
|
|
|
stream: Final = litellm.anthropic.messages.create(**arguments(messages_server, stream=True, callbacks=[recorder]))
|
|
assert isinstance(stream, Iterator)
|
|
|
|
assert b"".join(stream) == sse_payload()
|
|
assert_served_natively(messages_server)
|
|
assert len(recorder.wait_for("async_log_success_event")) == 1
|
|
|
|
|
|
def test_native_sync_messages_returns_the_provider_message(messages_server: RecordingServer) -> None:
|
|
recorder: Final = RecordingLogger()
|
|
|
|
response: Final = litellm.anthropic.messages.create(**arguments(messages_server, callbacks=[recorder]))
|
|
|
|
assert_served_natively(messages_server)
|
|
assert response["content"] == MESSAGES_RESPONSE["content"]
|
|
assert len(recorder.wait_for("log_success_event")) == 1
|