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 <noreply@anthropic.com>
This commit is contained in:
Yujong Lee 2026-11-05 04:24:30 +09:00
parent 0dd95d64ab
commit 312c5f8988
6 changed files with 97 additions and 31 deletions

View file

@ -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(

View file

@ -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)

View file

@ -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})

View file

@ -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__)
]

View file

@ -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

View file

@ -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__)]