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:
Daniel JB Clark 2026-10-03 06:54:20 -04:00
parent 7d9ef1546d
commit b9d159fcc9
No known key found for this signature in database
4 changed files with 153 additions and 8 deletions

View file

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

View file

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

View file

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

View 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