diff --git a/litellm/constants.py b/litellm/constants.py index 6e5de432c51..372585cd5cc 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1042,7 +1042,6 @@ openai_compatible_providers: Final[list] = [ "scx-ai", "prism", "sail", - "clinepass", # ClinePass (Cline API) - has its own module; listed here for exception mapping ] OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS: Final = frozenset({"openai"} | frozenset(openai_compatible_providers)) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 0fdfb301291..6af3a8912a1 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -2468,6 +2468,7 @@ def exception_type( or custom_llm_provider == "text-completion-openai" or custom_llm_provider == "custom_openai" or custom_llm_provider in litellm.openai_compatible_providers + or custom_llm_provider == "clinepass" or custom_llm_provider == "mistral" or custom_llm_provider == "runwayml" ): diff --git a/tests/unit/llms/clinepass/chat/test_clinepass_chat_transformation.py b/tests/unit/llms/clinepass/chat/test_clinepass_chat_transformation.py index 588aa7ec42f..7c2c6792612 100644 --- a/tests/unit/llms/clinepass/chat/test_clinepass_chat_transformation.py +++ b/tests/unit/llms/clinepass/chat/test_clinepass_chat_transformation.py @@ -85,12 +85,33 @@ def test_clinepass_is_not_a_json_configured_provider(): assert not JSONProviderRegistry.exists("clinepass") -def test_clinepass_stays_in_openai_compatible_providers(): - """Membership drives `_map_openai_exception`, so dropping it silently - downgrades a 401 to APIConnectionError. The explicit dispatch branch in - main.py precedes the openai_compatible_providers catch-all, so being listed - here does NOT route ClinePass to the OpenAI SDK path.""" - assert "clinepass" in litellm.openai_compatible_providers +def test_clinepass_is_not_in_openai_compatible_providers(): + """ClinePass must NOT be in `openai_compatible_providers`. + + An earlier revision listed it there to reach `_map_openai_exception`, on the + assumption that the explicit dispatch branch in main.py made membership + inert for routing. It is not inert. The list is also consulted by: + + * the speech branch in `main.py` -- which sends the request to the + provider's own `api_base` while taking the key from + `OPENAI_API_KEY`, leaking the user's OpenAI credential to a third-party + host for an endpoint ClinePass does not even implement; + * `OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS`, derived from this list, which + opens the same hole for transcription; + * image generation in `litellm/images/main.py`; + * `_add_provider_specific_params` in `litellm/utils.py`, which packs unknown + kwargs into `extra_body`. That is an OpenAI *SDK* concept the SDK unwraps + client-side. ClinePass dispatches through `BaseLLMHTTPHandler`, which + serialises optional params straight into the JSON body -- so membership + put a literal `"extra_body": {}` on the wire on every chat request, and + buried genuine vendor kwargs one level deep instead of flattening them. + + Exception mapping is preserved by registering ClinePass explicitly alongside + `mistral` and `runwayml` in `exception_mapping_utils.py`; + `test_upstream_401_maps_to_authentication_error` is the guard for that. + """ + assert "clinepass" not in litellm.openai_compatible_providers + assert "clinepass" not in litellm.constants.OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS def test_api_base_env_override(monkeypatch): @@ -358,7 +379,8 @@ def test_no_max_tokens_means_no_rewrite(): assert response.choices[0].finish_reason == "stop" -def test_max_completion_tokens_also_detects_truncation(): +def test_max_completion_tokens_param_detects_truncation_after_mapping(): + """``max_completion_tokens`` is mapped to ``max_tokens`` before ``request_data`` is built.""" response = _complete(_truncated_envelope(4000), max_completion_tokens=4000) assert response.choices[0].finish_reason == "length" diff --git a/tests/unit/llms/clinepass/test_clinepass_endpoint_guard.py b/tests/unit/llms/clinepass/test_clinepass_endpoint_guard.py new file mode 100644 index 00000000000..83d798c9b35 --- /dev/null +++ b/tests/unit/llms/clinepass/test_clinepass_endpoint_guard.py @@ -0,0 +1,123 @@ +"""ClinePass must not be reachable on endpoints it does not implement. + +ClinePass implements chat completions only. Before this guard existed, listing +it in `litellm.openai_compatible_providers` made the speech, transcription and +image-generation branches in litellm match it. Those branches send the request +to the provider's own `api_base` but read the credential from `OPENAI_API_KEY`, +so a `litellm.speech(model="clinepass/...")` call POSTed the caller's OpenAI key +to the Cline host -- for an endpoint that does not exist there. + +Asserting "not in the list" is a structural check and lives with the other +registry tests. These tests assert the behaviour instead: that no HTTP request +leaves the process at all, and that the OpenAI credential is never transmitted. +""" + +import contextlib + +import httpx +import pytest + +import litellm +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler + +SENTINEL_OPENAI_KEY = "sk-sentinel-openai-key-must-never-be-transmitted" + + +@pytest.fixture +def no_request_allowed(monkeypatch): + """Fail loudly if anything attempts an outbound request, and record it. + + Patched at the httpx transport layer rather than at litellm's handlers, so a + future dispatch path that bypasses HTTPHandler is still caught. + """ + attempted: list[tuple[str, str]] = [] + + def record_and_block(self, request, *args, **kwargs): + attempted.append((str(request.url), request.headers.get("authorization", ""))) + raise AssertionError(f"outbound request attempted to {request.url}") + + monkeypatch.setattr(httpx.Client, "send", record_and_block, raising=True) + monkeypatch.setattr(httpx.AsyncClient, "send", record_and_block, raising=True) + for handler in (HTTPHandler, AsyncHTTPHandler): + monkeypatch.setattr(handler, "post", record_and_block, raising=True) + + monkeypatch.setenv("OPENAI_API_KEY", SENTINEL_OPENAI_KEY) + monkeypatch.setenv("CLINEPASS_API_KEY", "cp-test-key") + return attempted + + +def test_speech_makes_no_outbound_request(no_request_allowed): + with pytest.raises(Exception) as excinfo: + litellm.speech(model="clinepass/deepseek-v4-flash", input="hi", voice="alloy") + + assert not isinstance(excinfo.value, AssertionError), ( + "speech() reached the network; the unsupported-endpoint guard is gone" + ) + assert no_request_allowed == [] + + +def test_transcription_makes_no_outbound_request(no_request_allowed, tmp_path): + audio = tmp_path / "a.mp3" + audio.write_bytes(b"\x00\x00") + + with open(audio, "rb") as handle: + with pytest.raises(Exception) as excinfo: + litellm.transcription(model="clinepass/deepseek-v4-flash", file=handle) + + assert not isinstance(excinfo.value, AssertionError) + assert no_request_allowed == [] + + +def test_image_generation_makes_no_outbound_request(no_request_allowed): + """Image generation must not reach the Cline host. + + Note it does not raise either: litellm returns an empty `ImageResponse` for + any provider with no image support. That is pre-existing upstream behaviour, + not a ClinePass quirk -- `mistral`, which has the same shape ClinePass now + has (own module, absent from `openai_compatible_providers`, explicitly + registered for exception mapping), returns the same empty response with zero + outbound requests. So this test asserts the property that is ours to keep: + nothing is transmitted. + """ + litellm.image_generation(model="clinepass/deepseek-v4-flash", prompt="a cat") + + assert no_request_allowed == [] + + +def test_openai_credential_is_never_transmitted(no_request_allowed): + """The point of the P1: whatever happens, the OpenAI key must not go out.""" + for call in ( + lambda: litellm.speech( + model="clinepass/deepseek-v4-flash", input="hi", voice="alloy" + ), + lambda: litellm.transcription(model="clinepass/deepseek-v4-flash", file=None), + lambda: litellm.image_generation( + model="clinepass/deepseek-v4-flash", prompt="a cat" + ), + ): + # Whether each endpoint raises or returns an empty response is upstream's + # business; that no credential leaves the process is ours. + with contextlib.suppress(Exception): + call() + + leaked = [url for url, auth in no_request_allowed if SENTINEL_OPENAI_KEY in auth] + assert leaked == [], f"OPENAI_API_KEY was transmitted to {leaked}" + + +def test_unknown_kwargs_are_flattened_not_wrapped_in_extra_body(): + """`extra_body` is an OpenAI-SDK concept the SDK unwraps client-side. + + ClinePass dispatches through `BaseLLMHTTPHandler`, which serialises optional + params directly into the JSON body, so an `extra_body` key would be sent to + the API verbatim and a genuine vendor kwarg would arrive nested one level + too deep. + """ + params = litellm.utils.get_optional_params( + model="cline-pass/deepseek-v4-flash", + custom_llm_provider="clinepass", + temperature=0.5, + some_vendor_knob=7, + ) + + assert "extra_body" not in params + assert params["some_vendor_knob"] == 7