mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
* 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
534 lines
18 KiB
Python
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 == ()
|