litellm/tests/test_litellm_rust/test_dispatch.py
Nate Armstrong 59538ff1d9
feat(messages): serve anthropic messages on rust only (#51119)
* feat(messages): apply modify_params rewrites on rust

* feat(messages): mint anthropic workload identity tokens on rust

* feat(messages): apply prompt caching injection on rust

* feat(messages): serve the easy python-only detectors on rust

* feat(messages): retry rejected thinking signatures on rust

* feat(messages): serve response cache reads on rust

* feat(messages): retry rejected strict tools on rust

* refactor(messages): delete the python path for anthropic messages

Provider anthropic on /v1/messages is now RUST_REQUIRED, so it runs on Rust
without LITELLM_RUST or litellm.rust(True) and never falls back to Python.
A call the rules would send to Python (a MESSAGES_PYTHON_FEATURES feature or a
detector that raises) gets UnsupportedParamsError naming the feature, raised
from one place in litellm/messages/dispatch.py. Other providers keep the
Python fallback. A call whose arguments the handler signature rejects still
gets Python's TypeError.

Deleted Python code that only served anthropic Messages:
- workload identity in AnthropicMessagesConfig.validate/avalidate (subclasses
  were never eligible; the Rust host mints the token itself)
- the anthropic Foundry beta-row and affinity-base branch in
  async_anthropic_messages_handler, and the "forward" mode of the beta filter
  that only that branch used

The proxy MCP tools detector now uses the light tool precheck so a function
tool never imports the gateway handler, since a raising detector now refuses.

* feat(messages): forward message threads on rust

The Rust path for anthropic /v1/messages dropped the thread body field, so
a create or continue turn reached Anthropic without its thread or the
message-threads-2026-08-12 beta. It now matches the Python handler from
#50976:

- the host forwards thread with the other body params
- a non-null thread adds the message-threads beta
- a well-formed continue turn keeps advisor blocks in history and derives
  no compaction, per-turn-control or tool-change beta from its messages
- the recorded httpx_response carries the request that was sent, for JSON
  and streamed calls, so the proxy's thread ownership hook resends the
  create turn's betas on a continue as it does for Python

Upstream error.details already reached the exception message unchanged, so
thread_not_found relays and keeps the deployment out of cooldown on both
paths. Parity cases cover create, continue, streaming, sync, retries,
thread_not_found and the ownership hook round trip.

* feat(messages): serve mock responses on rust

* feat(messages): send caller clients and unsupported tls through python on rust

* feat(messages): run pre-request hooks on rust

* feat(messages): run agentic loop hooks on rust

* feat(messages): run the safeguards classifier on rust

* feat(messages): run registered interceptors on rust

* fix(messages): match python stream cache gate, logging and interceptor order on rust

Store a streamed Messages response in the cache only when Python would: a
message_stop event line, no provider error frame and at least one frame with
output. Leave dict results untouched by mark_rust_response so hidden params
never reach result dicts, logging objects or hook digests. Ask interceptors
whether they handle the call after the cache lookup, not at decode time, so a
cache hit never calls can_handle. Emit the track_llm_api_timing service event
for the provider exchange. Bill and log a stream superseded by a router
fallback as the failure of its error frame, with the partial usage warning.
Hand litellm_logging_obj to deployment pre-call hooks as @client does, so the
autorouter baseline cache sees retried requests.

* fix(messages): forward python-only json values to anthropic as python does

Accept any max_tokens, sampling scalar and messages collection that
Python main accepts, and carry values serde_json cannot hold (ints beyond
64 bits, NaN and infinities, strings with lone surrogates) as validated
raw JSON leaves written back verbatim on the wire, so the upstream body
matches Python's json.dumps

* fix(messages): match python's body reads, error bodies, encoded heads and tls warnings on rust

* fix(messages): log python's plain provider header names on rust streams

* fix(messages): match python's request params, logged params and validation on rust

Read a non-bool stream by Python truthiness and send it as given, log the
caller's stream flag as Python's handler does, raise the raw Python errors
main raises for malformed messages, tools, model_info and hook-set
litellm_params or providers, read the tool custom-field drop from
drops_unsupported_params, log the caller's nested fields that
additional_drop_params strips and the Python values of raw JSON leaves,
keep a missing stop_reason absent, and map provider-config rejections
(missing max_tokens, unknown reasoning effort) as Python's HTTP handler
maps them, with Python's text, effort order, model and provider

* fix(messages): match python's tls curve names, redirects, auth token, header encoding and timeouts on rust

* fix(messages): match python on hooked streams, empty cache writes and mcp cache hits

* fix(messages): join already buffered stream reads and trim the env api key as python does on rust

* fix(messages): deliver stream bytes read before a cut, keep wide reply numbers and follow python's redirect limit on rust

A stream cut by a transport error now hands the caller the bytes already
buffered before raising, as aiohttp does for a caller that reads them before
the event loop sees the cut, so the frames and their usage reach the caller and
the failure log. Provider replies keep integers wider than 64 bits exactly and
read numbers that overflow a float as infinity, through the response, the
response cache and the logged result. The provider client follows up to 20
redirects as httpx does, and running out raises Python's text. A
provider_specific_header value the ascii codec cannot encode fails before the
send like a caller extra header. The deployment success hook gets the stream as
AnthropicMessagesStreamingResponse, as Python's handler hands it.

* feat(messages): serve proxy mcp tools on rust

* test(messages): cover hook, agentic and interceptor edge cases in the parity suite

* fix(messages): keep provider raw-json marked strings, drop non-string tool_result texts and key clamped streams as python does on rust

* fix(messages): match python's api key encoding, malformed api_base and string provider header errors on rust

A header value Python's client cannot encode as ASCII now fails before the send
whichever header carries it, so a caller api_key with a non-ASCII character
raises Python's InternalServerError instead of a raw UnicodeEncodeError after
the reply. A caller api_base without an http(s) scheme, or with whitespace,
control or non-ASCII characters, is sent through Python's client at the URL
Python builds, so its relative-URL and invalid-URL errors match, and a base
urlsplit cannot split raises Python's bare ValueError before the send. A
provider_specific_header of the wrong shape is scoped through Python's
ProviderSpecificHeaderUtils first, so a plain string raises Python's
AttributeError.

* fix(messages): keep integer temperature and top_p digits a float cannot hold and raise python's typeerror for a string max_tokens while capping the thinking budget on rust

temperature and top_p now deserialize through recognized::exact_float, which keeps an integer
where float(n) != n (beyond 2**53) as an unrecognized value with its exact digits, so it is sent
upstream and logged as the integer python forwards, and comparisons still go through scalar.rs

cap_thinking_budget_to_max_tokens compares max_tokens <= 1024 first, so a max_tokens python
cannot order against an int (a string, list or dict) raises TypeError "'<=' not supported between
instances of 'str' and 'int'" in both the reasoning effort translation and the adaptive effort
reshape. rust now returns ErrorDetail::UnorderableTypes there, the bridge raises it as a python
TypeError marked as a late rejection, and map_failure hands it to the caller unmapped

* test(messages): format the non-string tool_result parity helper

* fix(messages): flag and log the call before python's pre-send raises on rust

The bridge now hands settings() and map_failure() whether the call is async.
A provider_specific_header of the wrong shape sets the aanthropic_messages
flag before re-raising, as Python's handler has already flagged the call. A
base urlsplit cannot split also logs the resolved model, provider and optional
params first, as Python raises it only after its logger is updated. The
TypeError raised while capping the thinking budget for a string max_tokens
sets the flag too

* feat(messages): serve the web search short-circuit override on rust through the intercept host call

* fix(messages): give the web search entry kwargs its mutable-ok reason

* test(messages): drop the body hook gap the rust short-circuit closes

* fix(messages): name the partially unknown handler types on the short-circuit host call
2026-11-12 00:07:20 +00:00

331 lines
12 KiB
Python

from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator, Mapping
from dataclasses import dataclass
from typing import Final, NoReturn, TypeAlias
import pytest
from litellm.rust_bridge.bindings import NativeBinding
from litellm.rust_bridge.catalog import Feature, Route, RouteContext, RouteRule, Rules, Target
from litellm.rust_bridge.configuration import Rollout
from litellm.rust_bridge.dispatch import PublicDispatch
from litellm.rust_bridge.runtime import NO_PYTHON, NoPythonImplementationError
@dataclass(frozen=True, slots=True)
class Request:
model: str
def binding() -> NativeBinding[object]:
bound: Final[NativeBinding[object]] = NativeBinding("unused", validate=lambda value: value)
bound.override(None)
return bound
@pytest.mark.parametrize("rules", ((), (RouteRule(Route.MESSAGES, Rollout.RUST_REQUIRED),)))
def test_route_without_rules_forwards_before_request_projection(rules: Rules) -> None:
stream: Final[Iterator[int]] = iter((1, 2))
def reject_request(args: tuple[object, ...], kwargs: Mapping[str, object]) -> Request:
pytest.fail("Python-only routes must not project the request")
dispatch: Final = PublicDispatch(
route=Route.CHAT_COMPLETIONS, request=reject_request, context=lambda _: RouteContext(Route.CHAT_COMPLETIONS)
)
result: Final = dispatch.run(
("model",),
{"stream": True},
python=lambda *args, **kwargs: stream,
binding=binding(),
native=lambda hook, request, args, kwargs: pytest.fail("Python-only routes must not call native"),
rules=rules,
)
assert result is stream
def test_unconditional_python_rule_prevents_later_rust_rule_projection() -> None:
rules: Final[Rules] = (
RouteRule(Route.CHAT_COMPLETIONS, Rollout.PYTHON_ONLY),
RouteRule(Route.CHAT_COMPLETIONS, Rollout.RUST_REQUIRED),
)
def reject_request(args: tuple[object, ...], kwargs: Mapping[str, object]) -> Request:
pytest.fail("First-match Python rule must prevent request projection")
dispatch: Final = PublicDispatch(
route=Route.CHAT_COMPLETIONS,
request=reject_request,
context=lambda _: RouteContext(Route.CHAT_COMPLETIONS),
)
expected: Final = object()
result: Final = dispatch.run(
("model",),
{},
python=lambda *args, **kwargs: expected,
binding=binding(),
native=lambda hook, request, args, kwargs: pytest.fail("First-match Python rule must prevent native"),
rules=rules,
)
assert result is expected
def test_native_stream_result_is_not_consumed_or_wrapped() -> None:
request: Final = Request(model="streaming-model")
stream: Final[Iterator[int]] = iter((1, 2))
rules: Final[Rules] = (
RouteRule(Route.MESSAGES, Rollout.PYTHON_ONLY),
RouteRule(Route.CHAT_COMPLETIONS, Rollout.RUST_REQUIRED),
)
dispatch: Final = PublicDispatch(
route=Route.CHAT_COMPLETIONS,
request=lambda args, kwargs: request,
context=lambda value: RouteContext(Route.CHAT_COMPLETIONS, model=value.model),
)
def native(request: Request, args: tuple[object, ...], kwargs: Mapping[str, object]) -> Iterator[int]:
return stream
native_binding: Final[
NativeBinding[Callable[[Request, tuple[object, ...], Mapping[str, object]], Iterator[int]]]
] = NativeBinding("stream", validate=lambda _: None)
native_binding.override(native)
result: Final = dispatch.run(
("streaming-model",),
{"stream": True},
python=lambda *args, **kwargs: pytest.fail("Required native stream dispatch must not call Python"),
binding=native_binding,
native=lambda hook, value, args, kwargs: hook(value, args, kwargs),
rules=rules,
)
assert result is stream
@pytest.mark.asyncio
@pytest.mark.parametrize("rules", ((), (RouteRule(Route.MESSAGES, Rollout.RUST_REQUIRED),)))
async def test_async_route_without_rules_preserves_async_iterator_result(rules: Rules) -> None:
async def chunks() -> AsyncGenerator[int, None]:
yield 1
stream: Final = chunks()
def reject_request(args: tuple[object, ...], kwargs: Mapping[str, object]) -> Request:
pytest.fail("Python-only routes must not project the request")
async def python(*args: object, **kwargs: object) -> AsyncGenerator[int, None]: # kwargs-ok: pass-through shape
return stream
dispatch: Final = PublicDispatch(
route=Route.RESPONSES, request=reject_request, context=lambda _: RouteContext(Route.RESPONSES)
)
result: Final = await dispatch.arun(
("model",),
{"stream": True},
python=python,
binding=binding(),
native=lambda hook, request, args, kwargs: pytest.fail("Python-only routes must not call native"),
rules=rules,
)
assert result is stream
await stream.aclose()
@pytest.mark.asyncio
async def test_async_dispatch_accepts_websocket_style_none_result() -> None:
request: Final = Request(model="realtime-model")
rules: Final[Rules] = (RouteRule(Route.RESPONSES, Rollout.RUST_REQUIRED),)
dispatch: Final = PublicDispatch(
route=Route.RESPONSES,
request=lambda args, kwargs: request,
context=lambda value: RouteContext(Route.RESPONSES, model=value.model),
)
async def python(*args: object, **kwargs: object) -> None: # kwargs-ok: public pass-through shape
pytest.fail("Required native WebSocket dispatch must not call Python")
async def native(request: Request, args: tuple[object, ...], kwargs: Mapping[str, object]) -> None:
return None
native_binding: Final[
NativeBinding[Callable[[Request, tuple[object, ...], Mapping[str, object]], Awaitable[None]]]
] = NativeBinding("websocket", validate=lambda _: None)
native_binding.override(native)
result: Final = await dispatch.arun(
("realtime-model",),
{},
python=python,
binding=native_binding,
native=lambda hook, value, args, kwargs: hook(value, args, kwargs),
rules=rules,
)
assert result is None
def test_rules_for_other_routes_and_constrained_python_rules_skip_projection() -> None:
rules: Final[Rules] = (
RouteRule(Route.MESSAGES, Rollout.RUST_REQUIRED),
RouteRule(Route.OCR, Rollout.PYTHON_ONLY, providers=frozenset({"mistral"})),
)
def reject_request(args: tuple[object, ...], kwargs: Mapping[str, object]) -> Request:
pytest.fail("Rules that cannot select Rust must not project the request")
dispatch: Final = PublicDispatch(route=Route.OCR, request=reject_request, context=lambda _: RouteContext(Route.OCR))
expected: Final = object()
result: Final = dispatch.run(
("model",),
{},
python=lambda *args, **kwargs: expected,
binding=binding(),
native=lambda hook, request, args, kwargs: pytest.fail("Rules that cannot select Rust must not call native"),
rules=rules,
)
assert result is expected
@pytest.mark.parametrize(
"constrained",
(
RouteRule(Route.MESSAGES, Rollout.PYTHON_ONLY, providers=frozenset({"other"})),
RouteRule(Route.MESSAGES, Rollout.PYTHON_ONLY, models=frozenset({"other"})),
RouteRule(Route.MESSAGES, Rollout.PYTHON_ONLY, targets=frozenset({Target.CHAT_COMPLETIONS})),
RouteRule(Route.MESSAGES, Rollout.PYTHON_ONLY, features=frozenset({Feature.STREAMING})),
),
ids=("providers", "models", "targets", "features"),
)
def test_constrained_python_rule_still_projects_for_a_later_rust_rule(constrained: RouteRule) -> None:
expected: Final = object()
native_binding: Final[NativeBinding[Callable[[Request], object]]] = NativeBinding("x", validate=lambda _: None)
native_binding.override(lambda request: expected)
dispatch: Final = PublicDispatch(
route=Route.MESSAGES,
request=lambda args, kwargs: Request(model="model"),
context=lambda value: RouteContext(Route.MESSAGES, model=value.model, target=Target.MESSAGES),
)
result: Final = dispatch.run(
("model",),
{},
python=lambda *args, **kwargs: pytest.fail("a constrained Python rule must not hide the Rust rule after it"),
binding=native_binding,
native=lambda hook, value, args, kwargs: hook(value),
rules=(constrained, RouteRule(Route.MESSAGES, Rollout.RUST_REQUIRED)),
)
assert result is expected
NativeRoute: TypeAlias = Callable[[Request, tuple[object, ...], Mapping[str, object]], object]
def no_python_binding(result: object) -> NativeBinding[NativeRoute]:
bound: Final[NativeBinding[NativeRoute]] = NativeBinding("no_python", validate=lambda _: None)
bound.override(lambda request, args, kwargs: (result, request, args, dict(kwargs)))
return bound
async def dispatch_without_python(
dispatch: PublicDispatch[Request], bound: NativeBinding[NativeRoute], rules: Rules, *, asynchronous: bool
) -> object:
if not asynchronous:
return dispatch.run(
("model",),
{"page": 1},
python=NO_PYTHON,
binding=bound,
native=lambda hook, value, args, kwargs: hook(value, args, kwargs),
rules=rules,
)
async def native(
hook: NativeRoute, value: Request, args: tuple[object, ...], kwargs: Mapping[str, object]
) -> object:
return hook(value, args, kwargs)
return await dispatch.arun(("model",), {"page": 1}, python=NO_PYTHON, binding=bound, native=native, rules=rules)
def failing_context(request: Request) -> RouteContext:
raise RuntimeError("detector exploded")
def failing_dispatch() -> PublicDispatch[Request]:
return PublicDispatch(
route=Route.CHAT_COMPLETIONS, request=lambda args, kwargs: Request(model="model"), context=failing_context
)
def reject_native(hook: object, request: Request, args: tuple[object, ...], kwargs: Mapping[str, object]) -> NoReturn:
pytest.fail("a failed context build must not reach native")
def test_a_detector_error_keeps_the_call_on_python() -> None:
expected: Final = object()
result: Final = failing_dispatch().run(
("model",),
{},
python=lambda *args, **kwargs: expected,
binding=binding(),
native=reject_native,
rules=(RouteRule(Route.CHAT_COMPLETIONS, Rollout.RUST_REQUIRED),),
)
assert result is expected
async def test_a_detector_error_keeps_the_async_call_on_python() -> None:
expected: Final = object()
async def python(*args: object, **kwargs: object) -> object: # kwargs-ok: stub
return expected
result: Final = await failing_dispatch().arun(
("model",),
{},
python=python,
binding=binding(),
native=reject_native,
rules=(RouteRule(Route.CHAT_COMPLETIONS, Rollout.RUST_REQUIRED),),
)
assert result is expected
def ocr_dispatch(request: Request | None) -> PublicDispatch[Request]:
return PublicDispatch(
route=Route.OCR,
request=lambda args, kwargs: request,
context=lambda value: RouteContext(Route.OCR, model=value.model),
)
@pytest.mark.parametrize("asynchronous", (False, True))
async def test_dispatch_without_python_hands_every_call_to_native(asynchronous: bool) -> None:
request: Final = Request(model="model")
result: Final = object()
dispatched: Final = await dispatch_without_python(
ocr_dispatch(request),
no_python_binding(result),
(RouteRule(Route.OCR, Rollout.RUST_REQUIRED),),
asynchronous=asynchronous,
)
assert dispatched == (result, request, ("model",), {"page": 1})
@pytest.mark.parametrize("asynchronous", (False, True))
@pytest.mark.parametrize(
("request_value", "rules", "reason"),
(
(Request(model="model"), (), "must resolve to RUST_REQUIRED"),
(Request(model="model"), (RouteRule(Route.OCR, Rollout.RUST_OPT_OUT),), "must resolve to RUST_REQUIRED"),
(None, (RouteRule(Route.OCR, Rollout.RUST_REQUIRED),), "must project to a native request"),
),
ids=("no-rule", "opt-out-rule", "unprojectable-call"),
)
async def test_dispatch_without_python_never_falls_back(
asynchronous: bool, request_value: Request | None, rules: Rules, reason: str
) -> None:
bound: Final[NativeBinding[NativeRoute]] = NativeBinding("no_python", validate=lambda _: None)
bound.override(lambda request, args, kwargs: pytest.fail("a misdeclared route must not reach native"))
with pytest.raises(NoPythonImplementationError, match=f"ocr has no Python implementation, so .*{reason}"):
await dispatch_without_python(ocr_dispatch(request_value), bound, rules, asynchronous=asynchronous)