mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
fix(clinepass): stop unsupported endpoints reaching the provider with the OpenAI key
ClinePass implements chat completions only, but it was listed in
`openai_compatible_providers` to reach `_map_openai_exception`. That list is not
inert for routing:
* the speech branch in `main.py` matched it, and sends the request to the
provider's own `api_base` while reading the credential from `OPENAI_API_KEY` --
so `litellm.speech(model="clinepass/...")` POSTed the caller's OpenAI key to
the Cline host for an endpoint that does not exist there;
* `OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS` is derived from the same list, opening
the identical hole for transcription;
* `litellm/images/main.py` consults it for image generation;
* `_add_provider_specific_params` packs unknown kwargs into `extra_body`, 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.
Drop the membership and register ClinePass explicitly for `_map_openai_exception`
alongside `mistral` and `runwayml`, which is the established shape for a provider
with its own module. Exception mapping is unchanged and still covered by
`test_upstream_401_maps_to_authentication_error`.
Verified: speech, transcription and image generation now make zero outbound
requests and transmit no credential; unknown chat kwargs are flattened instead of
wrapped. Image generation returns an empty `ImageResponse` rather than raising,
which is pre-existing upstream behaviour for a provider with no image support --
`mistral` behaves identically.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
7d9ef1546d
commit
b9d159fcc9
4 changed files with 153 additions and 8 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
123
tests/unit/llms/clinepass/test_clinepass_endpoint_guard.py
Normal file
123
tests/unit/llms/clinepass/test_clinepass_endpoint_guard.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue