Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2553cbd
feat(clinepass): add ClinePass provider
djbclark Aug 22, 2026
e688210
fix(clinepass): correct the model namespace, harden truncation detection
djbclark Aug 22, 2026
9a91da3
feat(clinepass): add Admin UI credential fields entry
djbclark Aug 22, 2026
7d9ef15
test(clinepass): move the provider tests into the CI-selected tree
djbclark Oct 3, 2026
b9d159f
fix(clinepass): stop unsupported endpoints reaching the provider with…
djbclark Oct 3, 2026
bf18f22
fix(clinepass): address external review -- logging, unicode, dead cod…
djbclark Oct 3, 2026
69ecca0
feat(clinepass): register the model in the price/context catalogue
djbclark Oct 3, 2026
edf3132
fix(clinepass): report the upstream finish reason instead of inferrin…
djbclark Oct 3, 2026
a6c9f01
test(clinepass): cover streaming, usage, auth headers and 429
djbclark Oct 3, 2026
51214ed
fix(clinepass): address CI — mirrored cost map, logoless set, formatting
djbclark Oct 3, 2026
d33e84b
fix(clinepass): satisfy ruff — drop a redundant pass, narrow two raises
djbclark Oct 3, 2026
cb9aeab
fix(clinepass): type logging_obj and encoding instead of Any
djbclark Oct 3, 2026
6d3a53f
fix(clinepass): register the provider with litellm's own guards
djbclark Oct 3, 2026
0e756bf
test(clinepass): expect context-window and content-policy recognition
djbclark Oct 3, 2026
abadd88
fix(clinepass): never borrow the global litellm.api_key for chat
djbclark Oct 3, 2026
08408fe
test(clinepass): state what the extra_body assertions actually pin
djbclark Oct 3, 2026
304d7a2
fix(clinepass): justify the dict-typed overrides for the type-discipl…
djbclark Oct 3, 2026
ace7be6
fix(clinepass): reject realtime credential fallback and isolate provi…
djbclark Oct 4, 2026
497dc8a
fix(clinepass): isolate moderation and responses websocket credentials
djbclark Oct 4, 2026
0bc31c0
fix(clinepass): pin authenticated websocket models and type moderatio…
djbclark Oct 4, 2026
886b145
Merge upstream/main into clinepass-upstream
djbclark Oct 8, 2026
ba51099
fix(clinepass): drop typing.Any from the runtime logging-object fallback
djbclark Oct 8, 2026
6506040
fix(clinepass): satisfy the new Final, rebind and basedpyright ceilings
djbclark Oct 8, 2026
838f7bd
Merge upstream/main into clinepass-upstream
djbclark Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ Set `LITELLM_PROXY_API_BASE` and `LITELLM_PROXY_API_KEY` and every model call th
| [Bytez (`bytez`)](https://docs.litellm.ai/docs/providers/bytez) | ✅ | ✅ | ✅ | | | | | | | |
| [Cerebras (`cerebras`)](https://docs.litellm.ai/docs/providers/cerebras) | ✅ | ✅ | ✅ | | | | | | | |
| [Clarifai (`clarifai`)](https://docs.litellm.ai/docs/providers/clarifai) | ✅ | ✅ | ✅ | | | | | | | |
| [ClinePass (`clinepass`)](https://docs.litellm.ai/docs/providers/clinepass) | ✅ | ✅ | ✅ | | | | | | | |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Provider link lacks a page

The PR description says the linked ClinePass documentation page is only drafted for a separate repository. If this change merges before that page is published, readers following the new provider link will reach a missing page. Publish the companion page before exposing the link.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

| [Cloudflare AI Workers (`cloudflare`)](https://docs.litellm.ai/docs/providers/cloudflare_workers) | ✅ | ✅ | ✅ | | | | | | | |
| [Codestral (`codestral`)](https://docs.litellm.ai/docs/providers/codestral) | ✅ | ✅ | ✅ | | | | | | | |
| [Cognition (`cognition`)](https://docs.litellm.ai/docs/providers/cognition) | ✅ | ✅ | ✅ | | | | | | | |
Expand Down
8 changes: 8 additions & 0 deletions litellm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,7 @@ def identify(event_details):
xai_models: Set = set()
zai_models: Set = set()
deepseek_models: Set = set()
clinepass_models: set[str] = set() # mutable-ok: filled from the model cost map at import
tencent_models: Set = set()
runwayml_models: Set = set()
azure_ai_models: Set = set()
Expand Down Expand Up @@ -874,6 +875,8 @@ def _populate_provider_model_sets(model_cost_map: Mapping[str, Mapping[str, obje
fal_ai_models.add(key)
elif value.get("litellm_provider") == "deepseek":
deepseek_models.add(key)
elif value.get("litellm_provider") == "clinepass": # pyright: ignore[reportUnknownMemberType] # value comes from the untyped model cost map
clinepass_models.add(key) # pyright: ignore[reportUnknownArgumentType] # key comes from the untyped model cost map
elif value.get("litellm_provider") == "tencent":
tencent_models.add(key)
elif value.get("litellm_provider") == "runwayml":
Expand Down Expand Up @@ -1090,6 +1093,7 @@ def add_known_models(model_cost_map: Optional[Dict] = None):
| zai_models
| fal_ai_models
| deepseek_models
| clinepass_models
| modelscope_models
| azure_ai_models
| voyage_models
Expand Down Expand Up @@ -1196,6 +1200,7 @@ def _build_models_by_provider() -> dict:
"zai": zai_models,
"fal_ai": fal_ai_models,
"deepseek": deepseek_models,
"clinepass": clinepass_models,
"tencent": tencent_models,
"runwayml": runwayml_models,
"mistral": mistral_chat_models,
Expand Down Expand Up @@ -2104,6 +2109,9 @@ def set_global_gitlab_config(config: Mapping[str, object]) -> None:
)
from .llms.heroku.chat.transformation import HerokuChatConfig as HerokuChatConfig
from .llms.cometapi.chat.transformation import CometAPIConfig as CometAPIConfig
from .llms.clinepass.chat.transformation import (
ClinePassConfig as ClinePassConfig,
)
from .llms.azure.chat.gpt_transformation import (
AzureOpenAIConfig as AzureOpenAIConfig,
)
Expand Down
2 changes: 2 additions & 0 deletions litellm/_lazy_imports_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@
"AzureOpenAIAssistantsAPIConfig",
"HerokuChatConfig",
"CometAPIConfig",
"ClinePassConfig",
"AzureOpenAIConfig",
"AzureOpenAIGPT5Config",
"AzureOpenAITextConfig",
Expand Down Expand Up @@ -1157,6 +1158,7 @@
),
"HerokuChatConfig": (".llms.heroku.chat.transformation", "HerokuChatConfig"),
"CometAPIConfig": (".llms.cometapi.chat.transformation", "CometAPIConfig"),
"ClinePassConfig": (".llms.clinepass.chat.transformation", "ClinePassConfig"),
"AzureOpenAIConfig": (".llms.azure.chat.gpt_transformation", "AzureOpenAIConfig"),
"AzureOpenAIGPT5Config": (
".llms.azure.chat.gpt_5_transformation",
Expand Down
1 change: 1 addition & 0 deletions litellm/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,7 @@ class ServerStreamingClassification(str, Enum):
"docker_model_runner",
"amazon_nova",
"scaledown",
"clinepass",
]

# Resolving these providers runs an OAuth device flow (their provider info IS the login), so any
Expand Down
1 change: 1 addition & 0 deletions litellm/litellm_core_utils/exception_mapping_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2513,6 +2513,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"
):
Expand Down
3 changes: 3 additions & 0 deletions litellm/litellm_core_utils/get_llm_provider_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,9 @@ def _get_openai_compatible_provider_info(
api_base,
dynamic_api_key,
) = litellm.PerplexityChatConfig().get_openai_compatible_provider_info(api_base, api_key)
elif custom_llm_provider == "clinepass":
cfg: Final = litellm.ClinePassConfig()
api_base, dynamic_api_key = cfg.get_openai_compatible_provider_info(api_base, api_key) # rebind-ok: per branch
elif custom_llm_provider == "aiohttp_openai":
return model, "aiohttp_openai", api_key, api_base
elif custom_llm_provider == "anyscale":
Expand Down
281 changes: 281 additions & 0 deletions litellm/llms/clinepass/chat/transformation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
"""
Support for ClinePass (the Cline API) `/v1/chat/completions` endpoint.

ClinePass is OpenAI-compatible apart from two quirks, both handled here:

1. Non-streaming completions are nested under a ``data`` envelope --
``{"data": {"choices": [...]}, "success": true}`` -- rather than returning
``choices`` at the top level. Streaming responses are *not* wrapped, so the
inherited SSE handling needs no change.
2. A bare model id is rejected with HTTP 400 ``invalid model format. Expected
format: modelType/model``, but LiteLLM strips its own ``clinepass/`` routing
prefix before the request is built, so a qualifier has to be restored.

Documentation: https://docs.cline.bot/

Credentials come only from the request's api_key or CLINEPASS_API_KEY.
Moderation and realtime endpoints are unsupported and rejected before dispatch.
"""

import json
from collections.abc import Mapping
from typing import TYPE_CHECKING, Final, NoReturn

import httpx
from pydantic import TypeAdapter, ValidationError

from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import ModelResponse

from ...openai.chat.gpt_transformation import OpenAIGPTConfig
from ..common_utils import ClinePassException

# Needed only for annotations; importing litellm_logging at runtime from a
# provider module risks a circular import.
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.tokenizer import Encoding as Tokenizer

CLINEPASS_API_BASE: Final = "https://api.cline.bot/api/v1"

# ClinePass nests the completion under this key on non-streaming responses.
CLINEPASS_RESPONSE_ENVELOPE_KEY: Final = "data"

# The qualifier ClinePass expects on outbound model ids.
#
# Note the hyphen: the catalog namespace is ``cline-pass/``, not ``clinepass/``
# (the latter is LiteLLM's own routing prefix, which is stripped before the
# request is built). The API only validates the *shape* of a model id -- any
# ``<segment>/<model>`` is accepted with HTTP 200 -- so an incorrect namespace
# fails silently rather than loudly. It is not inert, though: for at least one
# model an unrecognised namespace resolves to a different, date-pinned snapshot
# (``cline-pass/deepseek-v4-flash`` -> ``deepseek/deepseek-v4-flash``, while
# ``clinepass/deepseek-v4-flash`` -> ``deepseek/deepseek-v4-flash-0731``).
CLINEPASS_MODEL_PREFIX: Final = "cline-pass/"

# Headers that describe the original byte stream and would be wrong once the
# body is rewritten by _unwrap_response_envelope().
_BODY_SPECIFIC_HEADERS: Final = ("content-length", "content-encoding")

_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object])


def _as_json_object(value: object) -> Mapping[str, object] | None:
try:
return _JSON_OBJECT.validate_python(value)
except ValidationError:
return None


def _unwrap_response_envelope(raw_response: httpx.Response) -> httpx.Response:
"""Strip ClinePass's ``data`` wrapper off a JSON completion body.

The OpenAI transforms read ``raw_response.json()`` directly, so rebuild the
response around the inner object rather than duplicating their bodies here.

Returns the original response untouched whenever the body does not look like
a wrapped completion, so an already-OpenAI-shaped body -- or an error nested
under the same key -- is not mistaken for one.
"""
try:
payload: Final = _JSON_OBJECT.validate_json(raw_response.content)
except ValidationError:
# Not a JSON object body -- there is no envelope to strip.
return raw_response

if "choices" in payload:
return raw_response

inner: Final = _as_json_object(payload.get(CLINEPASS_RESPONSE_ENVELOPE_KEY))
if inner is None or "choices" not in inner:
return raw_response

return httpx.Response(
status_code=raw_response.status_code,
headers={k: v for k, v in raw_response.headers.items() if k.lower() not in _BODY_SPECIFIC_HEADERS},
content=json.dumps(inner, ensure_ascii=False).encode("utf-8"),
request=_attached_request(raw_response),
)


def _attached_request(raw_response: httpx.Response) -> httpx.Request | None:
# httpx.Response.request raises RuntimeError rather than returning None when
# no request is attached, so ask for it defensively instead of reaching for
# the private attribute behind it.
try:
return raw_response.request
except RuntimeError:
return None


def _apply_model_prefix(data: dict[str, object]) -> dict[str, object]: # mutable-ok: dict-typed base transform_request
"""Restore the ``modelType/model`` qualifier on the outbound model id.

Only prefix ids that lost their qualifier, so a cross-provider id
(``clinepass/openrouter/foo`` -> ``openrouter/foo``) is forwarded unchanged.
"""
model: Final = data.get("model")
if isinstance(model, str) and "/" not in model:
return {**data, "model": f"{CLINEPASS_MODEL_PREFIX}{model}"}
return data


class ClinePassConfig(OpenAIGPTConfig):
"""
ClinePass configuration, inheriting the OpenAI chat transforms.

Overrides only the request/response points where ClinePass diverges; see the
module docstring for the two quirks.
"""

@staticmethod
def get_realtime_http_config(model: str) -> NoReturn:
from litellm.exceptions import BadRequestError

raise BadRequestError(
message="ClinePass does not support realtime endpoints",
model=model,
llm_provider="clinepass",
)

@staticmethod
def validate_moderation(model: str | None, custom_llm_provider: str | None = None) -> None:
if custom_llm_provider != "clinepass" and not (model or "").startswith("clinepass/"):
return
from litellm.exceptions import BadRequestError

raise BadRequestError(
message="ClinePass does not support moderation endpoints",
model=model or "",
llm_provider="clinepass",
)

def _get_openai_compatible_provider_info(
self, api_base: str | None, api_key: str | None
) -> tuple[str | None, str | None]:
return (
api_base or get_secret_str("CLINEPASS_API_BASE") or CLINEPASS_API_BASE,
api_key or get_secret_str("CLINEPASS_API_KEY"),
)

def get_openai_compatible_provider_info(
self,
api_base: str | None,
api_key: str | None,
) -> tuple[str | None, str | None]:
return self._get_openai_compatible_provider_info(api_base, api_key)

def get_complete_url(
self,
api_base: str | None,
api_key: str | None,
model: str,
optional_params: dict[str, object], # mutable-ok: matches the dict-typed base-class signature
litellm_params: dict[str, object], # mutable-ok: matches the dict-typed base-class signature
stream: bool | None = None,
) -> str:
base: Final = (api_base or CLINEPASS_API_BASE).rstrip("/")
if base.endswith("/chat/completions"):
return base

return f"{base}/chat/completions"

def get_models(
self, api_key: str | None = None, api_base: str | None = None
) -> list[str]: # mutable-ok: matches the dict-typed base-class signature
"""ClinePass exposes no model catalog.

``GET https://api.cline.bot/api/v1/models`` returns HTTP 404, and the
inherited OpenAI implementation would additionally ask for it at the
wrong path -- it rewrites the base URL down to scheme+host and appends
``/v1/models``. Return an empty catalog rather than making a request
that is known to fail.
"""
return []

def map_openai_params(
self,
non_default_params: dict[str, object], # mutable-ok: matches the dict-typed base-class signature
optional_params: dict[str, object], # mutable-ok: matches the dict-typed base-class signature
model: str,
drop_params: bool,
) -> dict[str, object]: # mutable-ok: matches the dict-typed base-class signature
"""ClinePass takes the legacy ``max_tokens`` spelling only."""
mapped_params: Final[dict[str, object]] # mutable-ok: dict-typed base map_openai_params
mapped_params = super().map_openai_params( # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] # base signature takes bare dict
non_default_params=non_default_params,
optional_params=optional_params,
model=model,
drop_params=drop_params,
)
if "max_completion_tokens" in mapped_params:
mapped_params["max_tokens"] = mapped_params.pop("max_completion_tokens")
return mapped_params

def transform_request(
self,
model: str,
messages: list[AllMessageValues], # mutable-ok: matches the dict-typed base-class signature
optional_params: dict[str, object], # mutable-ok: matches the dict-typed base-class signature
litellm_params: dict[str, object], # mutable-ok: matches the dict-typed base-class signature
headers: dict[str, object], # mutable-ok: matches the dict-typed base-class signature
) -> dict[str, object]: # mutable-ok: matches the dict-typed base-class signature
# BaseLLMHTTPHandler builds the body with this synchronous method on
# both the sync and the async path, so there is deliberately no
# async_transform_request() override -- it would never be called.
data: Final[dict[str, object]] # mutable-ok: dict-typed base transform_request
data = super().transform_request( # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] # base signature takes bare dict
model=model,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
headers=headers,
)
return _apply_model_prefix(data)

def transform_response(
self,
model: str,
raw_response: httpx.Response,
model_response: ModelResponse,
logging_obj: "Logging",
request_data: dict[str, object], # mutable-ok: matches the dict-typed base-class signature
messages: list[AllMessageValues], # mutable-ok: matches the dict-typed base-class signature
optional_params: dict[str, object], # mutable-ok: matches the dict-typed base-class signature
litellm_params: dict[str, object], # mutable-ok: matches the dict-typed base-class signature
encoding: "Tokenizer | None",
api_key: str | None = None,
json_mode: bool | None = None,
) -> ModelResponse:
# 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( # pyright: ignore[reportUnknownMemberType] # base signature takes bare dict
model=model,
raw_response=_unwrap_response_envelope(raw_response),
model_response=model_response,
logging_obj=logging_obj,
request_data=request_data,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
encoding=encoding,
api_key=api_key,
json_mode=json_mode,
)

def get_error_class(
self,
error_message: str,
status_code: int,
headers: dict[str, object] | httpx.Headers, # mutable-ok: matches the dict-typed base-class signature
) -> BaseLLMException:
return ClinePassException(
message=error_message,
status_code=status_code,
headers=headers,
)
5 changes: 5 additions & 0 deletions litellm/llms/clinepass/common_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from litellm.llms.base_llm.chat.transformation import BaseLLMException


class ClinePassException(BaseLLMException):
"""ClinePass exception handling class"""
Loading
Loading