Merge remote-tracking branch 'origin/main' into litellm_mcp_client_allowlist

This commit is contained in:
yassin 2026-09-17 22:22:28 +00:00
commit 52914a06d2
12 changed files with 116 additions and 25 deletions

View file

@ -3,7 +3,7 @@
Example: Using CLI token with LiteLLM SDK
This example shows how to use the CLI authentication token
in your Python scripts after running `litellm-proxy login`.
in your Python scripts after running `lite login`.
"""
from textwrap import indent
@ -22,7 +22,7 @@ def main():
api_key = litellm.get_litellm_gateway_api_key()
if not api_key:
print("❌ No CLI token found. Please run 'litellm-proxy login' first.")
print("❌ No CLI token found. Please run 'lite login' first.")
return
print("✅ Found CLI token.")
@ -58,6 +58,6 @@ if __name__ == "__main__":
main()
print("\n💡 Tips:")
print("1. Run 'litellm-proxy login' to authenticate first")
print("1. Run 'lite login' to authenticate first")
print("2. Replace 'https://your-proxy.com' with your actual proxy URL")
print("3. The token is stored in your OS keychain, or in ~/.litellm/token.json when there is none")

View file

@ -38680,8 +38680,7 @@
"required": false,
"schema": {
"default": 10,
"maximum": 100,
"minimum": 1,
"minimum": 0,
"title": "Count",
"type": "integer"
}
@ -39385,8 +39384,7 @@
"required": false,
"schema": {
"default": 10,
"maximum": 100,
"minimum": 1,
"minimum": 0,
"title": "Count",
"type": "integer"
}

View file

@ -1,5 +1,5 @@
"""CLI package for LiteLLM Proxy Client."""
from .main import cli
from .main import cli, litellm_proxy_cli
__all__ = ["cli"]
__all__ = ["cli", "litellm_proxy_cli"]

View file

@ -36,8 +36,8 @@ def migrate(ctx: click.Context, check_only: bool, dry_run: bool):
resumable; safe to re-run after an interruption.
Examples:
litellm-proxy encryption migrate --check # attestation scan, no writes
litellm-proxy encryption migrate # perform the migration
lite encryption migrate --check # attestation scan, no writes
lite encryption migrate # perform the migration
"""
client: Final = HTTPClient(ctx.obj["base_url"], ctx.obj["api_key"])

View file

@ -168,5 +168,16 @@ cli.add_command(configure_group)
cli.add_command(unconfigure_group)
LITELLM_PROXY_DEPRECATION_NOTICE: Final = (
"The `litellm-proxy` command is deprecated and will be removed in a future release; "
"run `lite` instead, it takes the same commands and options."
)
def litellm_proxy_cli() -> None:
click.secho(LITELLM_PROXY_DEPRECATION_NOTICE, err=True, fg="yellow")
cli()
if __name__ == "__main__":
cli()

View file

@ -264,6 +264,8 @@ scim_router: Final = APIRouter(
dependencies=[Depends(_premium_user_check)],
)
SCIM_MAX_PAGE_SIZE: Final = 100
# Helper functions for common operations
async def _get_prisma_client_or_raise_exception():
@ -1572,12 +1574,13 @@ def _parse_scim_eq_filter(scim_filter: str) -> tuple[str, str] | None:
)
async def get_users(
startIndex: int = Query(1, ge=1),
count: int = Query(10, ge=1, le=100),
count: int = Query(10, ge=0),
filter: str | None = Query(None),
):
"""
Get a list of users according to SCIM v2 protocol
"""
page_size: Final = min(count, SCIM_MAX_PAGE_SIZE)
verbose_proxy_logger.debug(
"SCIM GET USERS request: startIndex=%s count=%s filter=%s",
startIndex,
@ -1607,7 +1610,7 @@ async def get_users(
users: Final[Sequence[LiteLLM_UserTable]] = await _table(UserRepository(prisma_client)).find_many(
where=where_conditions,
skip=(startIndex - 1),
take=count,
take=page_size,
order={"created_at": "desc"},
)
@ -1623,7 +1626,7 @@ async def get_users(
return SCIMListResponse(
totalResults=total_count,
startIndex=startIndex,
itemsPerPage=min(count, len(scim_users)),
itemsPerPage=len(scim_users),
Resources=scim_users,
)
@ -2399,12 +2402,13 @@ class _TeamWhereConditions(TypedDict, total=False):
)
async def get_groups(
startIndex: int = Query(1, ge=1),
count: int = Query(10, ge=1, le=100),
count: int = Query(10, ge=0),
filter: str | None = Query(None),
):
"""
Get a list of groups according to SCIM v2 protocol
"""
page_size: Final = min(count, SCIM_MAX_PAGE_SIZE)
verbose_proxy_logger.debug(
"SCIM GET GROUPS request: startIndex=%s count=%s filter=%s",
startIndex,
@ -2425,7 +2429,7 @@ async def get_groups(
teams: Final = await _table(TeamRepository(prisma_client)).find_many(
where=where_conditions,
skip=(startIndex - 1),
take=count,
take=page_size,
order={"created_at": "desc"},
)
@ -2462,7 +2466,7 @@ async def get_groups(
return SCIMListResponse(
totalResults=total_count,
startIndex=startIndex,
itemsPerPage=min(count, len(scim_groups)),
itemsPerPage=len(scim_groups),
Resources=scim_groups,
)

View file

@ -354,7 +354,7 @@ def _get_cli_sso_flow_or_raise(login_id: str | None, cache: DualCache) -> dict:
status_code=400,
detail=(
"Your litellm CLI is out of date and uses a login flow this proxy no longer supports. "
"Upgrade it with `pip install -U 'litellm[proxy]'` and run `litellm-proxy login` again."
"Upgrade it with `pip install -U 'litellm[proxy]'` and run `lite login` again."
),
)
if not _is_valid_cli_sso_login_id(login_id):
@ -375,7 +375,7 @@ def _get_cli_sso_flow_or_raise(login_id: str | None, cache: DualCache) -> dict:
raise HTTPException(
status_code=400,
detail=(
"CLI login session not found or expired. Run `litellm-proxy login` again. "
"CLI login session not found or expired. Run `lite login` again. "
"If this happens immediately after starting a login, the proxy is likely running multiple "
"replicas without a shared cache; configure a Redis cache "
"so every replica can see the login session."

View file

@ -174,7 +174,7 @@ proxy-runtime = [
[project.scripts]
litellm = "litellm:run_server"
lite = "litellm.proxy.client.cli:cli"
litellm-proxy = "litellm.proxy.client.cli:cli"
litellm-proxy = "litellm.proxy.client.cli:litellm_proxy_cli"
[dependency-groups]
dev = [

View file

@ -367,7 +367,7 @@ async def obtain_cli_sso_token_via_poll_flow(
models: list[str],
) -> str:
"""
Obtain a CLI SSO JWT through the same HTTP flow as `litellm-proxy login`:
Obtain a CLI SSO JWT through the same HTTP flow as `lite login`:
/sso/cli/start -> (SSO callback) -> /sso/cli/complete -> /sso/cli/poll.
When the proxy SSO session cache is not shared with the test runner (otel CI
@ -551,7 +551,7 @@ async def test_team_budget_enforcement():
@pytest.mark.asyncio
async def test_team_budget_enforcement_cli_sso_token():
"""
Team budget enforcement for CLI SSO session tokens (litellm-proxy login JWT).
Team budget enforcement for CLI SSO session tokens (lite login JWT).
1. Create team with a tiny max_budget and a user on that team
2. Obtain a CLI SSO JWT (HTTP poll flow when Redis is shared, else mint)

View file

@ -1,4 +1,4 @@
"""CLI tests for the ``litellm-proxy encryption migrate`` command.
"""CLI tests for the ``lite encryption migrate`` command.
The HTTP client is mocked, so these assert the command's request routing (GET
check vs POST migrate, dry-run param) and its residual-state messaging without a

View file

@ -1,7 +1,9 @@
# stdlib imports
import json
import os
import sys
from pathlib import Path
from typing import Final
from unittest.mock import Mock, patch
import pytest
@ -9,7 +11,8 @@ from click.testing import CliRunner
import litellm.proxy.client.cli
from litellm._version import version as litellm_version
from litellm.proxy.client.cli import cli
from litellm.proxy.client.cli import cli, litellm_proxy_cli
from litellm.proxy.client.cli.main import LITELLM_PROXY_DEPRECATION_NOTICE
@pytest.fixture
@ -234,3 +237,32 @@ def test_version_flag_never_sends_api_key_to_unnamed_server(cli_runner, isolated
assert all(url.startswith("https://flag-proxy.example.com") for url in requested_urls)
sent_keys = [call.kwargs["headers"].get("Authorization") for call in mock_request.call_args_list]
assert sent_keys == ["Bearer sk-intended-for-flag-proxy"] * len(requested_urls)
def test_litellm_proxy_entrypoint_prints_deprecation_notice_on_stderr_and_still_runs(monkeypatch, capsys, requests_mock):
requests_mock.get("http://localhost:4000/health/readiness", json={"litellm_version": "1.2.3"})
monkeypatch.setattr(sys, "argv", ["litellm-proxy", "--version"])
monkeypatch.setenv("LITELLM_PROXY_URL", "http://localhost:4000")
with pytest.raises(SystemExit) as exit_info:
litellm_proxy_cli()
captured: Final = capsys.readouterr()
assert exit_info.value.code == 0
assert captured.err.strip() == LITELLM_PROXY_DEPRECATION_NOTICE
assert f"LiteLLM Proxy CLI Version: {litellm_version}" in captured.out
assert "LiteLLM Proxy Server Version: 1.2.3" in captured.out
assert "deprecated" not in captured.out
def test_lite_entrypoint_prints_nothing_on_stderr(monkeypatch, capsys, requests_mock):
requests_mock.get("http://localhost:4000/health/readiness", json={"litellm_version": "1.2.3"})
monkeypatch.setattr(sys, "argv", ["lite", "--version"])
monkeypatch.setenv("LITELLM_PROXY_URL", "http://localhost:4000")
with pytest.raises(SystemExit) as exit_info:
cli()
captured: Final = capsys.readouterr()
assert exit_info.value.code == 0
assert "LiteLLM Proxy Server Version: 1.2.3" in captured.out
assert f"LiteLLM Proxy CLI Version: {litellm_version}" in captured.out
assert captured.err == ""

View file

@ -7,7 +7,8 @@ from typing import Final
from unittest.mock import AsyncMock, MagicMock, call
import pytest
from fastapi import HTTPException
from fastapi import FastAPI, HTTPException
from httpx import ASGITransport, AsyncClient
from pytest_mock import MockerFixture
from litellm.proxy._types import (
@ -31,6 +32,7 @@ from litellm.proxy.management_endpoints.scim.scim_v2 import (
_handle_group_membership_changes,
_handle_team_membership_changes,
_parse_member_entries,
_premium_user_check,
_process_group_patch_operations,
_recompute_scim_member_roles,
_resolve_group_member_ids,
@ -45,8 +47,10 @@ from litellm.proxy.management_endpoints.scim.scim_v2 import (
patch_group,
patch_team_membership,
patch_user,
scim_router,
update_group,
update_user,
user_api_key_auth,
)
from litellm.types.proxy.management_endpoints.scim_v2 import (
SCIM_ENTERPRISE_USER_SCHEMA,
@ -484,6 +488,48 @@ async def test_scim_create_user_respects_default_role_set_via_ui(mocker, monkeyp
)
@pytest.fixture
def scim_test_client():
"""An in-process SCIM application with authorization dependencies bypassed."""
app = FastAPI()
app.dependency_overrides[_premium_user_check] = lambda: None
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
app.include_router(scim_router)
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
@pytest.mark.asyncio
@pytest.mark.parametrize("endpoint", ["Users", "Groups"])
@pytest.mark.parametrize(("requested_count", "effective_count"), [(0, 0), (200, 100), (1000, 100)])
async def test_scim_collection_endpoints_clamp_requested_page_size(
scim_test_client, endpoint, requested_count, effective_count, mocker
):
"""SCIM list endpoints accept zero and cap larger client page requests."""
mock_prisma_client = MagicMock()
mock_prisma_client.db = MagicMock()
table = MagicMock()
table.find_many = AsyncMock(return_value=[])
table.count = AsyncMock(return_value=0)
mock_prisma_client.db.litellm_usertable = table
mock_prisma_client.db.litellm_teamtable = table
mocker.patch( # test-quality-ok: HTTP validation requires an in-memory database boundary.
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
AsyncMock(return_value=mock_prisma_client),
)
async with scim_test_client as client:
response = await client.get(f"/scim/v2/{endpoint}?startIndex=1&count={requested_count}")
assert response.status_code == 200
table.find_many.assert_awaited_once_with(
where={},
skip=0,
take=effective_count,
order={"created_at": "desc"},
)
assert response.json()["itemsPerPage"] == 0
@pytest.mark.asyncio
async def test_get_users_filters_username_by_exposed_scim_username_for_okta(mocker):
"""