From 312c5f8988ea70364a441afd2203eef54c81b375 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Thu, 5 Nov 2026 04:24:30 +0900 Subject: [PATCH] refactor(rust-bridge): deprecate client= on every public LLM call client= now warns on every call that goes through the public dispatch (completion, embedding, responses, anthropic messages, ocr) and on transcription for every provider, whether Python still honors it today or Rust already ignores it. native_call_hook keeps stripping client and shared_session before Rust sees the call, without warning again. Co-Authored-By: Claude Opus 5.5 --- litellm/main.py | 7 ++-- litellm/rust_bridge/dispatch.py | 3 ++ litellm/rust_bridge/ignored_arguments.py | 13 +++++-- tests/test_litellm_rust/test_dispatch.py | 39 +++++++++++++++++++ tests/unit/rust_bridge/test_public_call.py | 39 +++++++------------ .../test_audio_transcription_rust_bridge.py | 27 +++++++++++++ 6 files changed, 97 insertions(+), 31 deletions(-) diff --git a/litellm/main.py b/litellm/main.py index 7792b21438b..47196936bb6 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -158,6 +158,7 @@ from litellm.llms.vertex_ai.common_utils import ( ) from litellm.realtime_api.main import realtime_health_check from litellm.responses.mcp.tool_precheck import may_name_litellm_mcp_gateway +from litellm.rust_bridge.ignored_arguments import warn_deprecated, warn_deprecated_in from litellm.secret_managers.main import get_secret_bool, get_secret_str from litellm.text_completions.streaming_iterator import TextCompletionStreamWrapper from litellm.types.completion import ( @@ -8412,6 +8413,7 @@ async def atranscription( loop: Final = asyncio.get_event_loop() model: Final = args[0] if len(args) > 0 else kwargs["model"] ### PASS ARGS TO Image Generation ### + warn_deprecated_in(_REQUEST_KWARGS_ADAPTER.validate_python(kwargs)) kwargs["atranscription"] = True file: Final = kwargs.get("file", None) try: @@ -8566,6 +8568,8 @@ def transcription( client: openai.AsyncOpenAI | openai.OpenAI | openai.AzureOpenAI | openai.AsyncAzureOpenAI | None = kwargs.pop( "client", None ) + if client is not None and not atranscription: + warn_deprecated("client") if litellm_logging_obj: litellm_logging_obj.model_call_details["client"] = str(client) @@ -8751,10 +8755,7 @@ def transcription( ) elif custom_llm_provider == "bedrock": from litellm.llms.bedrock.audio_transcription import BedrockAudioTranscriptionRustDispatch - from litellm.rust_bridge.ignored_arguments import warn_ignored - if client is not None: - warn_ignored("client") dispatch: Final = BedrockAudioTranscriptionRustDispatch() if atranscription: response = dispatch.async_audio_transcriptions( diff --git a/litellm/rust_bridge/dispatch.py b/litellm/rust_bridge/dispatch.py index ca98fe2b5b2..681bd032dcf 100644 --- a/litellm/rust_bridge/dispatch.py +++ b/litellm/rust_bridge/dispatch.py @@ -8,6 +8,7 @@ from litellm._logging import verbose_logger from litellm.rust_bridge import catalog, runtime from litellm.rust_bridge.bindings import NativeBinding from litellm.rust_bridge.catalog import Route, RouteContext, Rules +from litellm.rust_bridge.ignored_arguments import warn_deprecated_in RequestT: Final = TypeVar("RequestT") NativeT: Final = TypeVar("NativeT") @@ -58,6 +59,7 @@ class PublicDispatch(Generic[RequestT]): native: Callable[[NativeT, RequestT, tuple[object, ...], Mapping[str, object]], ResultT], rules: Rules | None = None, ) -> ResultT: + warn_deprecated_in(kwargs) selected_rules: Final = catalog.RULES if rules is None else rules if isinstance(python, runtime.NoPythonImplementation): native_request: Final = self._native_request(args, kwargs) @@ -94,6 +96,7 @@ class PublicDispatch(Generic[RequestT]): native: Callable[[NativeT, RequestT, tuple[object, ...], Mapping[str, object]], Awaitable[ResultT]], rules: Rules | None = None, ) -> ResultT: + warn_deprecated_in(kwargs) selected_rules: Final = catalog.RULES if rules is None else rules if isinstance(python, runtime.NoPythonImplementation): native_request: Final = self._native_request(args, kwargs) diff --git a/litellm/rust_bridge/ignored_arguments.py b/litellm/rust_bridge/ignored_arguments.py index e9fa6608801..e30548b3371 100644 --- a/litellm/rust_bridge/ignored_arguments.py +++ b/litellm/rust_bridge/ignored_arguments.py @@ -11,7 +11,9 @@ from typing import Final _LITELLM_ROOT: Final = f"{Path(__file__).parents[1]}{os.sep}" _DEPRECATED: Final = MappingProxyType( - {"client": "client= is ignored: litellm sends this request from Rust with its own HTTP settings"} + { + "client": "client= is deprecated: litellm is moving requests to Rust, which ignores it and uses its own HTTP settings" + } ) _INTERNAL: Final = frozenset({"shared_session"}) _DROPPED: Final = _INTERNAL | frozenset(_DEPRECATED) @@ -28,16 +30,19 @@ def _first_stacklevel_outside_litellm() -> int: ) -def warn_ignored(name: str) -> None: +def warn_deprecated(name: str) -> None: if sys.version_info >= (3, 12): warnings.warn(_DEPRECATED[name], DeprecationWarning, skip_file_prefixes=(_LITELLM_ROOT,)) return warnings.warn(_DEPRECATED[name], DeprecationWarning, stacklevel=_first_stacklevel_outside_litellm()) -def without_ignored(kwargs: Mapping[str, object]) -> Mapping[str, object]: +def warn_deprecated_in(kwargs: Mapping[str, object]) -> None: for name in (name for name in _DEPRECATED if kwargs.get(name) is not None): - warn_ignored(name) + warn_deprecated(name) + + +def without_ignored(kwargs: Mapping[str, object]) -> Mapping[str, object]: if _DROPPED.isdisjoint(kwargs): return kwargs return MappingProxyType({key: value for key, value in kwargs.items() if key not in _DROPPED}) diff --git a/tests/test_litellm_rust/test_dispatch.py b/tests/test_litellm_rust/test_dispatch.py index 193866409a2..d57ff76c0f0 100644 --- a/tests/test_litellm_rust/test_dispatch.py +++ b/tests/test_litellm_rust/test_dispatch.py @@ -1,3 +1,4 @@ +import warnings from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator, Mapping from dataclasses import dataclass from typing import Final, NoReturn, TypeAlias @@ -329,3 +330,41 @@ async def test_dispatch_without_python_never_falls_back( 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) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +async def test_python_served_call_keeps_the_client_but_warns_it_is_deprecated(asynchronous: bool) -> None: + client: Final = object() + received: Final[list[object]] = [] + + def python(*args: object, **kwargs: object) -> object: # kwargs-ok: records the forwarded call + received.append(kwargs["client"]) + return "python" + + async def apython(*args: object, **kwargs: object) -> object: # kwargs-ok: records the forwarded call + return python(*args, **kwargs) + + dispatch: Final = PublicDispatch( + route=Route.CHAT_COMPLETIONS, + request=lambda args, kwargs: None, + context=lambda _: RouteContext(Route.CHAT_COMPLETIONS), + ) + rules: Final[Rules] = (RouteRule(Route.CHAT_COMPLETIONS, Rollout.PYTHON_ONLY),) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result: Final = ( + await dispatch.arun( + ("model",), {"client": client}, python=apython, binding=binding(), native=pytest.fail, rules=rules + ) + if asynchronous + else dispatch.run( + ("model",), {"client": client}, python=python, binding=binding(), native=pytest.fail, rules=rules + ) + ) + + assert result == "python" + assert received == [client] + assert [(warning.category, warning.filename) for warning in caught if "client=" in str(warning.message)] == [ + (DeprecationWarning, __file__) + ] diff --git a/tests/unit/rust_bridge/test_public_call.py b/tests/unit/rust_bridge/test_public_call.py index 6997945dd2e..87231d89980 100644 --- a/tests/unit/rust_bridge/test_public_call.py +++ b/tests/unit/rust_bridge/test_public_call.py @@ -79,32 +79,23 @@ def test_flag_keywords_reject_a_parameter_given_twice() -> None: _FLAG.keywords((128,), {"max_tokens": 64}) -def _hook_kwargs(supplied: Mapping[str, object]) -> tuple[Mapping[str, object], tuple[warnings.WarningMessage, ...]]: - call: Final = native_call(_SIGNATURE, (128, [], "model"), supplied) - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - seen: Final = native_call_hook(lambda hooked: hooked.kwargs, call, (), {}) - return seen, tuple(warning for warning in caught if issubclass(warning.category, DeprecationWarning)) - - -def test_hook_drops_a_caller_client_and_warns_at_the_callers_line() -> None: - extra_body: Final = {"pages": [0]} - - seen, caught = _hook_kwargs({"client": object(), "pages": [1, 2], "extra_body": extra_body}) - - assert seen == {"pages": [1, 2], "extra_body": extra_body} - assert seen["extra_body"] is extra_body - assert len(caught) == 1 - assert "client=" in str(caught[0].message) - assert caught[0].filename == __file__ - @pytest.mark.parametrize( "supplied", - ({"shared_session": object(), "api_key": "k"}, {"client": None, "api_key": "k"}, {"api_key": "k"}), + ( + {"client": object(), "api_key": "k"}, + {"shared_session": object(), "api_key": "k"}, + {"client": None, "api_key": "k"}, + {"api_key": "k"}, + ), ) -def test_hook_drops_internal_or_unset_clients_without_warning(supplied: Mapping[str, object]) -> None: - seen, caught = _hook_kwargs(supplied) +def test_hook_hands_rust_the_call_without_client_or_shared_session(supplied: Mapping[str, object]) -> None: + extra_body: Final = {"pages": [0]} + call: Final = native_call(_SIGNATURE, (128, [], "model"), {**supplied, "extra_body": extra_body}) - assert seen == {"api_key": "k"} - assert caught == () + with warnings.catch_warnings(): + warnings.simplefilter("error") + seen: Final = native_call_hook(lambda hooked: hooked.kwargs, call, (), {}) + + assert seen == {"api_key": "k", "extra_body": extra_body} + assert seen["extra_body"] is extra_body diff --git a/tests/unit/test_audio_transcription_rust_bridge.py b/tests/unit/test_audio_transcription_rust_bridge.py index c3762330d8c..1ad0cf4afab 100644 --- a/tests/unit/test_audio_transcription_rust_bridge.py +++ b/tests/unit/test_audio_transcription_rust_bridge.py @@ -4,6 +4,7 @@ import warnings from collections.abc import Generator from typing import Final +import openai import pytest import litellm @@ -316,3 +317,29 @@ def test_bedrock_transcription_warns_that_a_caller_client_is_ignored() -> None: assert response.text == "rust" assert len(bridge.calls) == 1 assert [(warning.category, warning.filename) for warning in ignored] == [(DeprecationWarning, __file__)] + + +@pytest.mark.asyncio +async def test_bedrock_atranscription_warns_once_that_a_caller_client_is_deprecated() -> None: + bridge: Final = AsyncBridge() + NATIVE_ATRANSCRIPTION.override(bridge) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + response: Final = await litellm.atranscription(model=MODEL, file=AUDIO_FILE, client=object()) + + ignored: Final = [warning for warning in caught if "client=" in str(warning.message)] + assert response.text == "async rust" + assert [(warning.category, warning.filename) for warning in ignored] == [(DeprecationWarning, __file__)] + + +def test_python_served_transcription_warns_that_a_caller_client_is_deprecated() -> None: + client: Final = openai.OpenAI(api_key="sk-test", base_url="http://127.0.0.1:9", max_retries=0) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with pytest.raises(openai.APIConnectionError, match="Connection error"): + litellm.transcription(model="openai/whisper-1", file=AUDIO_FILE, client=client) + + ignored: Final = [warning for warning in caught if "client=" in str(warning.message)] + assert [(warning.category, warning.filename) for warning in ignored] == [(DeprecationWarning, __file__)]