litellm/tests/unit/rust_bridge/test_runtime.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

534 lines
18 KiB
Python

from __future__ import annotations
from collections.abc import Callable, Generator
from typing import Final, Protocol
import pytest
from litellm.exceptions import APIError
from litellm.litellm_core_utils.add_retry_fallback_headers import get_hidden_params_dict
from litellm.llms.base_llm.ocr.transformation import OCRResponse
from litellm.rust_bridge import bindings, configuration, runtime
from litellm.rust_bridge._native import RustBridgeDeclined, RustUpstreamError
from litellm.rust_bridge.catalog import Route, RouteContext, RouteRule
from litellm.rust_bridge.configuration import Rollout
from litellm.rust_bridge.execution import AsyncStream, Complete, StreamChunk, SyncStream
@pytest.fixture(autouse=True)
def rust_configuration(monkeypatch: pytest.MonkeyPatch) -> Generator[None]:
monkeypatch.delenv("LITELLM_RUST", raising=False)
configuration.reset_rust_configuration()
yield
configuration.reset_rust_configuration()
class NativeFn(Protocol):
def __call__(self) -> str: ...
CONTEXT: Final = RouteContext(Route.MESSAGES, provider="anthropic", model="model")
RUST: Final = "rust"
PYTHON: Final = "python"
def binding(native: NativeFn | None) -> bindings.NativeBinding[NativeFn]:
bound: Final[bindings.NativeBinding[NativeFn]] = bindings.NativeBinding("_messages", validate=lambda _: None)
bound.override(native)
return bound
def rules(rollout: Rollout) -> tuple[RouteRule, ...]:
return (RouteRule(Route.MESSAGES, rollout, providers=frozenset({"anthropic"})),)
class Recorder:
def __init__(self, native_effect: BaseException | None = None) -> None:
self._native_effect: Final = native_effect
self.calls: tuple[str, ...] = ()
def rust(self) -> str:
self.calls = (*self.calls, RUST)
if self._native_effect is not None:
raise self._native_effect
return RUST
def python(self) -> str:
self.calls = (*self.calls, PYTHON)
return PYTHON
def recorder(native_effect: BaseException | None = None) -> Recorder:
return Recorder(native_effect)
def run(rollout: Rollout, calls: Recorder, *, native_missing: bool = False, context: RouteContext = CONTEXT) -> str:
return runtime.run(
context,
binding=binding(None if native_missing else calls.rust),
native=lambda fn: fn(),
python=calls.python,
rules=rules(rollout),
)
@pytest.mark.parametrize(
("rollout", "switch", "expected"),
(
(Rollout.PYTHON_ONLY, None, (PYTHON,)),
(Rollout.PYTHON_ONLY, True, (PYTHON,)),
(Rollout.RUST_OPT_IN, None, (PYTHON,)),
(Rollout.RUST_OPT_IN, True, (RUST,)),
(Rollout.RUST_OPT_OUT, None, (RUST,)),
(Rollout.RUST_OPT_OUT, False, (PYTHON,)),
(Rollout.RUST_REQUIRED, None, (RUST,)),
(Rollout.RUST_REQUIRED, False, (RUST,)),
),
)
def test_rollout_and_switch_select_native_or_python(
rollout: Rollout, switch: bool | None, expected: tuple[str, ...]
) -> None:
calls: Final = recorder()
if switch is not None:
configuration.rust(switch)
assert run(rollout, calls) == expected[-1]
assert calls.calls == expected
def test_environment_switch_enables_opt_in_route(monkeypatch: pytest.MonkeyPatch) -> None:
calls: Final = recorder()
monkeypatch.setenv("LITELLM_RUST", "1")
assert run(Rollout.RUST_OPT_IN, calls) == "rust"
assert calls.calls == (RUST,)
@pytest.mark.parametrize(
("rollout", "environment", "switch", "expected"),
(
(Rollout.RUST_OPT_IN, "0", True, (PYTHON,)),
(Rollout.RUST_OPT_OUT, "0", True, (PYTHON,)),
(Rollout.RUST_OPT_IN, "1", False, (RUST,)),
(Rollout.RUST_OPT_OUT, "1", False, (RUST,)),
(Rollout.RUST_REQUIRED, "0", False, (RUST,)),
(Rollout.PYTHON_ONLY, "1", True, (PYTHON,)),
),
)
def test_environment_switch_wins_over_process_switch(
monkeypatch: pytest.MonkeyPatch,
rollout: Rollout,
environment: str,
switch: bool,
expected: tuple[str, ...],
) -> None:
calls: Final = recorder()
monkeypatch.setenv("LITELLM_RUST", environment)
configuration.rust(switch)
assert run(rollout, calls) == expected[-1]
assert calls.calls == expected
def test_context_outside_rule_stays_on_python() -> None:
calls: Final = recorder()
configuration.rust(True)
assert run(Rollout.RUST_REQUIRED, calls, context=RouteContext(Route.MESSAGES, provider="openai")) == "python"
assert run(Rollout.RUST_REQUIRED, calls, context=RouteContext(Route.RESPONSES, provider="anthropic")) == "python"
assert calls.calls == (PYTHON, PYTHON)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"context",
(
RouteContext(Route.CHAT_COMPLETIONS, provider="anthropic"),
RouteContext(Route.CHAT_COMPLETIONS, provider="bedrock"),
RouteContext(Route.RESPONSES, provider="openai"),
RouteContext(Route.TRANSCRIPTION, provider="openai"),
),
)
async def test_shipped_python_routes_never_load_native(monkeypatch: pytest.MonkeyPatch, context: RouteContext) -> None:
monkeypatch.setenv("LITELLM_RUST", "1")
configuration.rust(True)
calls: Final = recorder()
def reject_load(value: object) -> NativeFn | None:
pytest.fail("Python-only dispatch must not load a native binding")
bound: Final = bindings.NativeBinding("_messages", validate=reject_load)
async def native(fn: NativeFn) -> str:
return fn()
async def python() -> str:
return calls.python()
assert runtime.run(context, binding=bound, native=lambda fn: fn(), python=calls.python) == PYTHON
assert await runtime.arun(context, binding=bound, native=native, python=python) == PYTHON
assert calls.calls == (PYTHON, PYTHON)
def test_native_decline_falls_back_to_python_once() -> None:
calls: Final = recorder(RustBridgeDeclined("unsupported"))
assert run(Rollout.RUST_OPT_OUT, calls) == "python"
assert calls.calls == (RUST, PYTHON)
def test_unavailable_native_falls_back_to_python() -> None:
calls: Final = recorder()
assert run(Rollout.RUST_OPT_OUT, calls, native_missing=True) == "python"
assert calls.calls == (PYTHON,)
@pytest.mark.asyncio
@pytest.mark.parametrize("missing", (False, True))
async def test_python_fallback_does_not_claim_rust_execution(missing: bool) -> None:
calls: Final = recorder(RustBridgeDeclined("unsupported"))
bound: Final = binding(None if missing else calls.rust)
expected: Final = OCRResponse(pages=[], model="python")
def native(fn: NativeFn) -> OCRResponse:
fn()
pytest.fail("native must decline before constructing a response")
async def anative(fn: NativeFn) -> OCRResponse:
return native(fn)
async def python() -> OCRResponse:
return expected
assert (
runtime.run(CONTEXT, binding=bound, native=native, python=lambda: expected, rules=rules(Rollout.RUST_OPT_OUT))
is expected
)
assert (
await runtime.arun(CONTEXT, binding=bound, native=anative, python=python, rules=rules(Rollout.RUST_OPT_OUT))
is expected
)
assert get_hidden_params_dict(expected) == {}
@pytest.mark.asyncio
@pytest.mark.parametrize("shape", ("model", "dict"))
@pytest.mark.parametrize("asynchronous", (False, True))
@pytest.mark.parametrize("cache_key", (None, "test-cache-key"))
@pytest.mark.parametrize("route", (Route.MESSAGES, Route.OCR))
async def test_native_response_marker_reaches_caller_with_existing_metadata(
shape: str, asynchronous: bool, cache_key: str | None, route: Route
) -> None:
context: Final = RouteContext(route, provider="anthropic", model="model")
required: Final = (RouteRule(route, Rollout.RUST_REQUIRED, providers=frozenset({"anthropic"})),)
hidden: Final = {
"additional_headers": {"x-request-id": "upstream"},
"response_cost": 0.01,
**({"cache_key": cache_key} if cache_key is not None else {}),
}
response: Final[OCRResponse | dict[str, object]] = (
OCRResponse(pages=[], model="native") if shape == "model" else {"content": "native", "_hidden_params": hidden}
)
if isinstance(response, OCRResponse):
response._hidden_params = hidden # pyright: ignore[reportPrivateUsage] # seed SDK metadata to verify it survives native marking
bound: Final[bindings.NativeBinding[Callable[[], object]]] = bindings.NativeBinding("ocr", validate=lambda _: None)
bound.override(lambda: response)
def python() -> object:
pytest.fail("native success must not fall back")
async def anative(fn: Callable[[], object]) -> object:
return fn()
async def apython() -> object:
return python()
result: Final = (
await runtime.arun(context, binding=bound, native=anative, python=apython, rules=required)
if asynchronous
else runtime.run(context, binding=bound, native=lambda fn: fn(), python=python, rules=required)
)
assert result is response
assert get_hidden_params_dict(result) == (
{
"response_cost": 0.01,
"additional_headers": {"x-request-id": "upstream"},
**({"cache_key": cache_key} if cache_key is not None else {}),
}
if shape == "dict"
else {
"response_cost": 0.01,
"additional_headers": {
"x-request-id": "upstream",
"x-litellm-rust": "true",
**({"x-litellm-cache-key": cache_key} if cache_key is not None and route is not Route.MESSAGES else {}),
},
**({"cache_key": cache_key} if cache_key is not None else {}),
}
)
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", (False, True))
async def test_native_dict_response_reaches_caller_without_hidden_params_key(asynchronous: bool) -> None:
response: Final[dict[str, object]] = {"content": "native"}
bound: Final[bindings.NativeBinding[Callable[[], object]]] = bindings.NativeBinding("ocr", validate=lambda _: None)
bound.override(lambda: response)
def python() -> object:
pytest.fail("native success must not fall back")
async def anative(fn: Callable[[], object]) -> object:
return fn()
async def apython() -> object:
return python()
result: Final = (
await runtime.arun(CONTEXT, binding=bound, native=anative, python=apython, rules=rules(Rollout.RUST_REQUIRED))
if asynchronous
else runtime.run(
CONTEXT, binding=bound, native=lambda fn: fn(), python=python, rules=rules(Rollout.RUST_REQUIRED)
)
)
assert result is response
assert result == {"content": "native"}
class ScriptedStreamExecution:
def __init__(self, chunks: tuple[bytes, ...]) -> None:
self._steps: Final = iter((*(StreamChunk(chunk) for chunk in chunks), Complete(None)))
self.closed = False
def send(self, value: object) -> StreamChunk | Complete:
return next(self._steps)
def throw(self, error: BaseException) -> Complete:
return Complete(None)
def close(self) -> None:
self.closed = True
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", (False, True))
async def test_native_stream_marker_reaches_caller_without_wrapping_or_consuming_the_stream(
asynchronous: bool,
) -> None:
chunks: Final = (b"event: message_start\n\n", b"event: message_stop\n\n")
execution: Final = ScriptedStreamExecution(chunks)
stream: Final[AsyncStream | SyncStream] = AsyncStream(execution, {}) if asynchronous else SyncStream(execution, {})
bound: Final[bindings.NativeBinding[Callable[[], object]]] = bindings.NativeBinding(
"messages", validate=lambda _: None
)
bound.override(lambda: stream)
def python() -> object:
pytest.fail("native success must not fall back")
async def anative(fn: Callable[[], object]) -> object:
return fn()
async def apython() -> object:
return python()
result: Final = (
await runtime.arun(CONTEXT, binding=bound, native=anative, python=apython, rules=rules(Rollout.RUST_REQUIRED))
if asynchronous
else runtime.run(
CONTEXT, binding=bound, native=lambda fn: fn(), python=python, rules=rules(Rollout.RUST_REQUIRED)
)
)
assert result is stream
assert get_hidden_params_dict(result) == {"additional_headers": {"x-litellm-rust": "true"}}
assert not execution.closed
delivered: Final = tuple([chunk async for chunk in result]) if isinstance(result, AsyncStream) else tuple(result)
assert delivered == chunks
assert execution.closed
def test_upstream_error_maps_to_api_error_without_fallback() -> None:
calls: Final = recorder(RustUpstreamError(429, "rate limited"))
with pytest.raises(APIError, match="rate limited") as caught:
run(Rollout.RUST_OPT_OUT, calls)
assert caught.value.status_code == 429
assert calls.calls == (RUST,)
def test_other_native_errors_propagate_without_fallback() -> None:
failure: Final = ValueError("admitted")
calls: Final = recorder(failure)
with pytest.raises(ValueError, match="admitted") as caught:
run(Rollout.RUST_OPT_OUT, calls)
assert caught.value is failure
assert calls.calls == (RUST,)
def test_required_route_rejects_unavailable_bridge() -> None:
calls: Final = recorder()
with pytest.raises(RuntimeError, match="Rust messages bridge is unavailable"):
run(Rollout.RUST_REQUIRED, calls, native_missing=True)
assert PYTHON not in calls.calls
def test_required_route_rejects_native_decline() -> None:
calls: Final = recorder(RustBridgeDeclined("unsupported"))
with pytest.raises(RuntimeError, match="declined the request: unsupported"):
run(Rollout.RUST_REQUIRED, calls)
assert PYTHON not in calls.calls
@pytest.mark.asyncio
@pytest.mark.parametrize(
("native_effect", "native_missing", "expected"),
(
(None, False, (RUST,)),
(RustBridgeDeclined("unsupported"), False, (RUST, PYTHON)),
(None, True, (PYTHON,)),
),
)
async def test_arun_mirrors_sync_fallback(
native_effect: BaseException | None, native_missing: bool, expected: tuple[str, ...]
) -> None:
calls: Final = recorder(native_effect)
async def native(fn: NativeFn) -> str:
return fn()
async def python() -> str:
return calls.python()
result: Final = await runtime.arun(
CONTEXT,
binding=binding(None if native_missing else calls.rust),
native=native,
python=python,
rules=rules(Rollout.RUST_OPT_OUT),
)
assert result == expected[-1]
assert calls.calls == expected
@pytest.mark.asyncio
async def test_arun_required_route_rejects_unavailable_bridge() -> None:
async def python() -> str:
pytest.fail("fallback must not run")
with pytest.raises(RuntimeError, match="is unavailable"):
await runtime.arun(
CONTEXT,
binding=binding(None),
native=lambda fn: python(),
python=python,
rules=rules(Rollout.RUST_REQUIRED),
)
@pytest.mark.asyncio
async def test_arun_upstream_error_maps_to_api_error_without_fallback() -> None:
calls: Final = recorder(RustUpstreamError(503, "upstream unavailable"))
async def native(fn: NativeFn) -> str:
return fn()
async def python() -> str:
return calls.python()
with pytest.raises(APIError, match="upstream unavailable") as caught:
await runtime.arun(
CONTEXT,
binding=binding(calls.rust),
native=native,
python=python,
rules=rules(Rollout.RUST_OPT_OUT),
)
assert caught.value.status_code == 503
assert calls.calls == (RUST,)
async def run_without_python(
rollout: Rollout,
calls: Recorder,
*,
asynchronous: bool,
native_missing: bool = False,
context: RouteContext = CONTEXT,
) -> str:
bound: Final = binding(None if native_missing else calls.rust)
if not asynchronous:
return runtime.run(
context, binding=bound, native=lambda fn: fn(), python=runtime.NO_PYTHON, rules=rules(rollout)
)
async def native(fn: NativeFn) -> str:
return fn()
return await runtime.arun(context, binding=bound, native=native, python=runtime.NO_PYTHON, rules=rules(rollout))
@pytest.mark.parametrize("asynchronous", (False, True))
@pytest.mark.parametrize("switch", (None, False, True))
async def test_route_without_python_runs_native_whatever_the_rust_switch(
asynchronous: bool, switch: bool | None
) -> None:
calls: Final = recorder()
if switch is not None:
configuration.rust(switch)
assert await run_without_python(Rollout.RUST_REQUIRED, calls, asynchronous=asynchronous) == RUST
assert calls.calls == (RUST,)
@pytest.mark.parametrize("asynchronous", (False, True))
@pytest.mark.parametrize(
("native_missing", "effect", "message"),
(
(True, None, "Rust messages bridge is unavailable"),
(False, RustBridgeDeclined("unsupported"), "Rust messages bridge declined the request: unsupported"),
),
)
async def test_route_without_python_raises_when_native_cannot_serve_the_call(
asynchronous: bool, native_missing: bool, effect: BaseException | None, message: str
) -> None:
calls: Final = recorder(effect)
with pytest.raises(RuntimeError, match=message) as raised:
await run_without_python(Rollout.RUST_REQUIRED, calls, asynchronous=asynchronous, native_missing=native_missing)
assert not isinstance(raised.value, runtime.NoPythonImplementationError)
@pytest.mark.parametrize("asynchronous", (False, True))
@pytest.mark.parametrize("switch", (None, False, True))
@pytest.mark.parametrize(
("rollout", "context"),
(
(Rollout.PYTHON_ONLY, CONTEXT),
(Rollout.RUST_OPT_IN, CONTEXT),
(Rollout.RUST_OPT_OUT, CONTEXT),
(Rollout.RUST_REQUIRED, RouteContext(Route.MESSAGES, provider="unmatched", model="model")),
),
ids=("python-only", "opt-in", "opt-out", "no-matching-rule"),
)
async def test_route_without_python_rejects_rules_that_could_select_python(
asynchronous: bool, switch: bool | None, rollout: Rollout, context: RouteContext
) -> None:
calls: Final = recorder()
if switch is not None:
configuration.rust(switch)
with pytest.raises(runtime.NoPythonImplementationError, match="messages has no Python implementation"):
await run_without_python(rollout, calls, asynchronous=asynchronous, context=context)
assert calls.calls == ()