diff --git a/litellm/llms/clinepass/chat/transformation.py b/litellm/llms/clinepass/chat/transformation.py index 78fbd606840..9a78695dacb 100644 --- a/litellm/llms/clinepass/chat/transformation.py +++ b/litellm/llms/clinepass/chat/transformation.py @@ -49,71 +49,6 @@ CLINEPASS_MODEL_PREFIX: Final = "cline-pass/" _BODY_SPECIFIC_HEADERS: Final = ("content-length", "content-encoding") -def _as_positive_number(value: Any) -> float | None: - """Return ``value`` as a positive number, or ``None`` if it is not one. - - ``bool`` is rejected explicitly: it is a subclass of ``int``, and ``True`` - would otherwise read as a cap of 1. - """ - if isinstance(value, bool) or not isinstance(value, (int, float)): - return None - if value <= 0: - return None - return float(value) - - -def _correct_truncated_finish_reason(response: ModelResponse, request_data: dict) -> ModelResponse: - """Report a truncated ClinePass completion as ``length``, not ``stop``. - - This is a defensive correction for an upstream bug in which ClinePass - returned ``finish_reason: "stop"`` on a completion that had actually been - cut off by ``max_tokens``: a request capped at 4000 came back with - ``completion_tokens == 4000`` and still claimed a natural stop. Callers that - trust ``finish_reason`` -- the documented way to detect truncation -- cannot - then distinguish a complete answer from a guillotined one. - - Re-probing the live API later (2026-08-22, both streaming and non-streaming, - caps of 2000 and 4000, across both the ``cline-pass/`` and the fallback - namespace) did *not* reproduce the misreport: every capped response - correctly returned ``length``. The upstream bug appears to have been fixed, - or to be intermittent. This correction is therefore kept as a cheap safety - net rather than as a workaround for a currently-observable defect, and it is - deliberately conservative: - - - only an upstream ``stop`` is ever rewritten; ``length`` is already right, - - only when usage shows the cap was actually reached, - - and only for single-choice responses. ``usage.completion_tokens`` is an - aggregate across all choices while ``max_tokens`` is a per-choice limit, - so with ``n > 1`` the aggregate cannot identify *which* choice was - truncated -- two naturally-finished 60-token choices under a cap of 100 - would otherwise both be relabelled ``length``. - - Streaming is deliberately not covered: chunks are assembled by the inherited - SSE iterator, the terminal ``finish_reason`` arrives before the usage chunk - that would justify rewriting it, and callers that omit - ``stream_options.include_usage`` never receive usage at all. Since the - misreport no longer reproduces, buffering the stream to correct it is not - worth the latency and complexity. - """ - if len(response.choices) != 1: - return response - - max_tokens = _as_positive_number(request_data.get("max_tokens")) - if max_tokens is None: - return response - - usage = getattr(response, "usage", None) - completion_tokens = _as_positive_number(getattr(usage, "completion_tokens", None)) - if completion_tokens is None or completion_tokens < max_tokens: - return response - - for choice in response.choices: - if getattr(choice, "finish_reason", None) == "stop": - choice.finish_reason = "length" - - return response - - def _unwrap_response_envelope(raw_response: httpx.Response) -> httpx.Response: """Strip ClinePass's ``data`` wrapper off a JSON completion body. @@ -263,7 +198,12 @@ class ClinePassConfig(OpenAIGPTConfig): api_key: str | None = None, json_mode: bool | None = None, ) -> ModelResponse: - response = super().transform_response( + # ClinePass was once observed returning finish_reason "stop" on a completion + # cut off by max_tokens. Follow-up probes on 2026-08-22 did not reproduce it. + # The provider therefore reports the upstream finish reason unmodified: inferring + # truncation from usage equalling the cap produces false positives on natural + # completions that happen to land exactly on the cap. + return super().transform_response( model=model, raw_response=_unwrap_response_envelope(raw_response), model_response=model_response, @@ -276,11 +216,8 @@ class ClinePassConfig(OpenAIGPTConfig): api_key=api_key, json_mode=json_mode, ) - return _correct_truncated_finish_reason(response, request_data) - def get_error_class( - self, error_message: str, status_code: int, headers: dict | httpx.Headers - ) -> BaseLLMException: + def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BaseLLMException: return ClinePassException( message=error_message, status_code=status_code, 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 7c2c6792612..2747936bb3b 100644 --- a/tests/unit/llms/clinepass/chat/test_clinepass_chat_transformation.py +++ b/tests/unit/llms/clinepass/chat/test_clinepass_chat_transformation.py @@ -17,7 +17,6 @@ import litellm from litellm.llms.clinepass.chat.transformation import ( ClinePassConfig, _apply_model_prefix, - _correct_truncated_finish_reason, _unwrap_response_envelope, ) from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler @@ -275,6 +274,15 @@ def test_completion_streaming_is_not_unwrapped(): } for piece in ["one ", "two ", "three"] ] + chunks.append( + { + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "created": 1, + "model": "clinepass/deepseek-v4-flash", + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + ) body = "".join(f"data: {json.dumps(c)}\n\n" for c in chunks) + "data: [DONE]\n\n" def fake_post(self, url, *args, **kwargs): @@ -292,9 +300,17 @@ def test_completion_streaming_is_not_unwrapped(): max_tokens=4000, stream=True, ) - text = "".join(c.choices[0].delta.content or "" for c in stream if c.choices) + text = "" + finish_reason = None + for c in stream: + if c.choices: + if c.choices[0].delta.content: + text += c.choices[0].delta.content + if c.choices[0].finish_reason: + finish_reason = c.choices[0].finish_reason assert text == "one two three" + assert finish_reason == "stop" def test_upstream_401_maps_to_authentication_error(): @@ -329,9 +345,8 @@ def test_upstream_401_maps_to_authentication_error(): # # ClinePass was once observed returning finish_reason "stop" on a completion cut # off by max_tokens. Re-probing the live API on 2026-08-22 could not reproduce -# it (see _correct_truncated_finish_reason's docstring), so the correction is a -# conservative safety net: single-choice only, upstream "stop" only, and only -# when usage shows the cap was actually reached. +# it, so the provider now reports the upstream finish reason unmodified to avoid +# false positives on natural completions that land exactly on the cap. # -------------------------------------------------------------------------- @@ -354,14 +369,14 @@ def _complete(payload: dict, **kwargs): ) -def test_completion_at_the_cap_is_reported_as_length_not_stop(): +def test_completion_at_the_cap_preserves_upstream_stop(): response = _complete(_truncated_envelope(4000), max_tokens=4000) - assert response.choices[0].finish_reason == "length" + assert response.choices[0].finish_reason == "stop" -def test_completion_over_the_cap_is_reported_as_length(): +def test_completion_over_the_cap_preserves_upstream_stop(): response = _complete(_truncated_envelope(4001), max_tokens=4000) - assert response.choices[0].finish_reason == "length" + assert response.choices[0].finish_reason == "stop" def test_completion_below_the_cap_keeps_stop(): @@ -379,93 +394,12 @@ def test_no_max_tokens_means_no_rewrite(): assert response.choices[0].finish_reason == "stop" -def test_max_completion_tokens_param_detects_truncation_after_mapping(): +def test_max_completion_tokens_param_preserves_upstream_stop(): """``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" + assert response.choices[0].finish_reason == "stop" -@pytest.mark.parametrize("bad", [None, "4000", 0, -1, True, False]) -def test_unusable_cap_is_ignored(bad): - """Non-numeric, zero, negative and bool caps carry no truncation signal. - - ``bool`` matters because it subclasses ``int``: ``True`` would otherwise be - read as a cap of 1 and relabel every response as truncated. - """ - - class _Choice: - finish_reason = "stop" - - class _Usage: - completion_tokens = 9999 - - class _Response: - choices = [_Choice()] - usage = _Usage() - - result = _correct_truncated_finish_reason(_Response(), {"max_tokens": bad}) - assert result.choices[0].finish_reason == "stop" - - -def test_missing_usage_is_ignored(): - class _Choice: - finish_reason = "stop" - - class _Response: - choices = [_Choice()] - usage = None - - result = _correct_truncated_finish_reason(_Response(), {"max_tokens": 4000}) - assert result.choices[0].finish_reason == "stop" - - -def _stub_response(finish_reasons, completion_tokens): - """Minimal ModelResponse-shaped stub for the truncation helper.""" - - class _Choice: - def __init__(self, reason): - self.finish_reason = reason - - class _Usage: - pass - - usage = _Usage() - usage.completion_tokens = completion_tokens - - class _Response: - pass - - response = _Response() - response.choices = [_Choice(r) for r in finish_reasons] - response.usage = usage - return response - - -def test_multi_choice_response_is_never_rewritten(): - """`usage.completion_tokens` is an aggregate across choices while `max_tokens` - is per choice, so the aggregate cannot say WHICH choice was truncated. - - Two naturally-finished 60-token choices under a cap of 100 aggregate to 120, - which would otherwise relabel both as `length`. - """ - response = _stub_response(["stop", "stop"], completion_tokens=120) - result = _correct_truncated_finish_reason(response, {"max_tokens": 100}) - assert [c.finish_reason for c in result.choices] == ["stop", "stop"] - - -def test_single_choice_at_the_cap_is_still_rewritten(): - """The n>1 guard must not disable the correction for the normal n=1 case.""" - response = _stub_response(["stop"], completion_tokens=100) - result = _correct_truncated_finish_reason(response, {"max_tokens": 100}) - assert result.choices[0].finish_reason == "length" - - -def test_float_usage_and_cap_are_honoured(): - """A gateway that reports usage as JSON floats must still be understood.""" - response = _stub_response(["stop"], completion_tokens=4000.0) - result = _correct_truncated_finish_reason(response, {"max_tokens": 4000.0}) - assert result.choices[0].finish_reason == "length" - # -------------------------------------------------------------------------- # Model catalog