diff --git a/.circleci/config.yml b/.circleci/config.yml
index b0a705966a2..2f01b6de4f3 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -2731,7 +2731,7 @@ jobs:
- ~/.cache/uv
- restore_cache:
keys:
- - ui-e2e-node-deps-v2-{{ checksum "ui/litellm-dashboard/package-lock.json" }}
+ - ui-e2e-node-deps-v3-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
- run:
name: Install Node dependencies and Playwright
# The cimg/python:3.12-browsers image already ships the Chromium system
@@ -2742,11 +2742,14 @@ jobs:
command: |
cd ui/litellm-dashboard
npm ci
+ cd ../../tests/e2e/ui
+ npm ci
npx playwright install chromium
- save_cache:
- key: ui-e2e-node-deps-v2-{{ checksum "ui/litellm-dashboard/package-lock.json" }}
+ key: ui-e2e-node-deps-v3-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
paths:
- ui/litellm-dashboard/node_modules
+ - tests/e2e/ui/node_modules
- ~/.cache/ms-playwright
- run:
name: Build UI from source
@@ -2777,10 +2780,10 @@ jobs:
name: Seed database
command: |
PGPASSWORD=e2epassword psql -h localhost -p 5432 -U e2euser -d litellm_e2e \
- -f ui/litellm-dashboard/e2e_tests/fixtures/seed.sql
+ -f tests/e2e/ui/fixtures/seed.sql
- run:
name: Start mock LLM server
- command: uv run --no-sync python ui/litellm-dashboard/e2e_tests/fixtures/mock_llm_server/server.py
+ command: uv run --no-sync python tests/e2e/ui/fixtures/mock_llm_server/server.py
background: true
- run:
name: Start LiteLLM proxy
@@ -2798,7 +2801,7 @@ jobs:
command: |
LITELLM_LICENSE="$LITELLM_LICENSE" \
uv run --no-sync python -m litellm.proxy.proxy_cli \
- --config ui/litellm-dashboard/e2e_tests/fixtures/config.yml \
+ --config tests/e2e/ui/fixtures/config.yml \
--port 4000
background: true
- run:
@@ -2819,15 +2822,15 @@ jobs:
# Forward LITELLM_LICENSE so license.spec.ts can detect that the
# proxy was launched with a license and assert premium_user=true.
command: |
- cd ui/litellm-dashboard
+ cd tests/e2e/ui
LITELLM_LICENSE="$LITELLM_LICENSE" \
- npx playwright test --config e2e_tests/playwright.config.ts
+ npx playwright test --config playwright.config.ts
no_output_timeout: 10m
- store_artifacts:
- path: ui/litellm-dashboard/test-results
+ path: tests/e2e/ui/test-results
destination: e2e-test-results
- store_artifacts:
- path: ui/litellm-dashboard/playwright-report
+ path: tests/e2e/ui/playwright-report
destination: e2e-playwright-report
e2e_ui_testing_server_root_path:
@@ -2870,17 +2873,20 @@ jobs:
- ~/.cache/uv
- restore_cache:
keys:
- - ui-e2e-node-deps-v2-{{ checksum "ui/litellm-dashboard/package-lock.json" }}
+ - ui-e2e-node-deps-v3-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
- run:
name: Install Node dependencies and Playwright
command: |
cd ui/litellm-dashboard
npm ci
+ cd ../../tests/e2e/ui
+ npm ci
npx playwright install chromium
- save_cache:
- key: ui-e2e-node-deps-v2-{{ checksum "ui/litellm-dashboard/package-lock.json" }}
+ key: ui-e2e-node-deps-v3-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
paths:
- ui/litellm-dashboard/node_modules
+ - tests/e2e/ui/node_modules
- ~/.cache/ms-playwright
- run:
name: Build UI from source
@@ -2902,10 +2908,10 @@ jobs:
name: Seed database
command: |
PGPASSWORD=e2epassword psql -h localhost -p 5432 -U e2euser -d litellm_e2e \
- -f ui/litellm-dashboard/e2e_tests/fixtures/seed.sql
+ -f tests/e2e/ui/fixtures/seed.sql
- run:
name: Start mock LLM server
- command: uv run --no-sync python ui/litellm-dashboard/e2e_tests/fixtures/mock_llm_server/server.py
+ command: uv run --no-sync python tests/e2e/ui/fixtures/mock_llm_server/server.py
background: true
- run:
name: Start LiteLLM proxy under a server root path
@@ -2918,7 +2924,7 @@ jobs:
command: |
LITELLM_LICENSE="$LITELLM_LICENSE" \
uv run --no-sync python -m litellm.proxy.proxy_cli \
- --config ui/litellm-dashboard/e2e_tests/fixtures/config.yml \
+ --config tests/e2e/ui/fixtures/config.yml \
--port 4000
background: true
- run:
@@ -2937,15 +2943,15 @@ jobs:
- run:
name: Run migration smoke under SERVER_ROOT_PATH
command: |
- cd ui/litellm-dashboard
+ cd tests/e2e/ui
LITELLM_LICENSE="$LITELLM_LICENSE" \
- npx playwright test --config e2e_tests/migration.serverRootPath.config.ts
+ npx playwright test --config migration.serverRootPath.config.ts
no_output_timeout: 10m
- store_artifacts:
- path: ui/litellm-dashboard/test-results
+ path: tests/e2e/ui/test-results
destination: e2e-server-root-path-test-results
- store_artifacts:
- path: ui/litellm-dashboard/playwright-report
+ path: tests/e2e/ui/playwright-report
destination: e2e-server-root-path-playwright-report
build_docker_database_image:
diff --git a/.circleci/scripts/classify_changes.sh b/.circleci/scripts/classify_changes.sh
index 2c15428be6a..2ca2654a207 100755
--- a/.circleci/scripts/classify_changes.sh
+++ b/.circleci/scripts/classify_changes.sh
@@ -8,7 +8,7 @@ has_backend=false
while IFS= read -r file || [ -n "$file" ]; do
[ -n "$file" ] || continue
case "$file" in
- ui/*) has_client=true ;;
+ ui/* | tests/e2e/ui/*) has_client=true ;;
docs/* | *.md | *.mdx) : ;;
*) has_backend=true ;;
esac
diff --git a/.github/workflows/test_server_root_path.yml b/.github/workflows/test_server_root_path.yml
index f59cee29893..01f70511e79 100644
--- a/.github/workflows/test_server_root_path.yml
+++ b/.github/workflows/test_server_root_path.yml
@@ -106,8 +106,8 @@ jobs:
with:
node-version: "20"
- - name: Install UI deps and Chromium
- working-directory: ui/litellm-dashboard
+ - name: Install e2e deps and Chromium
+ working-directory: tests/e2e/ui
run: |
retry() {
local attempt=1
@@ -131,17 +131,17 @@ jobs:
retry npx playwright install --with-deps chromium
- name: Run SERVER_ROOT_PATH redirect e2e
- working-directory: ui/litellm-dashboard
+ working-directory: tests/e2e/ui
env:
SERVER_ROOT_PATH: ${{ matrix.root_path }}
- run: npx playwright test --config=e2e_tests/serverRootPath.config.ts
+ run: npx playwright test --config=serverRootPath.config.ts
- name: Upload Playwright artifacts on failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-trace-${{ strategy.job-index }}
- path: ui/litellm-dashboard/test-results/
+ path: tests/e2e/ui/test-results/
retention-days: 7
- name: Cleanup
diff --git a/litellm/__init__.py b/litellm/__init__.py
index 2f6643c644c..55821012df9 100644
--- a/litellm/__init__.py
+++ b/litellm/__init__.py
@@ -427,7 +427,9 @@ default_team_settings: Optional[List] = None
max_user_budget: Optional[float] = None
default_max_internal_user_budget: Optional[float] = None
max_internal_user_budget: Optional[float] = None
-max_ui_session_budget: Optional[float] = 0.25 # $0.25 USD budgets for UI Chat sessions
+max_ui_session_budget: Optional[float] = (
+ 1.0 # USD budget for each dashboard login session (playground, test connection)
+)
internal_user_budget_duration: Optional[str] = None
tag_budget_config: Optional[Dict[str, "BudgetConfig"]] = None
max_end_user_budget: Optional[float] = None
diff --git a/litellm/constants.py b/litellm/constants.py
index 2af84c139a1..9f60c635249 100644
--- a/litellm/constants.py
+++ b/litellm/constants.py
@@ -264,6 +264,9 @@ MAX_REDIS_BUFFER_DEQUEUE_COUNT = int(os.getenv("MAX_REDIS_BUFFER_DEQUEUE_COUNT",
# Bounds asyncio.Queue() instances (log queues, spend update queues, etc.) to prevent unbounded memory growth
LITELLM_ASYNCIO_QUEUE_MAXSIZE = int(os.getenv("LITELLM_ASYNCIO_QUEUE_MAXSIZE", 1000))
TOOL_POLICY_CACHE_TTL_SECONDS = int(os.getenv("TOOL_POLICY_CACHE_TTL_SECONDS", 60))
+GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS = int(
+ os.getenv("GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS", 24 * 60 * 60)
+)
# Aggregation threshold: default to 80% of the asyncio queue maxsize so the check can always trigger.
# Must be < LITELLM_ASYNCIO_QUEUE_MAXSIZE; if set higher the aggregation logic will never fire.
MAX_SIZE_IN_MEMORY_QUEUE = int(os.getenv("MAX_SIZE_IN_MEMORY_QUEUE", int(LITELLM_ASYNCIO_QUEUE_MAXSIZE * 0.8)))
@@ -1525,6 +1528,7 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [
# test_general_settings_ui_fields_are_db_overridable enforces that pairing.
"enable_anthropic_prompt_caching",
"anthropic_prompt_caching_ttl",
+ "max_ui_session_budget",
]
SPECIAL_LITELLM_AUTH_TOKEN = ["ui-token"]
DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int(os.getenv("DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL", 60))
diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py
index 856556f7c56..cf9dafcb222 100644
--- a/litellm/integrations/custom_guardrail.py
+++ b/litellm/integrations/custom_guardrail.py
@@ -1,3 +1,4 @@
+import hashlib
import os
import secrets
from datetime import datetime
@@ -46,7 +47,10 @@ if TYPE_CHECKING:
dc = DualCache()
-from litellm.constants import PRE_CALL_EXECUTED_GUARDRAILS_KEY
+from litellm.constants import (
+ GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS,
+ PRE_CALL_EXECUTED_GUARDRAILS_KEY,
+)
from litellm.exceptions import (
BlockedPiiEntityError,
GuardrailRaisedException,
@@ -113,6 +117,7 @@ class CustomGuardrail(CustomLogger):
on_sensitive_data: Optional[str] = None,
sensitive_data_route_to_model: Optional[str] = None,
sticky_session_routing: bool = True,
+ only_scan_new_messages: bool = False,
**kwargs,
):
"""
@@ -145,6 +150,7 @@ class CustomGuardrail(CustomLogger):
self.on_sensitive_data: Optional[str] = on_sensitive_data
self.sensitive_data_route_to_model: Optional[str] = sensitive_data_route_to_model
self.sticky_session_routing: bool = sticky_session_routing
+ self.only_scan_new_messages: bool = only_scan_new_messages
if supported_event_hooks:
## validate event_hook is in supported_event_hooks
@@ -269,6 +275,100 @@ class CustomGuardrail(CustomLogger):
"""Extract session_id from request data."""
return get_session_id_from_request_data(request_data)
+ @staticmethod
+ def _scanned_text_hash(text: str) -> str:
+ """Stable content hash for a single scannable text segment.
+
+ Hashing the exact text the provider would receive means an edited earlier
+ segment produces a different hash and gets re-scanned, while an unchanged
+ segment repeated on a later turn is skipped.
+ """
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
+
+ def _scanned_texts_cache_key(self, session_id: str) -> str:
+ return f"guardrail_scanned_texts:{self.guardrail_name}:{session_id}"
+
+ async def filter_new_texts_for_session(
+ self,
+ texts: list[str] | None,
+ request_data: dict[str, object],
+ cache: DualCache,
+ ) -> list[str] | None:
+ """Return only the text segments not already scanned earlier in this session.
+
+ Returns ``None`` when incremental scanning is inactive (feature off, no
+ session id, masking enabled, or the cache read failed). ``None`` signals
+ the caller to fall back to a full scan; a returned list (possibly empty)
+ signals the caller to scan only that subset and skip masking write-back.
+ """
+ if not self.only_scan_new_messages or not texts:
+ return None
+
+ if self.mask_request_content or self.mask_response_content:
+ verbose_logger.warning(
+ "Guardrail %s: only_scan_new_messages is not supported with masking; scanning full context.",
+ self.guardrail_name,
+ )
+ return None
+
+ session_id = get_session_id_from_request_data(request_data)
+ if not session_id:
+ verbose_logger.debug(
+ "Guardrail %s: only_scan_new_messages enabled but request has no session id; scanning full context.",
+ self.guardrail_name,
+ )
+ return None
+
+ try:
+ cached: object = await cache.async_get_cache(key=self._scanned_texts_cache_key(session_id))
+ except Exception as e: # noqa: BLE001 # cache is best-effort; any failure must fall back to a full scan
+ verbose_logger.warning(
+ "Guardrail %s: failed to read scanned-message cache (%s); scanning full context.",
+ self.guardrail_name,
+ e,
+ )
+ return None
+
+ seen: set[str] = {str(h) for h in cached} if isinstance(cached, list) else set()
+ return [text for text in texts if self._scanned_text_hash(text) not in seen]
+
+ async def mark_texts_scanned(
+ self,
+ texts: list[str] | None,
+ request_data: dict[str, object],
+ cache: DualCache,
+ ) -> None:
+ """Record the hashes of all text segments present on a successful (non-blocked) scan.
+
+ Called only after the guardrail allows the request, so a blocked segment is
+ never marked scanned and will be re-checked if the client retries.
+ """
+ if not self.only_scan_new_messages or not texts:
+ return
+ if self.mask_request_content or self.mask_response_content:
+ return
+ session_id = get_session_id_from_request_data(request_data)
+ if not session_id:
+ return
+
+ cache_key = self._scanned_texts_cache_key(session_id)
+ current_hashes = [self._scanned_text_hash(text) for text in texts]
+ try:
+ existing: object = await cache.async_get_cache(key=cache_key)
+ existing_hashes: list[str] = [str(h) for h in existing] if isinstance(existing, list) else []
+ merged: list[str] = list(dict.fromkeys(existing_hashes + current_hashes))
+ await cache.async_set_cache(
+ key=cache_key,
+ value=merged,
+ ttl=GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS,
+ )
+ except Exception as e: # noqa: BLE001 # cache is best-effort; any failure must not block the request
+ verbose_logger.warning(
+ "Guardrail %s: failed to persist scanned-message cache (%s); next call will re-scan.",
+ self.guardrail_name,
+ e,
+ )
+
def should_route_on_sensitive_data(self) -> bool:
"""
Returns True if this guardrail is configured to route requests
diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py
index 54156715da8..cec682d772a 100644
--- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py
+++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py
@@ -2046,6 +2046,90 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
masking_index += 1
verbose_proxy_logger.debug("Applied masking to choice text content")
+ @staticmethod
+ def _incremental_scan_cache() -> DualCache:
+ """Resolve the cache used to remember which segments a session already scanned.
+
+ Prefers the proxy's shared cache (``internal_usage_cache.dual_cache``), which is
+ backed by Redis when the deployment configures it, so incremental state is shared
+ across proxy instances. Falls back to a process-local ``DualCache`` singleton when
+ the proxy is not running (e.g. unit tests), where sharing does not apply.
+ """
+ from litellm.integrations.custom_guardrail import dc as fallback_cache
+
+ try:
+ from litellm.proxy.proxy_server import proxy_logging_obj as _proxy_logging
+ except Exception: # noqa: BLE001 # proxy not importable outside the server; use local fallback
+ return fallback_cache
+ if _proxy_logging is not None:
+ return _proxy_logging.internal_usage_cache.dual_cache
+ return fallback_cache
+
+ def _bedrock_response_has_masked_output(self, response: BedrockGuardrailResponse) -> bool:
+ """Return True if the guardrail rewrote (masked/anonymized) any scanned text.
+
+ Bedrock returns non-empty ``output``/``outputs`` text only when it changed the
+ content; an ``action == "NONE"`` response leaves both empty.
+ """
+ for field in ("output", "outputs"):
+ items = response.get(field) or []
+ if any(isinstance(item, dict) and item.get("text") for item in items):
+ return True
+ return False
+
+ async def _apply_incremental_request_scan(
+ self,
+ texts: list[str],
+ inputs: "GenericGuardrailAPIInputs",
+ request_data: dict,
+ ) -> Optional["GenericGuardrailAPIInputs"]:
+ """Scan only the text segments not already seen earlier in this session.
+
+ Returns ``None`` when incremental scanning is inactive (feature off, no
+ session id, masking enabled, or cache unavailable) or when the guardrail
+ turns out to mask content, telling the caller to run the normal full scan.
+ Otherwise scans only the new segments and skips the Bedrock call entirely
+ when nothing is new. Incremental mode is for blocking/detection guardrails
+ only: if the guardrail returns masked output it cannot be applied to the
+ skipped context, so the scan falls back to the full path and no session
+ state is recorded.
+ """
+ cache = self._incremental_scan_cache()
+
+ new_texts = await self.filter_new_texts_for_session(
+ texts=texts,
+ request_data=request_data,
+ cache=cache,
+ )
+ if new_texts is None:
+ return None
+
+ if not new_texts:
+ verbose_proxy_logger.debug("Bedrock Guardrail: no new messages to scan for this session, skipping API call")
+ return inputs
+
+ bedrock_response = await self.make_bedrock_api_request(
+ source="INPUT",
+ messages=[ChatCompletionUserMessage(role="user", content=text) for text in new_texts],
+ request_data=request_data,
+ logging_event_type=GuardrailEventHooks.pre_call,
+ )
+
+ if self._bedrock_response_has_masked_output(bedrock_response):
+ verbose_proxy_logger.warning(
+ "Bedrock Guardrail %s: guardrail returned masked/anonymized content; "
+ "only_scan_new_messages cannot apply masking to skipped context, falling back to a full-context scan",
+ self.guardrail_name,
+ )
+ return None
+
+ await self.mark_texts_scanned(
+ texts=texts,
+ request_data=request_data,
+ cache=cache,
+ )
+ return inputs
+
async def apply_guardrail(
self,
inputs: "GenericGuardrailAPIInputs",
@@ -2077,6 +2161,15 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
try:
verbose_proxy_logger.debug(f"Bedrock Guardrail: Applying guardrail to {len(texts)} text(s)")
+ if input_type == "request":
+ incremental_result = await self._apply_incremental_request_scan(
+ texts=texts,
+ inputs=inputs,
+ request_data=request_data,
+ )
+ if incremental_result is not None:
+ return incremental_result
+
masked_texts = []
selection = self._select_messages_for_apply_guardrail(
diff --git a/litellm/proxy/guardrails/guardrail_initializers.py b/litellm/proxy/guardrails/guardrail_initializers.py
index 14e76a21093..e909c15382b 100644
--- a/litellm/proxy/guardrails/guardrail_initializers.py
+++ b/litellm/proxy/guardrails/guardrail_initializers.py
@@ -35,6 +35,7 @@ def initialize_bedrock(litellm_params: LitellmParams, guardrail: Guardrail):
aws_sts_endpoint=litellm_params.aws_sts_endpoint,
aws_bedrock_runtime_endpoint=litellm_params.aws_bedrock_runtime_endpoint,
experimental_use_latest_role_message_only=litellm_params.experimental_use_latest_role_message_only,
+ only_scan_new_messages=litellm_params.only_scan_new_messages or False,
)
litellm.logging_callback_manager.add_litellm_callback(_bedrock_callback)
return _bedrock_callback
diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py
index fa123b7d76c..db2cf2b70dd 100644
--- a/litellm/proxy/management_endpoints/scim/scim_v2.py
+++ b/litellm/proxy/management_endpoints/scim/scim_v2.py
@@ -98,14 +98,27 @@ class UserProvisionerHelpers:
if not existing_user:
return None
- # Update the user
+ new_teams = list(dict.fromkeys(new_user_request.teams or []))
+
+ if new_user_request.user_id != existing_user.user_id:
+ await UserRepository(prisma_client).table.update(
+ where={"user_id": existing_user.user_id},
+ data={"user_id": new_user_request.user_id},
+ )
+
+ await _handle_team_membership_changes(
+ user_id=new_user_request.user_id,
+ existing_teams=existing_user.teams or [],
+ new_teams=new_teams,
+ raise_on_error=True,
+ )
+
updated_user = await UserRepository(prisma_client).table.update(
- where={"user_id": existing_user.user_id},
+ where={"user_id": new_user_request.user_id},
data={
- "user_id": new_user_request.user_id,
"user_email": new_user_request.user_email,
"user_alias": new_user_request.user_alias,
- "teams": new_user_request.teams,
+ "teams": new_teams,
"metadata": safe_dumps(new_user_request.metadata),
**({"user_role": new_user_request.user_role} if admin_group is not None else {}),
},
@@ -440,7 +453,12 @@ async def _get_team_members_display(member_ids: List[str]) -> List[SCIMMember]:
return members
-async def _handle_team_membership_changes(user_id: str, existing_teams: List[str], new_teams: List[str]) -> None:
+async def _handle_team_membership_changes(
+ user_id: str,
+ existing_teams: List[str],
+ new_teams: List[str],
+ raise_on_error: bool = False,
+) -> None:
"""Handle adding/removing user from teams based on changes."""
existing_teams_set = set(existing_teams)
new_teams_set = set(new_teams)
@@ -453,6 +471,7 @@ async def _handle_team_membership_changes(user_id: str, existing_teams: List[str
user_id=user_id,
teams_ids_to_add_user_to=list(teams_to_add),
teams_ids_to_remove_user_from=list(teams_to_remove),
+ raise_on_error=raise_on_error,
)
@@ -1497,16 +1516,29 @@ def _apply_patch_ops(
return update_data, final_team_set
+def _is_user_not_in_team_error(exc: HTTPException) -> bool:
+ """True when team_member_delete reports the user was already absent from the
+ team, which is the idempotent no-op case for a removal."""
+ detail = exc.detail
+ return isinstance(detail, dict) and detail.get("error") == "User not found in team"
+
+
async def patch_team_membership(
user_id: str,
teams_ids_to_add_user_to: List[str],
teams_ids_to_remove_user_from: List[str],
+ raise_on_error: bool = False,
) -> bool:
"""
Add or remove user from teams
Handles duplicate membership gracefully (idempotent operation).
- If a user is already in a team, that's fine - we don't treat it as an error.
+ A user already being in a team (on add) or already absent from it (on
+ remove) is treated as a no-op, not an error.
+
+ When ``raise_on_error`` is True a genuine add or remove failure (anything
+ other than those idempotent no-ops) propagates instead of being swallowed,
+ so a caller can avoid persisting a teams array the roster never received.
"""
for _team_id in teams_ids_to_add_user_to:
try:
@@ -1521,9 +1553,13 @@ async def patch_team_membership(
# Handle duplicate membership gracefully - this is idempotent
if e.type == ProxyErrorTypes.team_member_already_in_team:
verbose_proxy_logger.debug(f"User {user_id} is already in team {_team_id}, skipping add")
+ elif raise_on_error:
+ raise
else:
verbose_proxy_logger.exception(f"Error adding user to team {_team_id}: {e}")
except Exception as e:
+ if raise_on_error:
+ raise
verbose_proxy_logger.exception(f"Error adding user to team {_team_id}: {e}")
for _team_id in teams_ids_to_remove_user_from:
@@ -1532,7 +1568,16 @@ async def patch_team_membership(
data=TeamMemberDeleteRequest(team_id=_team_id, user_id=user_id),
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
)
+ except HTTPException as e:
+ if _is_user_not_in_team_error(e):
+ verbose_proxy_logger.debug(f"User {user_id} is not in team {_team_id}, skipping remove")
+ elif raise_on_error:
+ raise
+ else:
+ verbose_proxy_logger.exception(f"Error removing user from team {_team_id}: {e}")
except Exception as e:
+ if raise_on_error:
+ raise
verbose_proxy_logger.exception(f"Error removing user from team {_team_id}: {e}")
return True
@@ -1654,8 +1699,11 @@ async def get_groups(
# Convert to SCIM format
scim_groups = []
for team in teams:
- # Get team members with display names
- members = await _get_team_members_display(team.members or [])
+ # Get team members with display names. members_with_roles is the
+ # source of truth; the legacy `members` column is not populated by
+ # team creation, so reading it here would report an empty member
+ # list to the IdP and trigger repeated re-provisioning.
+ members = await _get_team_members_display(await _get_team_member_user_ids_from_team(team))
verbose_proxy_logger.debug(f"SCIM GET GROUPS members: {members}")
team_alias = getattr(team, "team_alias", team.team_id)
team_created_at = team.created_at.isoformat() if team.created_at else None
@@ -1885,8 +1933,12 @@ async def _process_group_patch_operations(
existing_metadata = existing_team.metadata or {}
metadata = dict(existing_metadata) if existing_metadata else {}
- # Track member changes
- current_members = set(existing_team.members or [])
+ # Track member changes. members_with_roles is the source of truth for team
+ # membership; the legacy `members` column is not populated by team creation
+ # or the real team endpoints, so seeding from it would make an `add`/`remove`
+ # operation recompute the member set from an empty base and silently drop
+ # everyone already in the team.
+ current_members = set(await _get_team_member_user_ids_from_team(existing_team))
final_members = current_members.copy()
# Process each patch operation
@@ -1963,24 +2015,24 @@ async def _process_group_patch_operations(
return update_data, final_members
-async def _apply_group_patch_updates(
- group_id: str, update_data: Dict[str, Any], final_members: Set[str], prisma_client
-):
- """Apply patch updates to the group in the database."""
- # Serialize metadata if present
+async def _apply_group_patch_updates(group_id: str, update_data: Dict[str, Any], prisma_client):
+ """Apply the group's metadata/displayName patch updates to the database.
+
+ Membership itself is not written here; it is reconciled onto the source of
+ truth (members_with_roles and each member's user.teams) by
+ _handle_group_membership_changes via team_member_add/team_member_delete.
+ Writing the legacy `members` column here too would create a second, unread
+ copy of membership that could drift from the source of truth.
+ """
if "metadata" in update_data and isinstance(update_data["metadata"], dict):
update_data["metadata"] = safe_dumps(update_data["metadata"])
- # Update members list
- update_data["members"] = list(final_members)
-
- # Update team in database
- updated_team = await TeamRepository(prisma_client).table.update(
- where={"team_id": group_id},
- data=update_data,
- )
-
- return updated_team
+ if update_data:
+ return await TeamRepository(prisma_client).table.update(
+ where={"team_id": group_id},
+ data=update_data,
+ )
+ return await TeamRepository(prisma_client).table.find_unique(where={"team_id": group_id})
async def _handle_group_membership_changes(group_id: str, current_members: Set[str], final_members: Set[str]):
@@ -2036,8 +2088,8 @@ async def patch_group(
# Track current members BEFORE update for comparison
current_members = set(await _get_team_member_user_ids_from_team(existing_team))
- # Apply updates to the database
- updated_team = await _apply_group_patch_updates(group_id, update_data, final_members, prisma_client)
+ # Apply the metadata/displayName updates to the database
+ updated_team = await _apply_group_patch_updates(group_id, update_data, prisma_client)
# Refresh team data from database to get the latest state after concurrent updates
# This prevents race conditions when multiple PATCH requests come in simultaneously
diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py
index 3c8444ecf26..de988a0140f 100644
--- a/litellm/proxy/management_endpoints/ui_sso.py
+++ b/litellm/proxy/management_endpoints/ui_sso.py
@@ -12,6 +12,7 @@ import asyncio
import base64
import hashlib
import inspect
+import json
import os
import re
import secrets
@@ -258,11 +259,20 @@ def _get_cli_sso_flow_or_raise(login_id: Optional[str], cache: DualCache) -> dic
raise HTTPException(status_code=400, detail="Invalid CLI login session id")
cache_key = _get_cli_sso_flow_cache_key(cast(str, login_id))
- flow = cache.get_cache(key=cache_key)
+ redis_cache = cache.redis_cache
+ if redis_cache is not None:
+ flow = redis_cache.get_cache(key=cache_key)
+ else:
+ flow = cache.get_cache(key=cache_key)
+ if isinstance(flow, str):
+ try:
+ flow = json.loads(flow)
+ except ValueError:
+ flow = None
if not isinstance(flow, dict) or "poll_secret_hash" not in flow:
verbose_proxy_logger.warning(
"CLI SSO login session not found in cache for login_id=%s. If the proxy runs multiple replicas, "
- "a shared Redis cache (enable_redis_auth_cache: true) is required for CLI login to work.",
+ "a shared Redis cache is required for CLI login to work.",
login_id,
)
raise HTTPException(
@@ -270,7 +280,7 @@ def _get_cli_sso_flow_or_raise(login_id: Optional[str], cache: DualCache) -> dic
detail=(
"CLI login session not found or expired. Run `litellm-proxy login` again. "
"If this happens immediately after starting a login, the proxy is likely running multiple "
- "replicas without a shared cache; configure Redis with `enable_redis_auth_cache: true` "
+ "replicas without a shared cache; configure a Redis cache "
"so every replica can see the login session."
),
)
@@ -278,11 +288,12 @@ def _get_cli_sso_flow_or_raise(login_id: Optional[str], cache: DualCache) -> dic
def _set_cli_sso_flow(login_id: str, cache: DualCache, flow: dict) -> None:
- cache.set_cache(
- key=_get_cli_sso_flow_cache_key(login_id),
- value=flow,
- ttl=CLI_SSO_SESSION_TTL_SECONDS,
- )
+ cache_key = _get_cli_sso_flow_cache_key(login_id)
+ redis_cache = cache.redis_cache
+ if redis_cache is not None:
+ redis_cache.set_cache(key=cache_key, value=json.dumps(flow), ttl=CLI_SSO_SESSION_TTL_SECONDS)
+ else:
+ cache.set_cache(key=cache_key, value=flow, ttl=CLI_SSO_SESSION_TTL_SECONDS)
def _verify_cli_sso_poll_secret(flow: dict, poll_secret: Optional[str]) -> bool:
@@ -593,11 +604,11 @@ def _render_cli_sso_verification_page(
@router.post("/sso/cli/start", tags=["experimental"], include_in_schema=False)
async def cli_sso_start(request: Request):
- from litellm.proxy.proxy_server import general_settings, user_api_key_cache
+ from litellm.proxy.proxy_server import cli_sso_session_cache, general_settings
_check_cli_sso_start_rate_limit(
request=request,
- cache=user_api_key_cache,
+ cache=cli_sso_session_cache,
use_x_forwarded_for=bool((general_settings or {}).get("use_x_forwarded_for", False)),
)
@@ -612,7 +623,7 @@ async def cli_sso_start(request: Request):
"user_code_verified": False,
"session_data": None,
}
- _set_cli_sso_flow(login_id=login_id, cache=user_api_key_cache, flow=flow)
+ _set_cli_sso_flow(login_id=login_id, cache=cli_sso_session_cache, flow=flow)
verification_uri_complete: str | None = (
(
@@ -644,9 +655,9 @@ async def cli_sso_complete(request: Request, login_id: str):
from litellm.proxy.common_utils.html_forms.cli_sso_success import (
render_cli_sso_success_page,
)
- from litellm.proxy.proxy_server import user_api_key_cache
+ from litellm.proxy.proxy_server import cli_sso_session_cache
- flow = _get_cli_sso_flow_or_raise(login_id=login_id, cache=user_api_key_cache)
+ flow = _get_cli_sso_flow_or_raise(login_id=login_id, cache=cli_sso_session_cache)
if not flow.get("sso_complete") or not flow.get("session_data"):
raise HTTPException(status_code=400, detail="CLI login is not ready")
@@ -670,7 +681,7 @@ async def cli_sso_complete(request: Request, login_id: str):
raise HTTPException(status_code=400, detail="Invalid verification code")
flow["user_code_verified"] = True
- _set_cli_sso_flow(login_id=login_id, cache=user_api_key_cache, flow=flow)
+ _set_cli_sso_flow(login_id=login_id, cache=cli_sso_session_cache, flow=flow)
html_content = render_cli_sso_success_page()
return HTMLResponse(content=html_content, status_code=200)
@@ -861,10 +872,10 @@ async def google_login(
Example:
"""
from litellm.proxy.proxy_server import (
+ cli_sso_session_cache,
general_settings,
premium_user,
prisma_client,
- user_api_key_cache,
user_custom_ui_sso_sign_in_handler,
)
@@ -912,7 +923,7 @@ async def google_login(
)
if source == LITELLM_CLI_SOURCE_IDENTIFIER:
- _get_cli_sso_flow_or_raise(login_id=key, cache=user_api_key_cache)
+ _get_cli_sso_flow_or_raise(login_id=key, cache=cli_sso_session_cache)
# Store CLI login handle in state for OAuth flow
cli_state: Optional[str] = SSOAuthenticationHandler._get_cli_state(
@@ -1957,6 +1968,7 @@ async def _complete_cli_sso_callback_session(
user_defined_values: Optional[SSOUserDefinedValues],
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
+ cli_sso_session_cache: DualCache,
proxy_logging_obj: ProxyLogging,
prefill_user_code: str | None = None,
sso_assertion: SSOIdentityAssertion | None = None,
@@ -2006,7 +2018,7 @@ async def _complete_cli_sso_callback_session(
flow["sso_complete"] = True
browser_complete_token = secrets.token_urlsafe(32)
flow["browser_complete_token_hash"] = _hash_cli_sso_secret(browser_complete_token)
- _set_cli_sso_flow(login_id=key, cache=user_api_key_cache, flow=flow)
+ _set_cli_sso_flow(login_id=key, cache=cli_sso_session_cache, flow=flow)
verbose_proxy_logger.info(
f"Stored CLI SSO session for user: {user_info.user_id}, teams: {teams}, num_teams: {len(teams)}"
@@ -2037,13 +2049,14 @@ async def cli_sso_callback(
verbose_proxy_logger.info("CLI SSO callback")
from litellm.proxy.proxy_server import (
+ cli_sso_session_cache,
general_settings,
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
- flow = _get_cli_sso_flow_or_raise(login_id=key, cache=user_api_key_cache)
+ flow = _get_cli_sso_flow_or_raise(login_id=key, cache=cli_sso_session_cache)
if prisma_client is None:
raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
@@ -2083,6 +2096,7 @@ async def cli_sso_callback(
user_defined_values=user_defined_values,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
+ cli_sso_session_cache=cli_sso_session_cache,
proxy_logging_obj=proxy_logging_obj,
prefill_user_code=prefill_user_code,
sso_assertion=sso_assertion,
@@ -2114,10 +2128,10 @@ async def cli_poll_key(
team_id: Optional team ID to assign to the JWT. If provided, must be one of user's teams.
"""
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
- from litellm.proxy.proxy_server import user_api_key_cache
+ from litellm.proxy.proxy_server import cli_sso_session_cache
try:
- flow = _get_cli_sso_flow_or_raise(login_id=key_id, cache=user_api_key_cache)
+ flow = _get_cli_sso_flow_or_raise(login_id=key_id, cache=cli_sso_session_cache)
if not _verify_cli_sso_poll_secret(flow=flow, poll_secret=x_litellm_cli_poll_secret):
raise HTTPException(status_code=403, detail="Invalid CLI polling secret")
@@ -2192,7 +2206,7 @@ async def cli_poll_key(
)
# Delete cache entry (single-use)
- user_api_key_cache.delete_cache(key=_get_cli_sso_flow_cache_key(key_id))
+ cli_sso_session_cache.delete_cache(key=_get_cli_sso_flow_cache_key(key_id))
verbose_proxy_logger.info(f"CLI JWT generated for user: {user_id}, team: {team_id}")
poll_response = {
diff --git a/litellm/proxy/management_helpers/utils.py b/litellm/proxy/management_helpers/utils.py
index 11a99caebf5..86beb063667 100644
--- a/litellm/proxy/management_helpers/utils.py
+++ b/litellm/proxy/management_helpers/utils.py
@@ -252,6 +252,21 @@ async def _resolve_member_budget_id(
return response.budget_id
+async def _append_team_id_if_absent(prisma_client: PrismaClient, user_id: str, team_id: str) -> None:
+ """Append team_id to a user's teams array, only if it is not already present.
+
+ The row-level filter makes the append a no-op once the team is present, so
+ repeated or concurrent adds of the same team cannot accumulate duplicate
+ team ids in user.teams (a duplicate also breaks auth logic that keys off the
+ number of teams a user belongs to). Teams added concurrently for a different
+ team id are unaffected, since each update filters on its own team id.
+ """
+ await UserRepository(prisma_client).table.update_many(
+ where={"user_id": user_id, "NOT": {"teams": {"has": team_id}}},
+ data={"teams": {"push": [team_id]}},
+ )
+
+
async def add_new_member(
new_member: Member,
max_budget_in_team: Optional[float],
@@ -276,13 +291,16 @@ async def add_new_member(
## ADD TEAM ID, to USER TABLE IF NEW ##
if new_member.user_id is not None:
new_user_defaults = get_new_internal_user_defaults(user_id=new_member.user_id)
+ # Upsert ensures the user row exists atomically (no create race when the
+ # same new user is provisioned concurrently), seeding teams on create.
+ # The teams append lives in the filtered update below rather than the
+ # upsert's update branch so an already-existing user does not get a
+ # duplicate team id.
_returned_user = await UserRepository(prisma_client).table.upsert(
where={"user_id": new_member.user_id},
- data={
- "update": {"teams": {"push": [team_id]}},
- "create": {"teams": [team_id], **new_user_defaults}, # type: ignore
- },
+ data={"create": {"teams": [team_id], **new_user_defaults}, "update": {}},
)
+ await _append_team_id_if_absent(prisma_client, new_member.user_id, team_id)
if _returned_user is not None:
returned_user = LiteLLM_UserTable(**_returned_user.model_dump())
elif new_member.user_email is not None:
@@ -302,12 +320,8 @@ async def add_new_member(
returned_user = LiteLLM_UserTable(**_returned_user.model_dump())
elif len(existing_user_row) == 1:
user_info = existing_user_row[0]
- _returned_user = await UserRepository(prisma_client).table.update(
- where={"user_id": user_info.user_id}, # type: ignore
- data={"teams": {"push": [team_id]}},
- )
- if _returned_user is not None:
- returned_user = LiteLLM_UserTable(**_returned_user.model_dump())
+ await _append_team_id_if_absent(prisma_client, user_info.user_id, team_id)
+ returned_user = LiteLLM_UserTable(**user_info.model_dump())
elif len(existing_user_row) > 1:
raise HTTPException(
status_code=400,
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index 6de3e43fc1a..7677ae51f9a 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -226,6 +226,7 @@ from litellm.constants import (
APSCHEDULER_MAX_INSTANCES,
APSCHEDULER_MISFIRE_GRACE_TIME,
APSCHEDULER_REPLACE_EXISTING,
+ CLI_SSO_SESSION_TTL_SECONDS,
DAYS_IN_A_MONTH,
DEFAULT_HEALTH_CHECK_INTERVAL,
DEFAULT_MODEL_CREATED_AT_TIME,
@@ -1970,6 +1971,7 @@ user_api_key_cache: UserApiKeyCache = UserApiKeyCache(
default_in_memory_ttl=UserAPIKeyCacheTTLEnum.in_memory_cache_ttl.value
)
spend_counter_cache = DualCache(default_in_memory_ttl=UserAPIKeyCacheTTLEnum.in_memory_cache_ttl.value)
+cli_sso_session_cache = DualCache(default_in_memory_ttl=CLI_SSO_SESSION_TTL_SECONDS)
model_max_budget_limiter = _PROXY_VirtualKeyModelMaxBudgetLimiter(dual_cache=user_api_key_cache)
litellm.logging_callback_manager.add_litellm_callback(model_max_budget_limiter)
redis_usage_cache: Optional[RedisCache] = None # redis cache used for tracking spend, tpm/rpm limits
@@ -3696,13 +3698,22 @@ def _build_redis_usage_cache_from_environment() -> RedisCache | None:
def _attach_redis_usage_cache(redis_cache: RedisCache, enable_redis_auth_cache: bool) -> None:
"""
Wires an established coordination Redis into the proxy-level caches that
- consume it directly: the spend counter cache, the cluster-wide config
- cache, and (only when opted in) the virtual-key auth cache.
+ consume it directly: the spend counter cache, the CLI SSO login-session
+ cache, the cluster-wide config cache, and (only when opted in) the
+ virtual-key auth cache.
+
+ The CLI SSO login-session cache is always backed by Redis when available so
+ that the browser SSO flow behind `lite login` survives landing on different
+ workers; it must not be gated behind enable_redis_auth_cache.
"""
spend_counter_cache.attach_redis_cache(
redis_cache,
default_redis_ttl=litellm.default_redis_ttl,
)
+ cli_sso_session_cache.attach_redis_cache(
+ redis_cache,
+ default_redis_ttl=CLI_SSO_SESSION_TTL_SECONDS,
+ )
if enable_redis_auth_cache is True:
user_api_key_cache.attach_redis_cache(
redis_cache,
@@ -4618,6 +4629,11 @@ class ProxyConfig:
verbose_proxy_logger.debug(
f"{blue_color_code} Initialized polling via cache: enabled={polling_via_cache_enabled}, native_background_mode={native_background_mode}, ttl={polling_cache_ttl}{reset_color_code}"
)
+ elif key == "max_ui_session_budget":
+ litellm.max_ui_session_budget = float(value) if value is not None else None
+ verbose_proxy_logger.debug(
+ f"{blue_color_code} setting litellm.max_ui_session_budget={litellm.max_ui_session_budget}{reset_color_code}"
+ )
elif key == "default_team_settings":
for idx, team_setting in enumerate(value): # run through pydantic validation
try:
@@ -14914,10 +14930,11 @@ GeneralSettingsUILiteLLMValue = Union[float, bool, str, None]
class GeneralSettingsUILiteLLMFieldSpec(TypedDict):
- type: Literal["Float", "Boolean", "Select"]
+ type: Literal["Float", "Dollar", "Boolean", "Select"]
description: str
options: NotRequired[tuple[str, ...]]
tab: NotRequired[str] # Admin UI sub-tab this field renders under; None groups it with the rest
+ default: NotRequired[float] # reset/clear restores this instead of None; fields whose None means fail-open set it
_GENERAL_SETTINGS_UI_LITELLM_FIELDS: dict[str, GeneralSettingsUILiteLLMFieldSpec] = {
@@ -14943,21 +14960,32 @@ _GENERAL_SETTINGS_UI_LITELLM_FIELDS: dict[str, GeneralSettingsUILiteLLMFieldSpec
"tab": "prompt_caching",
"description": "Empty uses Anthropic's 5m default. 1h suits long sessions but doubles the cache write cost.",
},
+ "max_ui_session_budget": {
+ "type": "Dollar",
+ "default": 1.0,
+ "description": (
+ "USD spend cap for each dashboard login session; covers LLM calls made from the dashboard "
+ "such as the playground and auto router Test Connection. Each login starts a fresh session "
+ "with this budget. Clearing restores the $1 default."
+ ),
+ },
}
def _general_settings_ui_litellm_default(
- field_type: Literal["Float", "Boolean", "Select"],
+ spec: GeneralSettingsUILiteLLMFieldSpec,
) -> GeneralSettingsUILiteLLMValue:
"""The value a field falls back to when it is cleared or reset."""
- return False if field_type == "Boolean" else None
+ if "default" in spec:
+ return spec["default"]
+ return False if spec["type"] == "Boolean" else None
def _validate_general_settings_ui_litellm_value(field_name: str, value: Any) -> GeneralSettingsUILiteLLMValue:
spec = _GENERAL_SETTINGS_UI_LITELLM_FIELDS[field_name]
field_type = spec["type"]
if value is None or value == "":
- return _general_settings_ui_litellm_default(field_type)
+ return _general_settings_ui_litellm_default(spec)
match field_type:
case "Boolean":
if not isinstance(value, bool):
@@ -14981,6 +15009,13 @@ def _validate_general_settings_ui_litellm_value(field_name: str, value: Any) ->
detail={"error": f"{field_name} must be a number in (0, 1] or empty"},
)
return float(value)
+ case "Dollar":
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or float(value) <= 0:
+ raise HTTPException(
+ status_code=400,
+ detail={"error": f"{field_name} must be a positive dollar amount or empty"},
+ )
+ return float(value)
case _:
assert_never(field_type)
@@ -15003,7 +15038,7 @@ async def _persist_general_settings_ui_litellm_field(
async def _reset_general_settings_ui_litellm_field(field_name: str, user_api_key_dict: UserAPIKeyAuth) -> dict:
config = await proxy_config.get_config()
before_value = config.get("litellm_settings", {}).get(field_name)
- default_value = _general_settings_ui_litellm_default(_GENERAL_SETTINGS_UI_LITELLM_FIELDS[field_name]["type"])
+ default_value = _general_settings_ui_litellm_default(_GENERAL_SETTINGS_UI_LITELLM_FIELDS[field_name])
setattr(litellm, field_name, default_value)
if "litellm_settings" in config:
config["litellm_settings"].pop(field_name, None)
@@ -15178,7 +15213,7 @@ async def get_config_list(
)
for litellm_field_name, spec in _GENERAL_SETTINGS_UI_LITELLM_FIELDS.items():
current_value: GeneralSettingsUILiteLLMValue = getattr(litellm, litellm_field_name, None)
- default_value = _general_settings_ui_litellm_default(spec["type"])
+ default_value = _general_settings_ui_litellm_default(spec)
stored_in_db_litellm: Optional[bool]
if litellm_field_name in db_litellm_settings:
stored_in_db_litellm = True
diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py
index 47d93fc2d7a..c86794b90f8 100644
--- a/litellm/types/guardrails.py
+++ b/litellm/types/guardrails.py
@@ -725,6 +725,18 @@ class BaseLitellmParams(ContentFilterConfigModel): # works for new and patch up
description="When True, guardrails only receive the latest message for the relevant role (e.g., newest user input pre-call, newest assistant output post-call)",
)
+ only_scan_new_messages: Optional[bool] = Field(
+ default=False,
+ description=(
+ "When True, the guardrail only scans messages that have not already been scanned "
+ "earlier in the same session (identified by litellm_session_id / session_id). "
+ "Message content is hashed per session and cached; only the diff (new or edited "
+ "messages) is sent to the guardrail provider on follow-up calls. Falls back to a "
+ "full scan when the request has no session id or the cache is unavailable. Intended "
+ "for blocking/detection guardrails; not applied when mask_request_content is set."
+ ),
+ )
+
skip_system_message_in_guardrail: Optional[bool] = Field(
default=None,
description=(
diff --git a/pyrightconfig.json b/pyrightconfig.json
index eabfbf515c4..2686ccd73d9 100644
--- a/pyrightconfig.json
+++ b/pyrightconfig.json
@@ -1,7 +1,7 @@
{
"include": ["litellm"],
"ignore": [],
- "exclude": ["**/node_modules", "**/__pycache__", "tests/e2e/claude_code", "litellm/types/utils.py", "litellm/proxy/_types.py"],
+ "exclude": ["**/node_modules", "**/__pycache__", "tests/e2e/claude_code", "tests/e2e/ui", "litellm/types/utils.py", "litellm/proxy/_types.py"],
"pythonVersion": "3.12",
"typeCheckingMode": "strict",
"enableTypeIgnoreComments": false,
diff --git a/tests/e2e/CLAUDE.md b/tests/e2e/CLAUDE.md
index 186517290ea..0e39664e358 100644
--- a/tests/e2e/CLAUDE.md
+++ b/tests/e2e/CLAUDE.md
@@ -22,6 +22,7 @@ Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family
- `other/` - the holding-pen suite for the `other.*` registry cluster with no home of its own yet: the master-key auth gate and the process-lifecycle health probes (liveness, public readiness, authenticated readiness diagnostics). Promote a cluster out once it is large/stable enough for its own suite
- `gateway/` - proxy configuration only (`litellm-config.yml`); no tests
- `claude_code/` - the Claude Code compatibility matrix: drives the real `claude` CLI (and HTTP probes) against a proxy for each feature x provider cell, reporting tagged-union outcomes via the `compat_result` fixture; ships its own driver/builder/publisher plus `_*_unit_tests/` trees. The HTTP probes ride the shared transport (`ProxyClient.count_tokens` / `ProxyClient.messages`); the CLI-driving path stays bespoke
+- `ui/` - the Admin UI browser suite: Playwright in TypeScript, driving the dashboard served by a live proxy on port 4000 (seeded postgres + mock LLM upstream; see its `run_e2e.sh`). It is a self-contained npm package with its own lockfile and does not use the Python harness, pytest markers, or the shared transport; the Python rules in this file (typed models, `Result` unions, basedpyright zero-error gate) do not apply inside it. Its only Python file, `fixtures/mock_llm_server/server.py`, is excluded from the e2e basedpyright gate via the root `pyrightconfig.json`
## MCP suite: real Datadog only
diff --git a/tests/e2e/load/test_session_anomaly.py b/tests/e2e/load/test_session_anomaly.py
index 80f8aff3ba4..7062587352b 100644
--- a/tests/e2e/load/test_session_anomaly.py
+++ b/tests/e2e/load/test_session_anomaly.py
@@ -62,7 +62,7 @@ class TestSummarizePlannedTurns:
class TestRetried:
def test_transient_failures_then_success_returns_the_success(self) -> None:
- outcome = Success(data=SessionMessagesResponse())
+ outcome = Success[SessionMessagesResponse](status_code=200, data=SessionMessagesResponse())
calls = iter(
(NetworkError(message="overloaded"), NetworkError(message="overloaded"), outcome)
)
@@ -88,7 +88,7 @@ class TestRetried:
raise AssertionError("slept after a successful attempt")
result = retried(
- lambda: Success(data=SessionMessagesResponse()),
+ lambda: Success[SessionMessagesResponse](status_code=200, data=SessionMessagesResponse()),
attempts=3,
sleep=sleep_means_retry,
)
diff --git a/ui/litellm-dashboard/e2e_tests/constants.ts b/tests/e2e/ui/constants.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/constants.ts
rename to tests/e2e/ui/constants.ts
diff --git a/ui/litellm-dashboard/e2e_tests/fixtures/config.yml b/tests/e2e/ui/fixtures/config.yml
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/fixtures/config.yml
rename to tests/e2e/ui/fixtures/config.yml
diff --git a/ui/litellm-dashboard/e2e_tests/fixtures/menuMappings.ts b/tests/e2e/ui/fixtures/menuMappings.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/fixtures/menuMappings.ts
rename to tests/e2e/ui/fixtures/menuMappings.ts
diff --git a/ui/litellm-dashboard/e2e_tests/fixtures/migratedPages.ts b/tests/e2e/ui/fixtures/migratedPages.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/fixtures/migratedPages.ts
rename to tests/e2e/ui/fixtures/migratedPages.ts
diff --git a/ui/litellm-dashboard/e2e_tests/fixtures/mock_llm_server/server.py b/tests/e2e/ui/fixtures/mock_llm_server/server.py
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/fixtures/mock_llm_server/server.py
rename to tests/e2e/ui/fixtures/mock_llm_server/server.py
diff --git a/ui/litellm-dashboard/e2e_tests/fixtures/pages.ts b/tests/e2e/ui/fixtures/pages.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/fixtures/pages.ts
rename to tests/e2e/ui/fixtures/pages.ts
diff --git a/ui/litellm-dashboard/e2e_tests/fixtures/roles.ts b/tests/e2e/ui/fixtures/roles.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/fixtures/roles.ts
rename to tests/e2e/ui/fixtures/roles.ts
diff --git a/ui/litellm-dashboard/e2e_tests/fixtures/seed.sql b/tests/e2e/ui/fixtures/seed.sql
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/fixtures/seed.sql
rename to tests/e2e/ui/fixtures/seed.sql
diff --git a/ui/litellm-dashboard/e2e_tests/fixtures/users.ts b/tests/e2e/ui/fixtures/users.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/fixtures/users.ts
rename to tests/e2e/ui/fixtures/users.ts
diff --git a/ui/litellm-dashboard/e2e_tests/globalSetup.ts b/tests/e2e/ui/globalSetup.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/globalSetup.ts
rename to tests/e2e/ui/globalSetup.ts
diff --git a/ui/litellm-dashboard/e2e_tests/helpers/navigation.ts b/tests/e2e/ui/helpers/navigation.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/helpers/navigation.ts
rename to tests/e2e/ui/helpers/navigation.ts
diff --git a/ui/litellm-dashboard/e2e_tests/migration.serverRootPath.config.ts b/tests/e2e/ui/migration.serverRootPath.config.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/migration.serverRootPath.config.ts
rename to tests/e2e/ui/migration.serverRootPath.config.ts
diff --git a/ui/litellm-dashboard/e2e_tests/migration.serverRootPath.globalSetup.ts b/tests/e2e/ui/migration.serverRootPath.globalSetup.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/migration.serverRootPath.globalSetup.ts
rename to tests/e2e/ui/migration.serverRootPath.globalSetup.ts
diff --git a/tests/e2e/ui/package-lock.json b/tests/e2e/ui/package-lock.json
new file mode 100644
index 00000000000..b22673a3535
--- /dev/null
+++ b/tests/e2e/ui/package-lock.json
@@ -0,0 +1,111 @@
+{
+ "name": "litellm-ui-e2e",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "litellm-ui-e2e",
+ "version": "0.0.0",
+ "devDependencies": {
+ "@playwright/test": "1.58.1",
+ "@types/node": "20.19.37",
+ "typescript": "5.9.3"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.58.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz",
+ "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.58.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.37",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
+ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.58.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
+ "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
+ "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/tests/e2e/ui/package.json b/tests/e2e/ui/package.json
new file mode 100644
index 00000000000..ede759d97cb
--- /dev/null
+++ b/tests/e2e/ui/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "litellm-ui-e2e",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "e2e": "playwright test --config playwright.config.ts",
+ "e2e:ui": "playwright test --ui --config playwright.config.ts",
+ "e2e:migration": "playwright test tests/migration/migratedPages.spec.ts --config playwright.config.ts",
+ "e2e:migration:root": "playwright test --config migration.serverRootPath.config.ts"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.58.1",
+ "@types/node": "20.19.37",
+ "typescript": "5.9.3"
+ }
+}
diff --git a/ui/litellm-dashboard/e2e_tests/playwright.config.ts b/tests/e2e/ui/playwright.config.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/playwright.config.ts
rename to tests/e2e/ui/playwright.config.ts
diff --git a/ui/litellm-dashboard/e2e_tests/run_e2e.sh b/tests/e2e/ui/run_e2e.sh
similarity index 98%
rename from ui/litellm-dashboard/e2e_tests/run_e2e.sh
rename to tests/e2e/ui/run_e2e.sh
index ea95f18890c..858eb401c8e 100755
--- a/ui/litellm-dashboard/e2e_tests/run_e2e.sh
+++ b/tests/e2e/ui/run_e2e.sh
@@ -20,8 +20,8 @@ set -euo pipefail
# ================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-DASHBOARD_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+DASHBOARD_DIR="$REPO_ROOT/ui/litellm-dashboard"
IS_CI="${CI:-false}"
CONTAINER_NAME="litellm-e2e-postgres-$$"
MOCK_PID=""
@@ -187,12 +187,12 @@ PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAM
# --- Playwright ---
echo "=== Installing Playwright dependencies ==="
-cd "$DASHBOARD_DIR"
+cd "$SCRIPT_DIR"
npm install --silent 2>/dev/null || true
npx playwright install chromium --with-deps 2>/dev/null || npx playwright install chromium
echo "=== Running Playwright tests ==="
-npx playwright test --config e2e_tests/playwright.config.ts "$@"
+npx playwright test --config playwright.config.ts "$@"
EXIT_CODE=$?
exit $EXIT_CODE
diff --git a/ui/litellm-dashboard/e2e_tests/serverRootPath.config.ts b/tests/e2e/ui/serverRootPath.config.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/serverRootPath.config.ts
rename to tests/e2e/ui/serverRootPath.config.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/auth/logout.spec.ts b/tests/e2e/ui/tests/auth/logout.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/auth/logout.spec.ts
rename to tests/e2e/ui/tests/auth/logout.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/auth/proxyLogoutUrl.spec.ts b/tests/e2e/ui/tests/auth/proxyLogoutUrl.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/auth/proxyLogoutUrl.spec.ts
rename to tests/e2e/ui/tests/auth/proxyLogoutUrl.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/auth/unauthenticatedRedirect.spec.ts b/tests/e2e/ui/tests/auth/unauthenticatedRedirect.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/auth/unauthenticatedRedirect.spec.ts
rename to tests/e2e/ui/tests/auth/unauthenticatedRedirect.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/internal-user/internalUser.spec.ts b/tests/e2e/ui/tests/internal-user/internalUser.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/internal-user/internalUser.spec.ts
rename to tests/e2e/ui/tests/internal-user/internalUser.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/internal-user/internalUserNoTeam.spec.ts b/tests/e2e/ui/tests/internal-user/internalUserNoTeam.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/internal-user/internalUserNoTeam.spec.ts
rename to tests/e2e/ui/tests/internal-user/internalUserNoTeam.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/internal-user/internalUserWithTeams.spec.ts b/tests/e2e/ui/tests/internal-user/internalUserWithTeams.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/internal-user/internalUserWithTeams.spec.ts
rename to tests/e2e/ui/tests/internal-user/internalUserWithTeams.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/internal-viewer/internalViewer.spec.ts b/tests/e2e/ui/tests/internal-viewer/internalViewer.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/internal-viewer/internalViewer.spec.ts
rename to tests/e2e/ui/tests/internal-viewer/internalViewer.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/login/internalUserIdentity.spec.ts b/tests/e2e/ui/tests/login/internalUserIdentity.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/login/internalUserIdentity.spec.ts
rename to tests/e2e/ui/tests/login/internalUserIdentity.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/login/login.spec.ts b/tests/e2e/ui/tests/login/login.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/login/login.spec.ts
rename to tests/e2e/ui/tests/login/login.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/login/serverRootPathRedirect.spec.ts b/tests/e2e/ui/tests/login/serverRootPathRedirect.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/login/serverRootPathRedirect.spec.ts
rename to tests/e2e/ui/tests/login/serverRootPathRedirect.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/mcp/mcpServers.spec.ts b/tests/e2e/ui/tests/mcp/mcpServers.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/mcp/mcpServers.spec.ts
rename to tests/e2e/ui/tests/mcp/mcpServers.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/migration/README.md b/tests/e2e/ui/tests/migration/README.md
similarity index 84%
rename from ui/litellm-dashboard/e2e_tests/tests/migration/README.md
rename to tests/e2e/ui/tests/migration/README.md
index 4b3a391d421..d6b33598ec4 100644
--- a/ui/litellm-dashboard/e2e_tests/tests/migration/README.md
+++ b/tests/e2e/ui/tests/migration/README.md
@@ -9,8 +9,9 @@ the default mount and a non-root `SERVER_ROOT_PATH` mount.
## Adding a page
When a page's migration merges, add its route segment to
-`e2e_tests/fixtures/migratedPages.ts` (keep it in lockstep with `MIGRATED_PAGES`
-in `src/utils/migratedPages.ts`). Both suites pick it up automatically.
+`tests/e2e/ui/fixtures/migratedPages.ts` (keep it in lockstep with `MIGRATED_PAGES`
+in `ui/litellm-dashboard/src/utils/migratedPages.ts`). Both suites pick it up
+automatically.
## Running
diff --git a/ui/litellm-dashboard/e2e_tests/tests/migration/migratedPages.spec.ts b/tests/e2e/ui/tests/migration/migratedPages.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/migration/migratedPages.spec.ts
rename to tests/e2e/ui/tests/migration/migratedPages.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/modelHub/modelHub.spec.ts b/tests/e2e/ui/tests/modelHub/modelHub.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/modelHub/modelHub.spec.ts
rename to tests/e2e/ui/tests/modelHub/modelHub.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts b/tests/e2e/ui/tests/modelsPage/addModel.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts
rename to tests/e2e/ui/tests/modelsPage/addModel.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/modelsPage/clearCustomPricing.spec.ts b/tests/e2e/ui/tests/modelsPage/clearCustomPricing.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/modelsPage/clearCustomPricing.spec.ts
rename to tests/e2e/ui/tests/modelsPage/clearCustomPricing.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/modelsPage/credentials.spec.ts b/tests/e2e/ui/tests/modelsPage/credentials.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/modelsPage/credentials.spec.ts
rename to tests/e2e/ui/tests/modelsPage/credentials.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/navigation/sidebar.spec.ts b/tests/e2e/ui/tests/navigation/sidebar.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/navigation/sidebar.spec.ts
rename to tests/e2e/ui/tests/navigation/sidebar.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/keys.spec.ts b/tests/e2e/ui/tests/proxy-admin/keys.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/proxy-admin/keys.spec.ts
rename to tests/e2e/ui/tests/proxy-admin/keys.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/license.spec.ts b/tests/e2e/ui/tests/proxy-admin/license.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/proxy-admin/license.spec.ts
rename to tests/e2e/ui/tests/proxy-admin/license.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/teams.spec.ts b/tests/e2e/ui/tests/proxy-admin/teams.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/proxy-admin/teams.spec.ts
rename to tests/e2e/ui/tests/proxy-admin/teams.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/settings/adminSettings.spec.ts b/tests/e2e/ui/tests/settings/adminSettings.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/settings/adminSettings.spec.ts
rename to tests/e2e/ui/tests/settings/adminSettings.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts b/tests/e2e/ui/tests/settings/routerSettings.spec.ts
similarity index 98%
rename from ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts
rename to tests/e2e/ui/tests/settings/routerSettings.spec.ts
index 3e140b9ab56..ffa5f2c2ae2 100644
--- a/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts
+++ b/tests/e2e/ui/tests/settings/routerSettings.spec.ts
@@ -6,7 +6,7 @@ import { Role, users } from "../../fixtures/users";
// Type-only import of the OpenAPI-generated backend schema, erased at runtime by
// esbuild. It types the round-trips below so mistakes surface in the editor; the live
// test against the real proxy is what actually enforces the contract.
-import type { components } from "../../../src/lib/http/schema";
+import type { components } from "../../../../../ui/litellm-dashboard/src/lib/http/schema";
// These tests mutate the proxy's shared router_settings, and the Loadbalancing save
// echoes the whole settings object, so they must not run concurrently.
diff --git a/ui/litellm-dashboard/e2e_tests/tests/team-admin/teamAdmin.spec.ts b/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/team-admin/teamAdmin.spec.ts
rename to tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/users/searchUsers.spec.ts b/tests/e2e/ui/tests/users/searchUsers.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/users/searchUsers.spec.ts
rename to tests/e2e/ui/tests/users/searchUsers.spec.ts
diff --git a/ui/litellm-dashboard/e2e_tests/tests/users/viewInternalUsers.spec.ts b/tests/e2e/ui/tests/users/viewInternalUsers.spec.ts
similarity index 100%
rename from ui/litellm-dashboard/e2e_tests/tests/users/viewInternalUsers.spec.ts
rename to tests/e2e/ui/tests/users/viewInternalUsers.spec.ts
diff --git a/tests/e2e/ui/tsconfig.json b/tests/e2e/ui/tsconfig.json
new file mode 100644
index 00000000000..f9290fe7b49
--- /dev/null
+++ b/tests/e2e/ui/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "commonjs",
+ "moduleResolution": "node",
+ "lib": ["ES2022", "DOM"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "noEmit": true,
+ "types": ["node"]
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py
index 9289dece83f..64813c1eda7 100644
--- a/tests/test_litellm/integrations/test_custom_guardrail.py
+++ b/tests/test_litellm/integrations/test_custom_guardrail.py
@@ -1716,3 +1716,201 @@ class TestApplyGuardrailStyleDeploymentDispatch:
await guardrail.async_pre_call_deployment_hook(kwargs, CallTypes.acompletion)
assert guardrail.apply_called is False
+
+
+class TestOnlyScanNewMessages:
+ """Incremental guardrail scanning: only send text segments not already scanned this session."""
+
+ def _guardrail(self, **overrides):
+ params = dict(guardrail_name="test-guard", only_scan_new_messages=True)
+ params.update(overrides)
+ return CustomGuardrail(**params)
+
+ def _cache(self):
+ from litellm.caching import DualCache
+
+ return DualCache()
+
+ @pytest.mark.asyncio
+ async def test_disabled_returns_none(self):
+ guardrail = self._guardrail(only_scan_new_messages=False)
+ result = await guardrail.filter_new_texts_for_session(
+ texts=["hi"],
+ request_data={"litellm_session_id": "s1"},
+ cache=self._cache(),
+ )
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_no_session_id_fails_safe_to_full_scan(self):
+ guardrail = self._guardrail()
+ result = await guardrail.filter_new_texts_for_session(
+ texts=["hi"],
+ request_data={"metadata": {}},
+ cache=self._cache(),
+ )
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_masking_guardrail_not_supported(self):
+ guardrail = self._guardrail(mask_request_content=True)
+ result = await guardrail.filter_new_texts_for_session(
+ texts=["hi"],
+ request_data={"litellm_session_id": "s1"},
+ cache=self._cache(),
+ )
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_cache_read_failure_fails_safe_to_full_scan(self):
+ from unittest.mock import AsyncMock
+
+ guardrail = self._guardrail()
+ cache = self._cache()
+ cache.async_get_cache = AsyncMock(side_effect=RuntimeError("redis down"))
+ result = await guardrail.filter_new_texts_for_session(
+ texts=["hi"],
+ request_data={"litellm_session_id": "s1"},
+ cache=cache,
+ )
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_dedupes_previously_scanned_texts(self):
+ guardrail = self._guardrail()
+ cache = self._cache()
+ request = {"litellm_session_id": "sess-dedupe"}
+ turn1 = ["you are helpful", "first question"]
+
+ first = await guardrail.filter_new_texts_for_session(texts=turn1, request_data=request, cache=cache)
+ assert first == turn1
+ await guardrail.mark_texts_scanned(texts=turn1, request_data=request, cache=cache)
+
+ turn2 = turn1 + ["an answer", "second question"]
+ second = await guardrail.filter_new_texts_for_session(texts=turn2, request_data=request, cache=cache)
+ assert second == ["an answer", "second question"]
+
+ @pytest.mark.asyncio
+ async def test_no_new_texts_returns_empty(self):
+ guardrail = self._guardrail()
+ cache = self._cache()
+ request = {"litellm_session_id": "sess-empty"}
+ texts = ["only message"]
+
+ await guardrail.filter_new_texts_for_session(texts=texts, request_data=request, cache=cache)
+ await guardrail.mark_texts_scanned(texts=texts, request_data=request, cache=cache)
+
+ again = await guardrail.filter_new_texts_for_session(texts=texts, request_data=request, cache=cache)
+ assert again == []
+
+ @pytest.mark.asyncio
+ async def test_modified_earlier_text_is_rescanned(self):
+ guardrail = self._guardrail()
+ cache = self._cache()
+ request = {"litellm_session_id": "sess-edit"}
+ original = ["original"]
+
+ await guardrail.filter_new_texts_for_session(texts=original, request_data=request, cache=cache)
+ await guardrail.mark_texts_scanned(texts=original, request_data=request, cache=cache)
+
+ edited = ["original EDITED"]
+ result = await guardrail.filter_new_texts_for_session(texts=edited, request_data=request, cache=cache)
+ assert result == edited
+
+ @pytest.mark.asyncio
+ async def test_blocked_scan_does_not_persist_hashes(self):
+ guardrail = self._guardrail()
+ cache = self._cache()
+ request = {"litellm_session_id": "sess-blocked"}
+ texts = ["please block me"]
+
+ filtered = await guardrail.filter_new_texts_for_session(texts=texts, request_data=request, cache=cache)
+ assert filtered == texts
+
+ again = await guardrail.filter_new_texts_for_session(texts=texts, request_data=request, cache=cache)
+ assert again == texts
+
+ @pytest.mark.asyncio
+ async def test_scanned_hashes_written_with_fixed_ttl(self):
+ from unittest.mock import AsyncMock
+
+ from litellm.constants import GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS
+
+ guardrail = self._guardrail()
+ cache = self._cache()
+ cache.async_set_cache = AsyncMock()
+ request = {"litellm_session_id": "sess-ttl"}
+
+ await guardrail.mark_texts_scanned(texts=["a", "b"], request_data=request, cache=cache)
+
+ cache.async_set_cache.assert_awaited_once()
+ assert cache.async_set_cache.await_args.kwargs["ttl"] == GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS
+
+ @pytest.mark.asyncio
+ async def test_session_id_from_metadata_is_used_for_dedupe(self):
+ guardrail = self._guardrail()
+ cache = self._cache()
+ request = {"metadata": {"session_id": "sess-meta"}}
+ texts = ["shared message"]
+
+ await guardrail.filter_new_texts_for_session(texts=texts, request_data=request, cache=cache)
+ await guardrail.mark_texts_scanned(texts=texts, request_data=request, cache=cache)
+
+ again = await guardrail.filter_new_texts_for_session(texts=texts, request_data=request, cache=cache)
+ assert again == []
+
+ @pytest.mark.asyncio
+ async def test_session_id_from_litellm_metadata_is_used_for_dedupe(self):
+ guardrail = self._guardrail()
+ cache = self._cache()
+ request = {"litellm_metadata": {"session_id": "sess-lmeta"}}
+ texts = ["shared message"]
+
+ await guardrail.filter_new_texts_for_session(texts=texts, request_data=request, cache=cache)
+ await guardrail.mark_texts_scanned(texts=texts, request_data=request, cache=cache)
+
+ again = await guardrail.filter_new_texts_for_session(texts=texts, request_data=request, cache=cache)
+ assert again == []
+
+ @pytest.mark.asyncio
+ async def test_mark_texts_scanned_disabled_does_not_persist(self):
+ from unittest.mock import AsyncMock
+
+ guardrail = self._guardrail(only_scan_new_messages=False)
+ cache = self._cache()
+ cache.async_set_cache = AsyncMock()
+
+ await guardrail.mark_texts_scanned(texts=["a"], request_data={"litellm_session_id": "s1"}, cache=cache)
+ cache.async_set_cache.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_mark_texts_scanned_masking_does_not_persist(self):
+ from unittest.mock import AsyncMock
+
+ guardrail = self._guardrail(mask_request_content=True)
+ cache = self._cache()
+ cache.async_set_cache = AsyncMock()
+
+ await guardrail.mark_texts_scanned(texts=["a"], request_data={"litellm_session_id": "s1"}, cache=cache)
+ cache.async_set_cache.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_mark_texts_scanned_without_session_does_not_persist(self):
+ from unittest.mock import AsyncMock
+
+ guardrail = self._guardrail()
+ cache = self._cache()
+ cache.async_set_cache = AsyncMock()
+
+ await guardrail.mark_texts_scanned(texts=["a"], request_data={"metadata": {}}, cache=cache)
+ cache.async_set_cache.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_mark_texts_scanned_survives_cache_write_failure(self):
+ from unittest.mock import AsyncMock
+
+ guardrail = self._guardrail()
+ cache = self._cache()
+ cache.async_set_cache = AsyncMock(side_effect=RuntimeError("redis down"))
+
+ await guardrail.mark_texts_scanned(texts=["a"], request_data={"litellm_session_id": "s1"}, cache=cache)
diff --git a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py
index c5422e0d70f..9cd1fbb59a6 100644
--- a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py
+++ b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py
@@ -373,3 +373,132 @@ class TestAnthropicMessagesHandlerToolInjection:
if __name__ == "__main__":
# Run the tests
pytest.main([__file__, "-v"])
+
+
+class TestAnthropicMessagesIncrementalScan:
+ """PR #33278: only_scan_new_messages through the real /v1/messages translation
+ handler (the path Claude Code uses). Encodes the wire payloads observed in the
+ live validation against a real Bedrock guardrail.
+ """
+
+ def _bedrock_guardrail(self):
+ from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import BedrockGuardrail
+
+ return BedrockGuardrail(
+ guardrail_name="bedrock-incremental-anthropic",
+ guardrailIdentifier="test-guardrail",
+ guardrailVersion="DRAFT",
+ default_on=True,
+ only_scan_new_messages=True,
+ )
+
+ def _data(self, messages, session_id):
+ return {
+ "model": "claude-sonnet-4-5",
+ "messages": messages,
+ "system": "You are a helpful geography assistant.",
+ "litellm_session_id": session_id,
+ }
+
+ @pytest.mark.asyncio
+ async def test_first_turn_scans_all_eligible_then_second_turn_scans_only_diff(self):
+ from unittest.mock import AsyncMock, patch
+
+ handler = AnthropicMessagesHandler()
+ guardrail = self._bedrock_guardrail()
+ sid = "anth-sess-diff"
+ turn1 = [{"role": "user", "content": "What is the capital of France?"}]
+ turn2 = turn1 + [
+ {"role": "assistant", "content": "Paris."},
+ {"role": "user", "content": "What is the capital of Germany?"},
+ ]
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = {"action": "NONE", "output": [], "outputs": []}
+ await handler.process_input_messages(
+ data=self._data(turn1, sid), guardrail_to_apply=guardrail
+ )
+ assert mock_api.call_count == 1
+ assert [m["content"] for m in mock_api.call_args.kwargs["messages"]] == [
+ "What is the capital of France?"
+ ]
+ mock_api.reset_mock()
+ await handler.process_input_messages(
+ data=self._data(turn2, sid), guardrail_to_apply=guardrail
+ )
+ assert mock_api.call_count == 1
+ assert [m["content"] for m in mock_api.call_args.kwargs["messages"]] == [
+ "Paris.",
+ "What is the capital of Germany?",
+ ]
+
+ @pytest.mark.asyncio
+ async def test_identical_resend_makes_no_guardrail_call(self):
+ from unittest.mock import AsyncMock, patch
+
+ handler = AnthropicMessagesHandler()
+ guardrail = self._bedrock_guardrail()
+ sid = "anth-sess-resend"
+ msgs = [
+ {"role": "user", "content": "What is the capital of France?"},
+ {"role": "assistant", "content": "Paris."},
+ {"role": "user", "content": "What is the capital of Germany?"},
+ ]
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = {"action": "NONE", "output": [], "outputs": []}
+ await handler.process_input_messages(data=self._data(msgs, sid), guardrail_to_apply=guardrail)
+ assert mock_api.call_count == 1
+ mock_api.reset_mock()
+ await handler.process_input_messages(data=self._data(msgs, sid), guardrail_to_apply=guardrail)
+ mock_api.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_edited_history_message_is_rescanned(self):
+ from unittest.mock import AsyncMock, patch
+
+ handler = AnthropicMessagesHandler()
+ guardrail = self._bedrock_guardrail()
+ sid = "anth-sess-edit"
+ msgs = [{"role": "user", "content": "What is the capital of France?"}]
+ edited = [{"role": "user", "content": "What is the capital and population of France?"}]
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = {"action": "NONE", "output": [], "outputs": []}
+ await handler.process_input_messages(data=self._data(msgs, sid), guardrail_to_apply=guardrail)
+ mock_api.reset_mock()
+ await handler.process_input_messages(data=self._data(edited, sid), guardrail_to_apply=guardrail)
+ assert mock_api.call_count == 1
+ assert [m["content"] for m in mock_api.call_args.kwargs["messages"]] == [
+ "What is the capital and population of France?"
+ ]
+
+ @pytest.mark.asyncio
+ async def test_mixed_text_and_tool_use_keeps_text_segments(self):
+ """A message carrying both text and a tool_use block must not lose its text.
+ (tool_use inputs and tool_result content are dropped from texts on the
+ anthropic input path today; that is pre-existing baseline behavior.)"""
+ from unittest.mock import AsyncMock, patch
+
+ handler = AnthropicMessagesHandler()
+ guardrail = self._bedrock_guardrail()
+ sid = "anth-sess-tools"
+ msgs = [
+ {"role": "user", "content": "Search for the weather in Paris"},
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "text", "text": "Let me look that up for you."},
+ {"type": "tool_use", "id": "toolu_1", "name": "search", "input": {"query": "canary-args"}},
+ ],
+ },
+ {
+ "role": "user",
+ "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "canary-result"}],
+ },
+ {"role": "user", "content": "Thanks, summarize the result."},
+ ]
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = {"action": "NONE", "output": [], "outputs": []}
+ await handler.process_input_messages(data=self._data(msgs, sid), guardrail_to_apply=guardrail)
+ scanned = [m["content"] for m in mock_api.call_args.kwargs["messages"]]
+ assert "Let me look that up for you." in scanned, "text beside a tool_use must be scanned"
+ assert "Search for the weather in Paris" in scanned
+ assert "Thanks, summarize the result." in scanned
diff --git a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py
index 4c268d9dfc9..7730b664c5e 100644
--- a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py
+++ b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py
@@ -1137,3 +1137,95 @@ class TestGetStructuredMessages:
if __name__ == "__main__":
# Run the tests
pytest.main([__file__, "-v"])
+
+
+class TestIncrementalScanRespectsSkipFlags:
+ """PR #33278: skip_system_message_in_guardrail and skip_tool_message_in_guardrail
+ are enforced while this handler builds inputs["texts"] (_extract_inputs early
+ returns for system/tool roles), upstream of BedrockGuardrail's incremental path.
+ Bypassing _select_messages_for_apply_guardrail therefore cannot resurrect skipped
+ content on any turn, including a session's first turn where every segment is new.
+ Verified live against a real Bedrock ApplyGuardrail before being encoded here.
+ The flags are set as instance attributes, mirroring how guardrail_registry
+ applies litellm_params to the callback (they are not constructor kwargs).
+ """
+
+ def _bedrock_guardrail(self):
+ from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import BedrockGuardrail
+
+ guardrail = BedrockGuardrail(
+ guardrail_name="bedrock-incremental-skip-flags",
+ guardrailIdentifier="test-guardrail",
+ guardrailVersion="DRAFT",
+ default_on=True,
+ only_scan_new_messages=True,
+ )
+ guardrail.skip_system_message_in_guardrail = True
+ guardrail.skip_tool_message_in_guardrail = True
+ return guardrail
+
+ def _messages(self, followup=None):
+ base = [
+ {"role": "system", "content": "SYSTEM-PROMPT-must-not-be-scanned"},
+ {"role": "user", "content": "Search for the weather in Paris"},
+ {
+ "role": "assistant",
+ "content": "Let me look that up.",
+ "tool_calls": [
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": {"name": "search", "arguments": '{"query": "weather"}'},
+ }
+ ],
+ },
+ {"role": "tool", "tool_call_id": "call_1", "content": "TOOL-RESULT-must-not-be-scanned"},
+ {"role": "user", "content": "Thanks, summarize."},
+ ]
+ return base + (followup or [])
+
+ @pytest.mark.asyncio
+ async def test_first_turn_scans_no_system_or_tool_content(self):
+ from unittest.mock import AsyncMock, patch
+
+ handler = OpenAIChatCompletionsHandler()
+ guardrail = self._bedrock_guardrail()
+ data = {"messages": self._messages(), "litellm_session_id": "skip-flags-turn1"}
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = {"action": "NONE", "output": [], "outputs": []}
+ await handler.process_input_messages(data=data, guardrail_to_apply=guardrail)
+ assert mock_api.call_count == 1
+ scanned = [m["content"] for m in mock_api.call_args.kwargs["messages"]]
+ assert scanned == [
+ "Search for the weather in Paris",
+ "Let me look that up.",
+ "Thanks, summarize.",
+ ]
+ assert not any("SYSTEM-PROMPT" in text for text in scanned)
+ assert not any("TOOL-RESULT" in text for text in scanned)
+
+ @pytest.mark.asyncio
+ async def test_second_turn_scans_only_new_eligible_content(self):
+ from unittest.mock import AsyncMock, patch
+
+ handler = OpenAIChatCompletionsHandler()
+ guardrail = self._bedrock_guardrail()
+ session = "skip-flags-turn2"
+ followup = [
+ {"role": "assistant", "content": "It is sunny in Paris."},
+ {"role": "user", "content": "And tomorrow?"},
+ ]
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = {"action": "NONE", "output": [], "outputs": []}
+ await handler.process_input_messages(
+ data={"messages": self._messages(), "litellm_session_id": session},
+ guardrail_to_apply=guardrail,
+ )
+ mock_api.reset_mock()
+ await handler.process_input_messages(
+ data={"messages": self._messages(followup), "litellm_session_id": session},
+ guardrail_to_apply=guardrail,
+ )
+ assert mock_api.call_count == 1
+ scanned = [m["content"] for m in mock_api.call_args.kwargs["messages"]]
+ assert scanned == ["It is sunny in Paris.", "And tomorrow?"]
diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py
index 15827b80bcf..53f32fd96fb 100644
--- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py
+++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py
@@ -3274,3 +3274,343 @@ async def test_chat_completion_modify_response_exception_streaming_logging_obj_n
# CustomStreamWrapper would raise AttributeError inside __init__ and this
# call would never reach here.
assert response is not None
+
+
+class TestBedrockOnlyScanNewMessages:
+ """Bedrock apply_guardrail honors only_scan_new_messages: scans only the per-session diff.
+
+ apply_guardrail is the path the proxy actually runs for Bedrock (via the unified
+ guardrail interface), so these tests exercise it directly rather than the legacy
+ async_pre_call_hook. Each test uses a unique session id to isolate the process-wide
+ incremental cache.
+ """
+
+ def _guardrail(self):
+ return BedrockGuardrail(
+ guardrail_name="bedrock-incremental",
+ guardrailIdentifier="test-guardrail",
+ guardrailVersion="DRAFT",
+ default_on=True,
+ only_scan_new_messages=True,
+ )
+
+ @pytest.mark.asyncio
+ async def test_second_turn_scans_only_new_messages(self):
+ guardrail = self._guardrail()
+ session = {"litellm_session_id": "sess-bedrock-diff"}
+ bedrock_none = {"action": "NONE", "output": [], "outputs": []}
+
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = bedrock_none
+
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["be helpful", "first question"]},
+ request_data=session,
+ input_type="request",
+ )
+ assert mock_api.call_count == 1
+ first_scanned = mock_api.call_args.kwargs["messages"]
+ assert [m["content"] for m in first_scanned] == ["be helpful", "first question"]
+
+ mock_api.reset_mock()
+
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["be helpful", "first question", "first answer", "second question"]},
+ request_data=session,
+ input_type="request",
+ )
+ assert mock_api.call_count == 1
+ second_scanned = mock_api.call_args.kwargs["messages"]
+ assert [m["content"] for m in second_scanned] == ["first answer", "second question"]
+
+ @pytest.mark.asyncio
+ async def test_identical_resend_skips_api_call(self):
+ guardrail = self._guardrail()
+ session = {"litellm_session_id": "sess-bedrock-resend"}
+
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = {"action": "NONE", "output": [], "outputs": []}
+
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["only question"]}, request_data=session, input_type="request"
+ )
+ assert mock_api.call_count == 1
+
+ mock_api.reset_mock()
+ result = await guardrail.apply_guardrail(
+ inputs={"texts": ["only question"]}, request_data=session, input_type="request"
+ )
+ mock_api.assert_not_called()
+ assert result["texts"] == ["only question"]
+
+ @pytest.mark.asyncio
+ async def test_no_session_id_scans_full_context(self):
+ guardrail = self._guardrail()
+
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = {"action": "NONE", "output": [], "outputs": []}
+
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["q1", "a1", "q2"]},
+ request_data={"metadata": {}},
+ input_type="request",
+ )
+ assert mock_api.call_count == 1
+ scanned = mock_api.call_args.kwargs["messages"]
+ assert [m["content"] for m in scanned] == ["q1", "a1", "q2"]
+
+ @pytest.mark.asyncio
+ async def test_masking_guardrail_falls_back_and_does_not_persist(self):
+ """A guardrail that anonymizes content must not be short-circuited.
+
+ Regression: the incremental fast path used to ignore the guardrail response,
+ so masked/anonymized output was dropped, the raw text reached the model, and
+ the segment was marked scanned so it was never re-checked. Detecting masked
+ output must force a full-context scan (which applies the masking) and must not
+ persist session state, so an identical resend is scanned again.
+ """
+ guardrail = self._guardrail()
+ session = {"litellm_session_id": "sess-bedrock-mask"}
+ masked = {
+ "action": "GUARDRAIL_INTERVENED",
+ "output": [],
+ "outputs": [{"text": "my ssn is [REDACTED]"}],
+ }
+
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = masked
+
+ result = await guardrail.apply_guardrail(
+ inputs={"texts": ["my ssn is 123-45-6789"]},
+ request_data=session,
+ input_type="request",
+ )
+ assert mock_api.call_count == 2
+ assert result["texts"] == ["my ssn is [REDACTED]"]
+
+ mock_api.reset_mock()
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["my ssn is 123-45-6789"]},
+ request_data=session,
+ input_type="request",
+ )
+ assert mock_api.call_count >= 1
+ first_scanned = mock_api.call_args_list[0].kwargs.get("messages")
+ assert first_scanned is not None
+ assert [m["content"] for m in first_scanned] == ["my ssn is 123-45-6789"]
+
+ @pytest.mark.asyncio
+ async def test_generic_agent_multi_turn_scans_only_new_each_turn(self):
+ """A generic agent (not Claude Code) opts in by propagating a session id.
+
+ Agent frameworks on the OpenAI SDK carry the session through the request
+ body (metadata.session_id here), not the x-claude-code-session-id header.
+ Across a growing multi-turn conversation every turn after the first must
+ send Bedrock only the newly appended segments, never the whole context.
+ """
+ guardrail = self._guardrail()
+ session = {"metadata": {"session_id": "agent-multi-turn"}}
+
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = {"action": "NONE", "output": [], "outputs": []}
+
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["system prompt", "turn 1 question"]},
+ request_data=session,
+ input_type="request",
+ )
+ assert [m["content"] for m in mock_api.call_args.kwargs["messages"]] == [
+ "system prompt",
+ "turn 1 question",
+ ]
+
+ mock_api.reset_mock()
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["system prompt", "turn 1 question", "turn 1 answer", "turn 2 question"]},
+ request_data=session,
+ input_type="request",
+ )
+ assert [m["content"] for m in mock_api.call_args.kwargs["messages"]] == [
+ "turn 1 answer",
+ "turn 2 question",
+ ]
+
+ mock_api.reset_mock()
+ await guardrail.apply_guardrail(
+ inputs={
+ "texts": [
+ "system prompt",
+ "turn 1 question",
+ "turn 1 answer",
+ "turn 2 question",
+ "turn 2 answer",
+ "turn 3 question",
+ ]
+ },
+ request_data=session,
+ input_type="request",
+ )
+ assert [m["content"] for m in mock_api.call_args.kwargs["messages"]] == [
+ "turn 2 answer",
+ "turn 3 question",
+ ]
+
+ def test_incremental_scan_cache_prefers_proxy_shared_cache(self):
+ guardrail = self._guardrail()
+ shared = DualCache()
+ proxy_logging = MagicMock()
+ proxy_logging.internal_usage_cache.dual_cache = shared
+
+ with patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging):
+ assert guardrail._incremental_scan_cache() is shared
+
+ def test_incremental_scan_cache_falls_back_when_proxy_logging_missing(self):
+ from litellm.integrations.custom_guardrail import dc as fallback_cache
+
+ guardrail = self._guardrail()
+ with patch("litellm.proxy.proxy_server.proxy_logging_obj", None):
+ assert guardrail._incremental_scan_cache() is fallback_cache
+
+ def test_incremental_scan_cache_falls_back_when_proxy_not_importable(self):
+ from litellm.integrations.custom_guardrail import dc as fallback_cache
+
+ guardrail = self._guardrail()
+ with patch.dict(sys.modules, {"litellm.proxy.proxy_server": None}):
+ assert guardrail._incremental_scan_cache() is fallback_cache
+
+ @pytest.mark.asyncio
+ async def test_blocked_turn_is_rescanned_on_retry(self):
+ guardrail = self._guardrail()
+ session = {"litellm_session_id": "sess-bedrock-blocked"}
+
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.side_effect = HTTPException(status_code=400, detail="blocked")
+ with pytest.raises(HTTPException):
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["blocked prompt"]}, request_data=session, input_type="request"
+ )
+
+ mock_api.reset_mock()
+ mock_api.side_effect = None
+ mock_api.return_value = {"action": "NONE", "output": [], "outputs": []}
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["blocked prompt"]}, request_data=session, input_type="request"
+ )
+ assert mock_api.call_count == 1
+ scanned = mock_api.call_args.kwargs["messages"]
+ assert [m["content"] for m in scanned] == ["blocked prompt"]
+
+
+class TestBedrockIncrementalFlagInteractions:
+ """Regression coverage for only_scan_new_messages combined with the other
+ Bedrock guardrail flags, from the PR #33278 live validation. Live evidence:
+ each of these was reproduced against a real Bedrock ApplyGuardrail first;
+ the mocks here encode the wire payloads observed there.
+ """
+
+ def _guardrail(self, **overrides):
+ params = dict(
+ guardrail_name="bedrock-incremental-flags",
+ guardrailIdentifier="test-guardrail",
+ guardrailVersion="DRAFT",
+ default_on=True,
+ only_scan_new_messages=True,
+ )
+ params.update(overrides)
+ return BedrockGuardrail(**params)
+
+ @pytest.mark.asyncio
+ async def test_edited_history_segment_rescans_only_that_segment(self):
+ guardrail = self._guardrail()
+ session = {"litellm_session_id": "sess-flags-edit"}
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = {"action": "NONE", "output": [], "outputs": []}
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["q1", "a1", "q2"]}, request_data=session, input_type="request"
+ )
+ mock_api.reset_mock()
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["q1 EDITED", "a1", "q2"]}, request_data=session, input_type="request"
+ )
+ assert mock_api.call_count == 1
+ assert [m["content"] for m in mock_api.call_args.kwargs["messages"]] == ["q1 EDITED"]
+
+ @pytest.mark.asyncio
+ async def test_same_content_different_session_rescans_everything(self):
+ guardrail = self._guardrail()
+ texts = ["shared question", "shared answer"]
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = {"action": "NONE", "output": [], "outputs": []}
+ await guardrail.apply_guardrail(
+ inputs={"texts": list(texts)}, request_data={"litellm_session_id": "sess-x1"}, input_type="request"
+ )
+ mock_api.reset_mock()
+ await guardrail.apply_guardrail(
+ inputs={"texts": list(texts)}, request_data={"litellm_session_id": "sess-x2"}, input_type="request"
+ )
+ assert mock_api.call_count == 1
+ assert [m["content"] for m in mock_api.call_args.kwargs["messages"]] == texts
+
+ @pytest.mark.asyncio
+ async def test_litellm_masking_flag_disables_incremental_single_full_scan(self):
+ """mask_request_content must fall back to exactly ONE full scan per turn
+ and never persist hashes (verified live: 1 call/turn, no cache writes)."""
+ guardrail = self._guardrail(mask_request_content=True)
+ session = {"litellm_session_id": "sess-flags-mask"}
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = {"action": "NONE", "output": [], "outputs": []}
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["q1"]}, request_data=session, input_type="request"
+ )
+ assert mock_api.call_count == 1
+ mock_api.reset_mock()
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["q1"]}, request_data=session, input_type="request"
+ )
+ assert mock_api.call_count == 1, "masking mode must re-scan every turn, exactly once"
+
+ @pytest.mark.asyncio
+ async def test_server_side_anonymize_falls_back_full_scan_and_never_persists(self):
+ """A guardrail that rewrites content (Bedrock-side ANONYMIZE) must fall back
+ to the full scan so masking applies, and record no session state. Live
+ validation showed this costs 2 provider calls per turn; the count is
+ asserted here as documentation of that intended-tradeoff behavior."""
+ guardrail = self._guardrail()
+ session = {"litellm_session_id": "sess-flags-anon"}
+ masked = {"action": "NONE", "output": [{"text": "MASKED q1"}], "outputs": [{"text": "MASKED q1"}]}
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = masked
+ result = await guardrail.apply_guardrail(
+ inputs={"texts": ["q1"]}, request_data=session, input_type="request"
+ )
+ assert mock_api.call_count == 2, "incremental attempt + full-scan fallback"
+ assert result["texts"] == ["MASKED q1"], "masked content must be applied"
+ mock_api.reset_mock()
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["q1"]}, request_data=session, input_type="request"
+ )
+ assert mock_api.call_count == 2, "no hashes persisted, so the double scan repeats"
+
+ @pytest.mark.asyncio
+ @pytest.mark.xfail(
+ reason="PR #33278 known gap: incremental path bypasses _select_messages_for_apply_guardrail, "
+ "so experimental_use_latest_role_message_only is silently ignored. Intended semantics "
+ "(pending DRI decision): incremental mode defers to the latest-role selection.",
+ strict=False,
+ )
+ async def test_latest_role_only_is_respected_with_incremental(self):
+ guardrail = self._guardrail(experimental_use_latest_role_message_only=True)
+ session = {"litellm_session_id": "sess-flags-latestrole"}
+ structured = [
+ {"role": "system", "content": "sys"},
+ {"role": "user", "content": "q1"},
+ ]
+ with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
+ mock_api.return_value = {"action": "NONE", "output": [], "outputs": []}
+ await guardrail.apply_guardrail(
+ inputs={"texts": ["sys", "q1"], "structured_messages": structured},
+ request_data=session,
+ input_type="request",
+ )
+ scanned = [m["content"] for m in mock_api.call_args.kwargs["messages"]]
+ assert scanned == ["q1"], "latest-role selection must exclude the system prompt"
diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py
index 3ff8e2a6886..f458645e51f 100644
--- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py
@@ -4,14 +4,18 @@ import pytest
from fastapi import HTTPException
from litellm.proxy._types import (
+ LiteLLM_TeamTable,
LiteLLM_UserTable,
LitellmUserRoles,
+ Member,
NewUserRequest,
NewUserResponse,
+ ProxyErrorTypes,
ProxyException,
)
from litellm.proxy.management_endpoints.scim.scim_v2 import (
UserProvisionerHelpers,
+ _apply_group_patch_updates,
_extract_group_member_ids,
_handle_team_membership_changes,
_process_group_patch_operations,
@@ -19,6 +23,7 @@ from litellm.proxy.management_endpoints.scim.scim_v2 import (
create_group,
create_user,
delete_group,
+ get_groups,
get_users,
get_service_provider_config,
patch_group,
@@ -55,9 +60,7 @@ async def test_create_user_existing_user_conflict(mocker):
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
- return_value={"user_id": "existing-user"}
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value={"user_id": "existing-user"})
# Mock the _get_prisma_client_or_raise_exception to return our mock
mocker.patch(
@@ -231,9 +234,7 @@ async def test_create_user_ingests_entitlements_and_roles(mocker, monkeypatch):
},
{"value": "bare-entitlement"},
]
- assert created_metadata["scim_roles"] == [
- {"value": "engineering-admin", "type": "role"}
- ]
+ assert created_metadata["scim_roles"] == [{"value": "engineering-admin", "type": "role"}]
@pytest.mark.asyncio
@@ -257,9 +258,7 @@ async def test_create_user_uses_default_internal_user_params_role(mocker, monkey
default_params = {
"user_role": LitellmUserRoles.PROXY_ADMIN,
}
- monkeypatch.setattr(
- "litellm.default_internal_user_params", default_params, raising=False
- )
+ monkeypatch.setattr("litellm.default_internal_user_params", default_params, raising=False)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
@@ -339,10 +338,7 @@ async def test_scim_create_user_respects_default_role_set_via_ui(mocker, monkeyp
"BUG: _update_litellm_setting did not update litellm.default_internal_user_params in memory. "
"The local variable reassignment (in_memory_var = ...) doesn't propagate back."
)
- assert (
- litellm.default_internal_user_params.get("user_role")
- == LitellmUserRoles.INTERNAL_USER
- )
+ assert litellm.default_internal_user_params.get("user_role") == LitellmUserRoles.INTERNAL_USER
# Step 3: Create a user via SCIM
scim_user = SCIMUser(
@@ -438,9 +434,7 @@ async def test_get_users_filters_username_by_exposed_scim_username_for_okta(mock
take=10,
order={"created_at": "desc"},
)
- mock_prisma_client.db.litellm_usertable.count.assert_awaited_once_with(
- where=expected_where
- )
+ mock_prisma_client.db.litellm_usertable.count.assert_awaited_once_with(where=expected_where)
assert response.totalResults == 1
assert response.Resources[0].id == "internal-user-id"
@@ -494,9 +488,7 @@ async def test_get_users_filters_email_value_by_user_email(mocker):
take=10,
order={"created_at": "desc"},
)
- mock_prisma_client.db.litellm_usertable.count.assert_awaited_once_with(
- where=expected_where
- )
+ mock_prisma_client.db.litellm_usertable.count.assert_awaited_once_with(where=expected_where)
assert response.totalResults == 1
assert response.Resources[0].id == "internal-user-id"
@@ -544,15 +536,12 @@ async def test_handle_existing_user_by_email_no_existing_user(mocker):
)
assert result is None
- mock_prisma_client.db.litellm_usertable.find_first.assert_called_once_with(
- where={"user_email": "test@example.com"}
- )
+ mock_prisma_client.db.litellm_usertable.find_first.assert_called_once_with(where={"user_email": "test@example.com"})
@pytest.mark.asyncio
async def test_handle_existing_user_by_email_existing_user_updated(mocker):
- """Should update existing user and return SCIMUser when user with email exists"""
- # Mock existing user - create a proper mock object with attributes
+ """Should rename the existing user, sync team roster, and return SCIMUser"""
existing_user = mocker.MagicMock()
existing_user.user_id = "old-user-id"
existing_user.user_email = "test@example.com"
@@ -560,7 +549,6 @@ async def test_handle_existing_user_by_email_existing_user_updated(mocker):
existing_user.teams = ["old-team"]
existing_user.metadata = {"old": "data"}
- # Mock updated user
updated_user = {
"user_id": "new-user-id",
"user_email": "test@example.com",
@@ -569,7 +557,6 @@ async def test_handle_existing_user_by_email_existing_user_updated(mocker):
"metadata": '{"new": "data"}',
}
- # Mock SCIM user to be returned
mock_scim_user = SCIMUser(
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
id="new-user-id",
@@ -581,18 +568,17 @@ async def test_handle_existing_user_by_email_existing_user_updated(mocker):
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(
- return_value=existing_user
- )
- mock_prisma_client.db.litellm_usertable.update = AsyncMock(
- return_value=updated_user
- )
+ mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=existing_user)
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=updated_user)
- # Mock the transformation function
mock_transform = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=mock_scim_user),
)
+ mock_membership = mocker.patch(
+ "litellm.proxy.management_endpoints.scim.scim_v2._handle_team_membership_changes",
+ AsyncMock(),
+ )
new_user_request = NewUserRequest(
user_id="new-user-id",
@@ -607,29 +593,276 @@ async def test_handle_existing_user_by_email_existing_user_updated(mocker):
prisma_client=mock_prisma_client, new_user_request=new_user_request
)
- # Verify the result
assert result == mock_scim_user
- # Verify database operations
- mock_prisma_client.db.litellm_usertable.find_first.assert_called_once_with(
- where={"user_email": "test@example.com"}
- )
+ mock_prisma_client.db.litellm_usertable.find_first.assert_called_once_with(where={"user_email": "test@example.com"})
- mock_prisma_client.db.litellm_usertable.update.assert_called_once_with(
- where={"user_id": "old-user-id"},
- data={
- "user_id": "new-user-id",
+ update_calls = mock_prisma_client.db.litellm_usertable.update.call_args_list
+ assert len(update_calls) == 2
+ assert update_calls[0].kwargs == {
+ "where": {"user_id": "old-user-id"},
+ "data": {"user_id": "new-user-id"},
+ }
+ assert update_calls[1].kwargs == {
+ "where": {"user_id": "new-user-id"},
+ "data": {
"user_email": "test@example.com",
"user_alias": "New Name",
"teams": ["new-team"],
"metadata": '{"new": "data"}',
},
+ }
+
+ mock_membership.assert_awaited_once_with(
+ user_id="new-user-id",
+ existing_teams=["old-team"],
+ new_teams=["new-team"],
+ raise_on_error=True,
)
- # Verify transformation was called
mock_transform.assert_called_once_with(updated_user)
+@pytest.mark.asyncio
+async def test_handle_existing_user_by_email_syncs_roster_and_dedups_teams(mocker):
+ """Existing-email upsert must add the user to the team roster via the shared
+ team_member_add path and dedup the teams built from repeated SCIM groups.
+
+ Regression: previously the user's ``teams`` array was raw-written (with
+ duplicates) and the team roster (members_with_roles / LiteLLM_TeamMembership)
+ was never touched, so the user appeared in the group on their profile but was
+ absent from the team directly.
+ """
+ existing_user = mocker.MagicMock()
+ existing_user.user_id = "same-id"
+ existing_user.user_email = "member@example.com"
+ existing_user.user_alias = "Member"
+ existing_user.teams = []
+ existing_user.metadata = {}
+
+ mock_prisma_client = mocker.MagicMock()
+ mock_prisma_client.db = mocker.MagicMock()
+ mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
+ mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=existing_user)
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value={})
+
+ mocker.patch(
+ "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
+ AsyncMock(return_value=None),
+ )
+ mock_membership = mocker.patch(
+ "litellm.proxy.management_endpoints.scim.scim_v2._handle_team_membership_changes",
+ AsyncMock(),
+ )
+
+ new_user_request = NewUserRequest(
+ user_id="same-id",
+ user_email="member@example.com",
+ user_alias="Member",
+ teams=["team-a", "team-a", "team-b"],
+ metadata={},
+ auto_create_key=False,
+ )
+
+ await UserProvisionerHelpers.handle_existing_user_by_email(
+ prisma_client=mock_prisma_client, new_user_request=new_user_request
+ )
+
+ mock_membership.assert_awaited_once_with(
+ user_id="same-id",
+ existing_teams=[],
+ new_teams=["team-a", "team-b"],
+ raise_on_error=True,
+ )
+
+ update_calls = mock_prisma_client.db.litellm_usertable.update.call_args_list
+ assert len(update_calls) == 1
+ assert update_calls[0].kwargs["where"] == {"user_id": "same-id"}
+ assert update_calls[0].kwargs["data"]["teams"] == ["team-a", "team-b"]
+
+
+@pytest.mark.asyncio
+async def test_handle_existing_user_by_email_roster_add_failure_blocks_teams_write(mocker):
+ """A genuine roster add failure must propagate and must not persist the teams array.
+
+ Regression: the roster sync went through patch_team_membership which swallowed
+ real team_member_add failures, so the endpoint reported success and wrote a
+ teams array listing a team the roster never received. The strict path now
+ surfaces the failure so user.teams and members_with_roles cannot diverge.
+ """
+ existing_user = mocker.MagicMock()
+ existing_user.user_id = "uid"
+ existing_user.user_email = "member@example.com"
+ existing_user.user_alias = "Member"
+ existing_user.teams = []
+ existing_user.metadata = {}
+
+ mock_prisma_client = mocker.MagicMock()
+ mock_prisma_client.db = mocker.MagicMock()
+ mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
+ mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=existing_user)
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value={})
+
+ mock_team_member_add = mocker.patch(
+ "litellm.proxy.management_endpoints.scim.scim_v2.team_member_add",
+ AsyncMock(side_effect=HTTPException(status_code=404, detail={"error": "Team not found"})),
+ )
+
+ new_user_request = NewUserRequest(
+ user_id="uid",
+ user_email="member@example.com",
+ user_alias="Member",
+ teams=["missing-team"],
+ metadata={},
+ auto_create_key=False,
+ )
+
+ with pytest.raises(HTTPException):
+ await UserProvisionerHelpers.handle_existing_user_by_email(
+ prisma_client=mock_prisma_client, new_user_request=new_user_request
+ )
+
+ mock_team_member_add.assert_awaited_once()
+ assert mock_prisma_client.db.litellm_usertable.update.await_count == 0
+
+
+@pytest.mark.asyncio
+async def test_handle_existing_user_by_email_roster_add_already_member_is_noop(mocker):
+ """Being already in the team is benign even under the strict path: the upsert
+ succeeds and the deduped teams array is still persisted."""
+ existing_user = mocker.MagicMock()
+ existing_user.user_id = "uid"
+ existing_user.user_email = "member@example.com"
+ existing_user.user_alias = "Member"
+ existing_user.teams = []
+ existing_user.metadata = {}
+
+ mock_prisma_client = mocker.MagicMock()
+ mock_prisma_client.db = mocker.MagicMock()
+ mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
+ mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=existing_user)
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value={})
+
+ mock_team_member_add = mocker.patch(
+ "litellm.proxy.management_endpoints.scim.scim_v2.team_member_add",
+ AsyncMock(
+ side_effect=ProxyException(
+ message="already in team",
+ type=ProxyErrorTypes.team_member_already_in_team.value,
+ param=None,
+ code=400,
+ )
+ ),
+ )
+ mocker.patch(
+ "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
+ AsyncMock(return_value=None),
+ )
+
+ new_user_request = NewUserRequest(
+ user_id="uid",
+ user_email="member@example.com",
+ user_alias="Member",
+ teams=["team-x"],
+ metadata={},
+ auto_create_key=False,
+ )
+
+ await UserProvisionerHelpers.handle_existing_user_by_email(
+ prisma_client=mock_prisma_client, new_user_request=new_user_request
+ )
+
+ mock_team_member_add.assert_awaited_once()
+ update_calls = mock_prisma_client.db.litellm_usertable.update.call_args_list
+ assert len(update_calls) == 1
+ assert update_calls[0].kwargs["data"]["teams"] == ["team-x"]
+
+
+@pytest.mark.asyncio
+async def test_handle_existing_user_by_email_roster_remove_failure_blocks_teams_write(mocker):
+ """A genuine roster removal failure must propagate and must not persist the teams array,
+ symmetrically with add failures, so user.teams cannot drop a team the roster still holds."""
+ existing_user = mocker.MagicMock()
+ existing_user.user_id = "uid"
+ existing_user.user_email = "member@example.com"
+ existing_user.user_alias = "Member"
+ existing_user.teams = ["old-team"]
+ existing_user.metadata = {}
+
+ mock_prisma_client = mocker.MagicMock()
+ mock_prisma_client.db = mocker.MagicMock()
+ mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
+ mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=existing_user)
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value={})
+
+ mock_team_member_delete = mocker.patch(
+ "litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete",
+ AsyncMock(side_effect=HTTPException(status_code=500, detail={"error": "No db connected"})),
+ )
+
+ new_user_request = NewUserRequest(
+ user_id="uid",
+ user_email="member@example.com",
+ user_alias="Member",
+ teams=[],
+ metadata={},
+ auto_create_key=False,
+ )
+
+ with pytest.raises(HTTPException):
+ await UserProvisionerHelpers.handle_existing_user_by_email(
+ prisma_client=mock_prisma_client, new_user_request=new_user_request
+ )
+
+ mock_team_member_delete.assert_awaited_once()
+ assert mock_prisma_client.db.litellm_usertable.update.await_count == 0
+
+
+@pytest.mark.asyncio
+async def test_handle_existing_user_by_email_roster_remove_already_absent_is_noop(mocker):
+ """A user already absent from the team is the idempotent removal no-op even under the
+ strict path: the upsert succeeds and the deduped teams array is still persisted."""
+ existing_user = mocker.MagicMock()
+ existing_user.user_id = "uid"
+ existing_user.user_email = "member@example.com"
+ existing_user.user_alias = "Member"
+ existing_user.teams = ["old-team"]
+ existing_user.metadata = {}
+
+ mock_prisma_client = mocker.MagicMock()
+ mock_prisma_client.db = mocker.MagicMock()
+ mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
+ mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=existing_user)
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value={})
+
+ mock_team_member_delete = mocker.patch(
+ "litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete",
+ AsyncMock(side_effect=HTTPException(status_code=400, detail={"error": "User not found in team"})),
+ )
+ mocker.patch(
+ "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
+ AsyncMock(return_value=None),
+ )
+
+ new_user_request = NewUserRequest(
+ user_id="uid",
+ user_email="member@example.com",
+ user_alias="Member",
+ teams=[],
+ metadata={},
+ auto_create_key=False,
+ )
+
+ await UserProvisionerHelpers.handle_existing_user_by_email(
+ prisma_client=mock_prisma_client, new_user_request=new_user_request
+ )
+
+ mock_team_member_delete.assert_awaited_once()
+ update_calls = mock_prisma_client.db.litellm_usertable.update.call_args_list
+ assert len(update_calls) == 1
+ assert update_calls[0].kwargs["data"]["teams"] == []
+
+
@pytest.mark.asyncio
async def test_handle_team_membership_changes_no_changes(mocker):
"""Should not call patch_team_membership when existing teams equal new teams"""
@@ -762,9 +995,7 @@ async def test_update_user_success(mocker):
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.update = AsyncMock(
- return_value=updated_user
- )
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=updated_user)
# Mock dependencies
mocker.patch(
@@ -815,11 +1046,7 @@ async def test_update_user_not_found(mocker):
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._check_user_exists",
- AsyncMock(
- side_effect=HTTPException(
- status_code=404, detail={"error": "User not found"}
- )
- ),
+ AsyncMock(side_effect=HTTPException(status_code=404, detail={"error": "User not found"})),
)
# Should raise ProxyException (which wraps the HTTPException)
@@ -864,9 +1091,7 @@ async def test_patch_user_success(mocker):
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.update = AsyncMock(
- return_value=updated_user
- )
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=updated_user)
# Mock dependencies
mocker.patch(
@@ -903,9 +1128,7 @@ async def test_patch_user_not_found(mocker):
"""Should raise 404 when user doesn't exist for patch"""
patch_ops = SCIMPatchOp(
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
- Operations=[
- SCIMPatchOperation(op="replace", path="displayName", value="New Name")
- ],
+ Operations=[SCIMPatchOperation(op="replace", path="displayName", value="New Name")],
)
# Mock dependencies to raise HTTPException for user not found
@@ -915,11 +1138,7 @@ async def test_patch_user_not_found(mocker):
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._check_user_exists",
- AsyncMock(
- side_effect=HTTPException(
- status_code=404, detail={"error": "User not found"}
- )
- ),
+ AsyncMock(side_effect=HTTPException(status_code=404, detail={"error": "User not found"})),
)
# Should raise ProxyException (which wraps the HTTPException)
@@ -939,9 +1158,7 @@ async def test_get_service_provider_config(mocker):
# Verify it returns the correct response
assert isinstance(result, SCIMServiceProviderConfig)
- assert result.schemas == [
- "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
- ]
+ assert result.schemas == ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"]
assert result.patch.supported is True
assert result.bulk.supported is False
assert result.meta is not None
@@ -993,21 +1210,15 @@ async def test_update_group_metadata_serialization_issue(mocker):
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
# Mock team operations
- mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
- return_value=mock_existing_team
- )
- mock_prisma_client.db.litellm_teamtable.update = AsyncMock(
- return_value=mock_updated_team
- )
+ mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team)
+ mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=mock_updated_team)
# Mock user operations
mock_user = mocker.MagicMock()
mock_user.user_id = "user1"
mock_user.user_email = "user1@example.com" # Add proper string value for user_email
mock_user.teams = [group_id]
- mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
- return_value=mock_user
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user)
mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=mock_user)
# Mock the _get_prisma_client_or_raise_exception to return our mock
@@ -1044,9 +1255,7 @@ async def test_update_group_metadata_serialization_issue(mocker):
metadata = update_data["metadata"]
# The fix should ensure metadata is serialized as a JSON string
- assert isinstance(
- metadata, str
- ), f"metadata should be a JSON string, but got {type(metadata)}"
+ assert isinstance(metadata, str), f"metadata should be a JSON string, but got {type(metadata)}"
# Verify we can parse it back to verify it contains the expected data
import json
@@ -1103,9 +1312,7 @@ async def test_team_membership_management(mocker):
# Check calls for adding members
add_calls = [
- call
- for call in mock_patch_team_membership.call_args_list
- if call[1]["teams_ids_to_add_user_to"] == [group_id]
+ call for call in mock_patch_team_membership.call_args_list if call[1]["teams_ids_to_add_user_to"] == [group_id]
]
assert len(add_calls) == 2 # user3 and user4
@@ -1131,9 +1338,7 @@ async def test_team_membership_management(mocker):
# Each call should either add OR remove, not both
add_teams = call[1]["teams_ids_to_add_user_to"]
remove_teams = call[1]["teams_ids_to_remove_user_from"]
- assert (len(add_teams) > 0) != (
- len(remove_teams) > 0
- ) # XOR - one should be empty
+ assert (len(add_teams) > 0) != (len(remove_teams) > 0) # XOR - one should be empty
@pytest.mark.asyncio
@@ -1184,9 +1389,7 @@ async def test_update_group_e2e(mocker):
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
# Mock database operations
- mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
- return_value=existing_team
- )
+ mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing_team)
# Mock the updated team that gets returned from database
updated_team = LiteLLM_TeamTable(
@@ -1203,16 +1406,12 @@ async def test_update_group_e2e(mocker):
"scim_data": scim_group_update.model_dump(),
},
)
- mock_prisma_client.db.litellm_teamtable.update = AsyncMock(
- return_value=updated_team
- )
+ mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=updated_team)
# Mock user validation (all users exist)
mock_user = mocker.MagicMock()
mock_user.user_id = "test-user"
- mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
- return_value=mock_user
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user)
# Mock dependencies
mocker.patch(
@@ -1265,29 +1464,19 @@ async def test_update_group_e2e(mocker):
assert metadata["scim_data"]["displayName"] == "Updated Team Name"
# Verify team membership changes were handled correctly
- assert (
- mock_patch_team_membership.call_count == 3
- ) # Remove user1, add user3, add user4
+ assert mock_patch_team_membership.call_count == 3 # Remove user1, add user3, add user4
# Check membership changes
call_args_list = mock_patch_team_membership.call_args_list
# Find remove operation (user1)
- remove_calls = [
- call
- for call in call_args_list
- if call[1]["teams_ids_to_remove_user_from"] == [group_id]
- ]
+ remove_calls = [call for call in call_args_list if call[1]["teams_ids_to_remove_user_from"] == [group_id]]
assert len(remove_calls) == 1
assert remove_calls[0][1]["user_id"] == "user1"
assert remove_calls[0][1]["teams_ids_to_add_user_to"] == []
# Find add operations (user3, user4)
- add_calls = [
- call
- for call in call_args_list
- if call[1]["teams_ids_to_add_user_to"] == [group_id]
- ]
+ add_calls = [call for call in call_args_list if call[1]["teams_ids_to_add_user_to"] == [group_id]]
assert len(add_calls) == 2
add_user_ids = {call[1]["user_id"] for call in add_calls}
assert add_user_ids == {"user3", "user4"}
@@ -1302,9 +1491,7 @@ async def test_update_group_e2e(mocker):
assert len(result.members) == 3
# Verify SCIM transformation was called with updated team
- ScimTransformations.transform_litellm_team_to_scim_group.assert_called_once_with(
- updated_team
- )
+ ScimTransformations.transform_litellm_team_to_scim_group.assert_called_once_with(updated_team)
@pytest.mark.asyncio
@@ -1330,15 +1517,9 @@ async def test_create_group_with_nonexistent_users_rejects(mocker, monkeypatch):
id=group_id,
displayName="Test Group",
members=[
- SCIMMember(
- value="existing-user", display="Existing User"
- ), # This user exists
- SCIMMember(
- value="new-user-1", display="New User 1"
- ), # This user doesn't exist
- SCIMMember(
- value="new-user-2", display="New User 2"
- ), # This user doesn't exist
+ SCIMMember(value="existing-user", display="Existing User"), # This user exists
+ SCIMMember(value="new-user-1", display="New User 1"), # This user doesn't exist
+ SCIMMember(value="new-user-2", display="New User 2"), # This user doesn't exist
],
)
@@ -1364,9 +1545,7 @@ async def test_create_group_with_nonexistent_users_rejects(mocker, monkeypatch):
return mock_user
return None # new-user-1 and new-user-2 don't exist
- mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
- side_effect=mock_user_lookup
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup)
# Mock dependencies
mocker.patch(
@@ -1381,9 +1560,7 @@ async def test_create_group_with_nonexistent_users_rejects(mocker, monkeypatch):
# Verify it's a 400 Bad Request
assert int(exc_info.value.code) == 400
assert "does not exist" in str(exc_info.value.message)
- assert "new-user-1" in str(exc_info.value.message) or "new-user-2" in str(
- exc_info.value.message
- )
+ assert "new-user-1" in str(exc_info.value.message) or "new-user-2" in str(exc_info.value.message)
@pytest.mark.asyncio
@@ -1418,15 +1595,9 @@ async def test_update_group_with_nonexistent_users_rejects(mocker, monkeypatch):
id=group_id,
displayName="Updated Group Name",
members=[
- SCIMMember(
- value="existing-user", display="Existing User"
- ), # This user exists
- SCIMMember(
- value="new-user-3", display="New User 3"
- ), # This user doesn't exist
- SCIMMember(
- value="new-user-4", display="New User 4"
- ), # This user doesn't exist
+ SCIMMember(value="existing-user", display="Existing User"), # This user exists
+ SCIMMember(value="new-user-3", display="New User 3"), # This user doesn't exist
+ SCIMMember(value="new-user-4", display="New User 4"), # This user doesn't exist
],
)
@@ -1437,18 +1608,14 @@ async def test_update_group_with_nonexistent_users_rejects(mocker, monkeypatch):
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
# Mock team operations
- mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
- return_value=mock_existing_team
- )
+ mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team)
# Mock updated team response
mock_updated_team = mocker.MagicMock()
mock_updated_team.team_id = group_id
mock_updated_team.team_alias = "Updated Group Name"
mock_updated_team.members = ["existing-user", "new-user-3", "new-user-4"]
- mock_prisma_client.db.litellm_teamtable.update = AsyncMock(
- return_value=mock_updated_team
- )
+ mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=mock_updated_team)
# Mock user lookup - only existing-user exists
def mock_user_lookup(where):
@@ -1459,9 +1626,7 @@ async def test_update_group_with_nonexistent_users_rejects(mocker, monkeypatch):
return mock_user
return None # new-user-3 and new-user-4 don't exist
- mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
- side_effect=mock_user_lookup
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup)
# Mock dependencies
mocker.patch(
@@ -1481,15 +1646,11 @@ async def test_update_group_with_nonexistent_users_rejects(mocker, monkeypatch):
# Verify it's a 400 Bad Request
assert int(exc_info.value.code) == 400
assert "does not exist" in str(exc_info.value.message)
- assert "new-user-3" in str(exc_info.value.message) or "new-user-4" in str(
- exc_info.value.message
- )
+ assert "new-user-3" in str(exc_info.value.message) or "new-user-4" in str(exc_info.value.message)
@pytest.mark.asyncio
-async def test_create_group_with_nonexistent_users_creates_when_flag_true(
- mocker, monkeypatch
-):
+async def test_create_group_with_nonexistent_users_creates_when_flag_true(mocker, monkeypatch):
"""
Test that creating a group with non-existent users creates them when scim_upsert_user is True.
This preserves backward compatible behavior.
@@ -1510,15 +1671,9 @@ async def test_create_group_with_nonexistent_users_creates_when_flag_true(
id=group_id,
displayName="Test Group",
members=[
- SCIMMember(
- value="existing-user", display="Existing User"
- ), # This user exists
- SCIMMember(
- value="new-user-1", display="New User 1"
- ), # This user doesn't exist - should be created
- SCIMMember(
- value="new-user-2", display="New User 2"
- ), # This user doesn't exist - should be created
+ SCIMMember(value="existing-user", display="Existing User"), # This user exists
+ SCIMMember(value="new-user-1", display="New User 1"), # This user doesn't exist - should be created
+ SCIMMember(value="new-user-2", display="New User 2"), # This user doesn't exist - should be created
],
)
@@ -1540,9 +1695,7 @@ async def test_create_group_with_nonexistent_users_creates_when_flag_true(
return mock_user
return None # new-user-1 and new-user-2 don't exist
- mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
- side_effect=mock_user_lookup
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup)
# Mock user creation
created_user_1 = NewUserResponse(user_id="new-user-1", key="test-key-1")
@@ -1591,9 +1744,7 @@ async def test_create_group_with_nonexistent_users_creates_when_flag_true(
@pytest.mark.asyncio
-async def test_extract_group_member_ids_with_flag_true_creates_users(
- mocker, monkeypatch
-):
+async def test_extract_group_member_ids_with_flag_true_creates_users(mocker, monkeypatch):
"""
Test that _extract_group_member_ids creates users when scim_upsert_user is True.
"""
@@ -1612,12 +1763,8 @@ async def test_extract_group_member_ids_with_flag_true_creates_users(
id="test-group",
displayName="Test Group",
members=[
- SCIMMember(
- value="existing-user", display="Existing User"
- ), # This user exists
- SCIMMember(
- value="new-user-1", display="New User 1"
- ), # This user doesn't exist - should be created
+ SCIMMember(value="existing-user", display="Existing User"), # This user exists
+ SCIMMember(value="new-user-1", display="New User 1"), # This user doesn't exist - should be created
],
)
@@ -1635,9 +1782,7 @@ async def test_extract_group_member_ids_with_flag_true_creates_users(
return mock_user
return None # new-user-1 doesn't exist
- mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
- side_effect=mock_user_lookup
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup)
# Mock user creation
created_user = NewUserResponse(user_id="new-user-1", key="test-key-1")
@@ -1662,9 +1807,7 @@ async def test_extract_group_member_ids_with_flag_true_creates_users(
assert len(result.created_users) == 1
# Verify user was created
- mock_create_user.assert_called_once_with(
- user_id="new-user-1", created_via="scim_group_membership"
- )
+ mock_create_user.assert_called_once_with(user_id="new-user-1", created_via="scim_group_membership")
@pytest.mark.asyncio
@@ -1687,12 +1830,8 @@ async def test_extract_group_member_ids_with_flag_false_rejects(mocker, monkeypa
id="test-group",
displayName="Test Group",
members=[
- SCIMMember(
- value="existing-user", display="Existing User"
- ), # This user exists
- SCIMMember(
- value="new-user-1", display="New User 1"
- ), # This user doesn't exist - should be rejected
+ SCIMMember(value="existing-user", display="Existing User"), # This user exists
+ SCIMMember(value="new-user-1", display="New User 1"), # This user doesn't exist - should be rejected
],
)
@@ -1710,9 +1849,7 @@ async def test_extract_group_member_ids_with_flag_false_rejects(mocker, monkeypa
return mock_user
return None # new-user-1 doesn't exist
- mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
- side_effect=mock_user_lookup
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=mock_user_lookup)
# Mock dependencies
mocker.patch(
@@ -1731,9 +1868,7 @@ async def test_extract_group_member_ids_with_flag_false_rejects(mocker, monkeypa
@pytest.mark.asyncio
-async def test_process_group_patch_operations_with_flag_true_creates_users(
- mocker, monkeypatch
-):
+async def test_process_group_patch_operations_with_flag_true_creates_users(mocker, monkeypatch):
"""
Test that _process_group_patch_operations creates users when scim_upsert_user is True.
"""
@@ -1749,11 +1884,7 @@ async def test_process_group_patch_operations_with_flag_true_creates_users(
# Test data
patch_ops = SCIMPatchOp(
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
- Operations=[
- SCIMPatchOperation(
- op="add", path="members", value=[{"value": "new-user-1"}]
- )
- ],
+ Operations=[SCIMPatchOperation(op="add", path="members", value=[{"value": "new-user-1"}])],
)
# Mock existing team
@@ -1787,15 +1918,11 @@ async def test_process_group_patch_operations_with_flag_true_creates_users(
assert "new-user-1" in final_members
# Verify user was created
- mock_create_user.assert_called_once_with(
- user_id="new-user-1", created_via="scim_group_patch"
- )
+ mock_create_user.assert_called_once_with(user_id="new-user-1", created_via="scim_group_patch")
@pytest.mark.asyncio
-async def test_process_group_patch_operations_with_flag_false_rejects(
- mocker, monkeypatch
-):
+async def test_process_group_patch_operations_with_flag_false_rejects(mocker, monkeypatch):
"""
Test that _process_group_patch_operations rejects non-existent users when scim_upsert_user is False.
"""
@@ -1811,11 +1938,7 @@ async def test_process_group_patch_operations_with_flag_false_rejects(
# Test data
patch_ops = SCIMPatchOp(
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
- Operations=[
- SCIMPatchOperation(
- op="add", path="members", value=[{"value": "new-user-1"}]
- )
- ],
+ Operations=[SCIMPatchOperation(op="add", path="members", value=[{"value": "new-user-1"}])],
)
# Mock existing team
@@ -1890,9 +2013,7 @@ async def test_create_user_grants_admin_when_in_scim_admin_group(mocker, monkeyp
@pytest.mark.asyncio
-async def test_create_user_keeps_default_when_not_in_scim_admin_group(
- mocker, monkeypatch
-):
+async def test_create_user_keeps_default_when_not_in_scim_admin_group(mocker, monkeypatch):
"""When scim_admin_group is configured but the user's groups don't include it,
the user keeps the non-admin default role."""
from litellm.proxy.proxy_server import proxy_config
@@ -1936,9 +2057,7 @@ async def test_create_user_keeps_default_when_not_in_scim_admin_group(
@pytest.mark.asyncio
-async def test_update_user_demotes_admin_when_removed_from_scim_admin_group(
- mocker, monkeypatch
-):
+async def test_update_user_demotes_admin_when_removed_from_scim_admin_group(mocker, monkeypatch):
"""Core demotion test: a PUT whose new groups no longer include the configured
admin group must re-evaluate the role and write the non-admin default, so an
admin removed from the IdP group is demoted without re-login."""
@@ -1972,9 +2091,7 @@ async def test_update_user_demotes_admin_when_removed_from_scim_admin_group(
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.update = AsyncMock(
- return_value=updated_user
- )
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=updated_user)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
@@ -2000,9 +2117,7 @@ async def test_update_user_demotes_admin_when_removed_from_scim_admin_group(
@pytest.mark.asyncio
-async def test_update_user_does_not_force_role_when_scim_admin_group_unset(
- mocker, monkeypatch
-):
+async def test_update_user_does_not_force_role_when_scim_admin_group_unset(mocker, monkeypatch):
"""When scim_admin_group is unset, PUT must not touch user_role (current
behavior preserved)."""
from litellm.proxy.proxy_server import proxy_config
@@ -2035,9 +2150,7 @@ async def test_update_user_does_not_force_role_when_scim_admin_group_unset(
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.update = AsyncMock(
- return_value=updated_user
- )
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=updated_user)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
@@ -2063,9 +2176,7 @@ async def test_update_user_does_not_force_role_when_scim_admin_group_unset(
@pytest.mark.asyncio
-async def test_update_user_demotes_when_default_params_lack_user_role(
- mocker, monkeypatch
-):
+async def test_update_user_demotes_when_default_params_lack_user_role(mocker, monkeypatch):
"""Regression: default_internal_user_params set without a user_role key must
still resolve to the non-admin default on demotion, not silently skip and
leave the user PROXY_ADMIN."""
@@ -2075,9 +2186,7 @@ async def test_update_user_demotes_when_default_params_lack_user_role(
return {"litellm_settings": {"scim_admin_group": "litellm-admins"}}
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
- monkeypatch.setattr(
- "litellm.default_internal_user_params", {"max_budget": 10}, raising=False
- )
+ monkeypatch.setattr("litellm.default_internal_user_params", {"max_budget": 10}, raising=False)
existing_user = mocker.MagicMock()
existing_user.teams = ["litellm-admins"]
@@ -2101,9 +2210,7 @@ async def test_update_user_demotes_when_default_params_lack_user_role(
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.update = AsyncMock(
- return_value=updated_user
- )
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=updated_user)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
@@ -2129,9 +2236,7 @@ async def test_update_user_demotes_when_default_params_lack_user_role(
@pytest.mark.asyncio
-async def test_patch_user_demotes_admin_when_removed_from_scim_admin_group(
- mocker, monkeypatch
-):
+async def test_patch_user_demotes_admin_when_removed_from_scim_admin_group(mocker, monkeypatch):
"""PATCH that drops the admin team from the resulting team set must write the
non-admin default, mirroring the PUT demotion path."""
from litellm.proxy.proxy_server import proxy_config
@@ -2148,11 +2253,7 @@ async def test_patch_user_demotes_admin_when_removed_from_scim_admin_group(
patch_ops = SCIMPatchOp(
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
- Operations=[
- SCIMPatchOperation(
- op="replace", path="groups", value=[{"value": "engineering"}]
- )
- ],
+ Operations=[SCIMPatchOperation(op="replace", path="groups", value=[{"value": "engineering"}])],
)
updated_user = {
@@ -2168,13 +2269,9 @@ async def test_patch_user_demotes_admin_when_removed_from_scim_admin_group(
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.update = AsyncMock(
- return_value=updated_user
- )
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=updated_user)
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
- mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
- return_value=engineering_team
- )
+ mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=engineering_team)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
@@ -2223,11 +2320,7 @@ async def test_patch_user_grants_admin_by_team_display_name(mocker, monkeypatch)
patch_ops = SCIMPatchOp(
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
- Operations=[
- SCIMPatchOperation(
- op="replace", path="groups", value=[{"value": "team-abc-123"}]
- )
- ],
+ Operations=[SCIMPatchOperation(op="replace", path="groups", value=[{"value": "team-abc-123"}])],
)
updated_user = {
@@ -2243,13 +2336,9 @@ async def test_patch_user_grants_admin_by_team_display_name(mocker, monkeypatch)
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.update = AsyncMock(
- return_value=updated_user
- )
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value=updated_user)
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
- mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
- return_value=admin_team
- )
+ mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=admin_team)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
@@ -2302,9 +2391,7 @@ def _scim_admin_prisma(mocker, *, user_teams):
@pytest.mark.asyncio
-async def test_recompute_scim_member_roles_demotes_when_not_in_admin_group(
- mocker, monkeypatch
-):
+async def test_recompute_scim_member_roles_demotes_when_not_in_admin_group(mocker, monkeypatch):
"""The shared recompute helper writes the non-admin default for a member whose
resulting teams no longer include the configured admin group."""
from litellm.proxy.proxy_server import proxy_config
@@ -2324,9 +2411,7 @@ async def test_recompute_scim_member_roles_demotes_when_not_in_admin_group(
@pytest.mark.asyncio
-async def test_recompute_scim_member_roles_grants_when_in_admin_group(
- mocker, monkeypatch
-):
+async def test_recompute_scim_member_roles_grants_when_in_admin_group(mocker, monkeypatch):
"""The shared recompute helper grants PROXY_ADMIN when a member's resulting
teams include the configured admin group."""
from litellm.proxy.proxy_server import proxy_config
@@ -2346,9 +2431,7 @@ async def test_recompute_scim_member_roles_grants_when_in_admin_group(
@pytest.mark.asyncio
-async def test_recompute_scim_member_roles_noop_when_admin_group_unset(
- mocker, monkeypatch
-):
+async def test_recompute_scim_member_roles_noop_when_admin_group_unset(mocker, monkeypatch):
"""With scim_admin_group unset the recompute helper must not touch any role,
preserving current behavior for SCIM group writes."""
from litellm.proxy.proxy_server import proxy_config
@@ -2395,16 +2478,10 @@ async def test_update_group_recomputes_roles_for_changed_members(mocker):
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
- mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
- return_value=existing_team
- )
- mock_prisma_client.db.litellm_teamtable.update = AsyncMock(
- return_value=existing_team
- )
+ mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing_team)
+ mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=existing_team)
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
- return_value=mocker.MagicMock()
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock())
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
@@ -2452,24 +2529,16 @@ async def test_patch_group_recomputes_roles_for_changed_members(mocker):
)
patch_ops = SCIMPatchOp(
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
- Operations=[
- SCIMPatchOperation(op="remove", path="members", value=[{"value": "user1"}])
- ],
+ Operations=[SCIMPatchOperation(op="remove", path="members", value=[{"value": "user1"}])],
)
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
- mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
- return_value=existing_team
- )
- mock_prisma_client.db.litellm_teamtable.update = AsyncMock(
- return_value=existing_team
- )
+ mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing_team)
+ mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=existing_team)
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
- return_value=mocker.MagicMock()
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock())
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
@@ -2519,9 +2588,7 @@ async def test_delete_group_recomputes_roles_for_members(mocker):
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
- mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
- return_value=existing_team
- )
+ mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing_team)
mock_prisma_client.db.litellm_teamtable.delete = AsyncMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=member)
@@ -2552,16 +2619,16 @@ async def test_handle_existing_user_by_email_applies_role_when_admin_group_set(m
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(
- return_value=existing_user
- )
- mock_prisma_client.db.litellm_usertable.update = AsyncMock(
- return_value={"user_id": "new-user-id"}
- )
+ mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=existing_user)
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value={"user_id": "new-user-id"})
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=mocker.MagicMock()),
)
+ mocker.patch(
+ "litellm.proxy.management_endpoints.scim.scim_v2._handle_team_membership_changes",
+ AsyncMock(),
+ )
new_user_request = NewUserRequest(
user_id="new-user-id",
@@ -2592,16 +2659,16 @@ async def test_handle_existing_user_by_email_leaves_role_when_admin_group_unset(
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(
- return_value=existing_user
- )
- mock_prisma_client.db.litellm_usertable.update = AsyncMock(
- return_value={"user_id": "new-user-id"}
- )
+ mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=existing_user)
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value={"user_id": "new-user-id"})
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=mocker.MagicMock()),
)
+ mocker.patch(
+ "litellm.proxy.management_endpoints.scim.scim_v2._handle_team_membership_changes",
+ AsyncMock(),
+ )
new_user_request = NewUserRequest(
user_id="new-user-id",
@@ -2622,9 +2689,7 @@ async def test_handle_existing_user_by_email_leaves_role_when_admin_group_unset(
@pytest.mark.asyncio
-async def test_create_user_existing_email_upsert_demotes_when_admin_group_set(
- mocker, monkeypatch
-):
+async def test_create_user_existing_email_upsert_demotes_when_admin_group_set(mocker, monkeypatch):
"""End-to-end create wiring: a SCIM POST that upserts an existing email while
the user is not in the admin group must write the non-admin default, not leave
a stale PROXY_ADMIN."""
@@ -2650,12 +2715,8 @@ async def test_create_user_existing_email_upsert_demotes_when_admin_group_set(
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
- mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(
- return_value=existing_user
- )
- mock_prisma_client.db.litellm_usertable.update = AsyncMock(
- return_value={"user_id": "returning-user"}
- )
+ mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=existing_user)
+ mock_prisma_client.db.litellm_usertable.update = AsyncMock(return_value={"user_id": "returning-user"})
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
@@ -2669,6 +2730,10 @@ async def test_create_user_existing_email_upsert_demotes_when_admin_group_set(
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=scim_user),
)
+ mocker.patch(
+ "litellm.proxy.management_endpoints.scim.scim_v2._handle_team_membership_changes",
+ AsyncMock(),
+ )
await create_user(user=scim_user)
@@ -2698,9 +2763,7 @@ async def test_create_group_recomputes_roles_for_members(mocker):
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
- return_value=mocker.MagicMock()
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock())
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
@@ -2754,16 +2817,10 @@ async def test_update_group_rename_recomputes_retained_members(mocker):
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
- mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
- return_value=existing_team
- )
- mock_prisma_client.db.litellm_teamtable.update = AsyncMock(
- return_value=existing_team
- )
+ mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing_team)
+ mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=existing_team)
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
- return_value=mocker.MagicMock()
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock())
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
@@ -2808,24 +2865,16 @@ async def test_patch_group_rename_recomputes_retained_members(mocker):
)
patch_ops = SCIMPatchOp(
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
- Operations=[
- SCIMPatchOperation(op="replace", path="displayName", value="Engineering")
- ],
+ Operations=[SCIMPatchOperation(op="replace", path="displayName", value="Engineering")],
)
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
- mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
- return_value=existing_team
- )
- mock_prisma_client.db.litellm_teamtable.update = AsyncMock(
- return_value=existing_team
- )
+ mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing_team)
+ mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=existing_team)
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
- mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
- return_value=mocker.MagicMock()
- )
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock())
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
@@ -2855,3 +2904,161 @@ async def test_patch_group_rename_recomputes_retained_members(mocker):
recompute_mock.assert_awaited_once()
assert set(recompute_mock.call_args[0][1]) == {"user1"}
+
+
+@pytest.mark.asyncio
+async def test_process_group_patch_operations_add_retains_existing_members(
+ mocker, monkeypatch
+):
+ """A SCIM group ``add`` operation must not drop members already in the team.
+
+ Team membership lives in members_with_roles; team creation leaves the legacy
+ ``members`` column empty. Seeding the patch result from that empty column
+ made an ``add`` recompute the member set from scratch and remove everyone
+ already in the team. The result set must be seeded from members_with_roles so
+ existing members survive an add of a new one.
+ """
+
+ async def mock_get_config():
+ return {"litellm_settings": {"scim_upsert_user": True}}
+
+ from litellm.proxy.proxy_server import proxy_config
+
+ monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
+
+ existing_team = LiteLLM_TeamTable(
+ team_id="team-1",
+ team_alias="Team One",
+ members=[], # legacy column intentionally empty, as real teams leave it
+ members_with_roles=[Member(user_id="existing-user", role="user")],
+ )
+ patch_ops = SCIMPatchOp(
+ schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
+ Operations=[
+ SCIMPatchOperation(op="add", path="members", value=[{"value": "new-user"}])
+ ],
+ )
+
+ mock_prisma_client = mocker.MagicMock()
+ mock_prisma_client.db = mocker.MagicMock()
+ mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
+ # new-user already exists in the DB
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
+ return_value=mocker.MagicMock(user_id="new-user")
+ )
+
+ _, final_members = await _process_group_patch_operations(
+ patch_ops=patch_ops,
+ existing_team=existing_team,
+ prisma_client=mock_prisma_client,
+ )
+
+ assert final_members == {"existing-user", "new-user"}
+
+
+@pytest.mark.asyncio
+async def test_process_group_patch_operations_remove_uses_members_with_roles(
+ mocker, monkeypatch
+):
+ """A ``remove`` op must diff against members_with_roles, so removing one
+ member leaves the rest of the team intact rather than emptying it."""
+
+ async def mock_get_config():
+ return {"litellm_settings": {"scim_upsert_user": True}}
+
+ from litellm.proxy.proxy_server import proxy_config
+
+ monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
+
+ existing_team = LiteLLM_TeamTable(
+ team_id="team-1",
+ team_alias="Team One",
+ members=[],
+ members_with_roles=[
+ Member(user_id="keep-user", role="user"),
+ Member(user_id="drop-user", role="user"),
+ ],
+ )
+ patch_ops = SCIMPatchOp(
+ schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
+ Operations=[
+ SCIMPatchOperation(
+ op="remove", path="members", value=[{"value": "drop-user"}]
+ )
+ ],
+ )
+
+ mock_prisma_client = mocker.MagicMock()
+ mock_prisma_client.db = mocker.MagicMock()
+ mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
+ return_value=mocker.MagicMock(user_id="drop-user")
+ )
+
+ _, final_members = await _process_group_patch_operations(
+ patch_ops=patch_ops,
+ existing_team=existing_team,
+ prisma_client=mock_prisma_client,
+ )
+
+ assert final_members == {"keep-user"}
+
+
+@pytest.mark.asyncio
+async def test_get_groups_reports_members_from_members_with_roles(mocker):
+ """GET /Groups must report members from members_with_roles (the source of
+ truth), not the legacy ``members`` column that team creation leaves empty.
+ Reporting an empty member list makes the IdP repeatedly re-provision."""
+ team = LiteLLM_TeamTable(
+ team_id="team-1",
+ team_alias="Team One",
+ members=[], # legacy column empty
+ members_with_roles=[Member(user_id="member-1", role="user")],
+ )
+
+ mock_prisma_client = mocker.MagicMock()
+ mock_prisma_client.db = mocker.MagicMock()
+ mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
+ mock_prisma_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[team])
+ mock_prisma_client.db.litellm_teamtable.count = AsyncMock(return_value=1)
+ mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
+ return_value=mocker.MagicMock(user_id="member-1", user_email="member-1@example.com")
+ )
+
+ mocker.patch(
+ "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
+ AsyncMock(return_value=mock_prisma_client),
+ )
+
+ response = await get_groups(startIndex=1, count=10, filter=None)
+
+ assert [m.value for m in response.Resources[0].members] == ["member-1"]
+
+
+@pytest.mark.asyncio
+async def test_apply_group_patch_updates_does_not_write_legacy_members(mocker):
+ """The group PATCH apply must not write the legacy ``members`` column.
+
+ Membership is reconciled onto the source of truth (members_with_roles and
+ each member's user.teams) separately; writing the legacy column here too
+ would create a second, unread copy of membership that can drift from the
+ source of truth, which is the inconsistency this PR removes.
+ """
+ mock_prisma_client = mocker.MagicMock()
+ mock_prisma_client.db = mocker.MagicMock()
+ mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
+ updated = mocker.MagicMock()
+ mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=updated)
+
+ result = await _apply_group_patch_updates(
+ group_id="team-1",
+ update_data={"team_alias": "Renamed"},
+ prisma_client=mock_prisma_client,
+ )
+
+ assert result is updated
+ mock_prisma_client.db.litellm_teamtable.update.assert_awaited_once()
+ written = mock_prisma_client.db.litellm_teamtable.update.call_args.kwargs["data"]
+ assert "members" not in written
+ assert written["team_alias"] == "Renamed"
diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
index 4936191c344..50817b6a4c2 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
@@ -4106,6 +4106,8 @@ async def test_new_team_max_budget_within_user_limit():
}
mock_prisma.db.litellm_usertable = MagicMock()
mock_prisma.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user)
+ mock_prisma.db.litellm_usertable.update_many = AsyncMock()
+ mock_prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user)
mock_prisma.db.litellm_usertable.update = AsyncMock(return_value=mock_user)
# Mock team membership table
@@ -4247,6 +4249,8 @@ async def test_new_team_org_scoped_budget_bypasses_user_limit():
}
mock_prisma.db.litellm_usertable = MagicMock()
mock_prisma.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user)
+ mock_prisma.db.litellm_usertable.update_many = AsyncMock()
+ mock_prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user)
mock_prisma.db.litellm_usertable.update = AsyncMock(return_value=mock_user)
# Mock team membership table
@@ -4393,6 +4397,8 @@ async def test_new_team_org_scoped_models_bypasses_user_limit():
}
mock_prisma.db.litellm_usertable = MagicMock()
mock_prisma.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user)
+ mock_prisma.db.litellm_usertable.update_many = AsyncMock()
+ mock_prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user)
mock_prisma.db.litellm_usertable.update = AsyncMock(return_value=mock_user)
# Mock team membership table
@@ -7245,6 +7251,8 @@ async def test_new_team_soft_budget_validation(
}
mock_prisma.db.litellm_usertable = MagicMock()
mock_prisma.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user)
+ mock_prisma.db.litellm_usertable.update_many = AsyncMock()
+ mock_prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user)
mock_prisma.db.litellm_usertable.update = AsyncMock(return_value=mock_user)
# Mock team membership table
diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py
index e1856860c8a..c693017e134 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py
@@ -2214,7 +2214,95 @@ class TestCLIKeyRegenerationFlow:
_get_cli_sso_flow_or_raise(login_id="cli-test_1234567890", cache=mock_cache)
assert expired_exc.value.status_code == 400
assert "session not found or expired" in expired_exc.value.detail
- assert "enable_redis_auth_cache" in expired_exc.value.detail
+ assert "configure a Redis cache" in expired_exc.value.detail
+ assert "enable_redis_auth_cache" not in expired_exc.value.detail
+
+ def test_cli_sso_flow_is_redis_authoritative_when_redis_attached(self):
+ """
+ When Redis is attached, the CLI SSO flow must be read from and written to
+ Redis directly, never the in-memory layer. Otherwise the worker that served
+ /sso/cli/start keeps serving its stale in-memory flow and never sees the
+ sso_complete/session_data update another worker wrote, which is exactly the
+ multi-worker failure this fix targets.
+ """
+ from litellm.proxy.management_endpoints.ui_sso import (
+ CLI_SSO_SESSION_TTL_SECONDS,
+ _get_cli_sso_flow_cache_key,
+ _get_cli_sso_flow_or_raise,
+ _set_cli_sso_flow,
+ )
+
+ login_id = "cli-redis_authoritative_1234567890"
+ cache_key = _get_cli_sso_flow_cache_key(login_id)
+ fresh_flow = {"poll_secret_hash": "fresh", "sso_complete": True}
+ stale_flow = {"poll_secret_hash": "stale", "sso_complete": False}
+
+ redis_cache = MagicMock()
+ redis_cache.get_cache.return_value = fresh_flow
+ cache = MagicMock()
+ cache.redis_cache = redis_cache
+ cache.get_cache.return_value = stale_flow
+
+ result = _get_cli_sso_flow_or_raise(login_id=login_id, cache=cache)
+
+ assert result == fresh_flow
+ redis_cache.get_cache.assert_called_once_with(key=cache_key)
+ cache.get_cache.assert_not_called()
+
+ _set_cli_sso_flow(login_id=login_id, cache=cache, flow=fresh_flow)
+
+ redis_cache.set_cache.assert_called_once_with(
+ key=cache_key, value=json.dumps(fresh_flow), ttl=CLI_SSO_SESSION_TTL_SECONDS
+ )
+ cache.set_cache.assert_not_called()
+
+ def test_cli_sso_flow_with_enum_survives_redis_round_trip(self):
+ """
+ RedisCache stores values via str(value) and reads them back through
+ json.loads/ast.literal_eval. A raw flow dict containing a Python enum
+ (session_data.user_role after the SSO callback) produces an unparseable
+ repr, so every worker reading the completed flow from Redis got a
+ SyntaxError and returned 400 "session not found". The flow must survive
+ a real Redis serialization round trip.
+ """
+ from litellm.caching.redis_cache import RedisCache
+ from litellm.proxy._types import LitellmUserRoles
+ from litellm.proxy.management_endpoints.ui_sso import (
+ _get_cli_sso_flow_or_raise,
+ _set_cli_sso_flow,
+ )
+
+ login_id = "cli-enum_round_trip_1234567890"
+ completed_flow = {
+ "poll_secret_hash": "hash",
+ "sso_complete": True,
+ "user_code_verified": False,
+ "session_data": {
+ "user_id": "user-1",
+ "user_role": LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
+ "models": [],
+ "teams": ["team-1"],
+ "team_details": [{"team_id": "team-1", "team_alias": "alias"}],
+ },
+ }
+
+ redis_store: dict = {}
+ redis_cache = MagicMock()
+ redis_cache.set_cache.side_effect = lambda key, value, ttl: redis_store.__setitem__(
+ key, str(value).encode("utf-8")
+ )
+ redis_cache.get_cache.side_effect = lambda key: RedisCache._get_cache_logic(
+ MagicMock(), redis_store.get(key)
+ )
+ cache = MagicMock()
+ cache.redis_cache = redis_cache
+
+ _set_cli_sso_flow(login_id=login_id, cache=cache, flow=completed_flow)
+ flow = _get_cli_sso_flow_or_raise(login_id=login_id, cache=cache)
+
+ assert flow["sso_complete"] is True
+ assert flow["session_data"]["user_role"] == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value
+ assert flow["session_data"]["team_details"] == [{"team_id": "team-1", "team_alias": "alias"}]
@pytest.mark.asyncio
async def test_cli_sso_start_creates_bound_flow(self):
@@ -2228,10 +2316,13 @@ class TestCLIKeyRegenerationFlow:
mock_request = MagicMock(spec=Request)
mock_request.client = SimpleNamespace(host="127.0.0.1")
mock_request.headers = {}
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.increment_cache.return_value = 1
- with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache):
+ with (
+ patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
+ ):
result = await cli_sso_start(request=mock_request)
assert result["login_id"].startswith("cli-")
@@ -2259,10 +2350,13 @@ class TestCLIKeyRegenerationFlow:
mock_request = MagicMock(spec=Request)
mock_request.client = SimpleNamespace(host="127.0.0.1")
mock_request.headers = {}
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.increment_cache.return_value = 31
- with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache):
+ with (
+ patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
+ ):
with pytest.raises(HTTPException) as exc_info:
await cli_sso_start(request=mock_request)
@@ -2281,7 +2375,7 @@ class TestCLIKeyRegenerationFlow:
mock_request.client = SimpleNamespace(host="127.0.0.1")
mock_request.headers = {}
mock_request.base_url = "https://proxy.example.com/"
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.increment_cache.return_value = 1
with (
@@ -2315,7 +2409,7 @@ class TestCLIKeyRegenerationFlow:
mock_request.client = SimpleNamespace(host="127.0.0.1")
mock_request.headers = {}
mock_request.base_url = "https://proxy.example.com/"
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.increment_cache.return_value = 1
with (
@@ -2349,7 +2443,7 @@ class TestCLIKeyRegenerationFlow:
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.example.com/"
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {"poll_secret_hash": "h"}
async def drive(enabled: bool):
@@ -2358,6 +2452,7 @@ class TestCLIKeyRegenerationFlow:
patch("litellm.proxy.proxy_server.premium_user", True),
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
patch(
"litellm.proxy.proxy_server.user_custom_ui_sso_sign_in_handler",
None,
@@ -2525,7 +2620,7 @@ class TestCLIKeyRegenerationFlow:
)
mock_sso_result = {"user_email": "test@example.com", "user_id": "test-user-123"}
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": "poll-secret-hash",
"user_code_hash": "user-code-hash",
@@ -2544,6 +2639,7 @@ class TestCLIKeyRegenerationFlow:
),
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
):
result = await cli_sso_callback(
request=mock_request,
@@ -2568,7 +2664,7 @@ class TestCLIKeyRegenerationFlow:
mock_request.body = AsyncMock(
return_value=b"user_code=ABCD-EFGH&browser_complete_token=browser-token"
)
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
"user_code_hash": _hash_cli_sso_secret(
@@ -2582,6 +2678,7 @@ class TestCLIKeyRegenerationFlow:
with (
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
patch(
"litellm.proxy.common_utils.html_forms.cli_sso_success.render_cli_sso_success_page",
return_value="Success",
@@ -2606,7 +2703,7 @@ class TestCLIKeyRegenerationFlow:
mock_request = MagicMock(spec=Request)
mock_request.body = AsyncMock(return_value=b"user_code=ABCD-EFGH")
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
"user_code_hash": _hash_cli_sso_secret(
@@ -2618,7 +2715,10 @@ class TestCLIKeyRegenerationFlow:
"session_data": {"user_id": "test-user-123"},
}
- with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache):
+ with (
+ patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
+ ):
with pytest.raises(HTTPException) as exc_info:
await cli_sso_complete(
request=mock_request, login_id="cli-session-4567890"
@@ -2640,7 +2740,7 @@ class TestCLIKeyRegenerationFlow:
mock_request.body = AsyncMock(
return_value=b"user_code=ABCD-EFGH&browser_complete_token=browser-token"
)
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
"user_code_hash": _hash_cli_sso_secret(
@@ -2651,7 +2751,10 @@ class TestCLIKeyRegenerationFlow:
"session_data": None,
}
- with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache):
+ with (
+ patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
+ ):
with pytest.raises(HTTPException) as exc_info:
await cli_sso_complete(
request=mock_request, login_id="cli-session-4567890"
@@ -2687,7 +2790,7 @@ class TestCLIKeyRegenerationFlow:
mock_sso_result = {"user_email": "test@example.com", "user_id": "test-user-123"}
# Mock cache
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": "poll-secret-hash",
"user_code_hash": "user-code-hash",
@@ -2709,6 +2812,7 @@ class TestCLIKeyRegenerationFlow:
),
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
patch(
"litellm.proxy.common_utils.html_forms.cli_sso_success.render_cli_sso_success_page",
return_value="Success",
@@ -2769,7 +2873,7 @@ class TestCLIKeyRegenerationFlow:
}
# Mock cache
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
"sso_complete": True,
@@ -2777,7 +2881,10 @@ class TestCLIKeyRegenerationFlow:
"session_data": session_data,
}
- with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache):
+ with (
+ patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
+ ):
# Act - First poll without team_id
result = await cli_poll_key(
key_id=session_key,
@@ -2803,7 +2910,7 @@ class TestCLIKeyRegenerationFlow:
cli_poll_key,
)
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
"sso_complete": True,
@@ -2816,7 +2923,10 @@ class TestCLIKeyRegenerationFlow:
},
}
- with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache):
+ with (
+ patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
+ ):
with pytest.raises(HTTPException) as exc_info:
await cli_poll_key(key_id="cli-session-789123", team_id=None)
@@ -2830,7 +2940,7 @@ class TestCLIKeyRegenerationFlow:
cli_poll_key,
)
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
"sso_complete": True,
@@ -2843,7 +2953,10 @@ class TestCLIKeyRegenerationFlow:
},
}
- with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache):
+ with (
+ patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
+ ):
result = await cli_poll_key(
key_id="cli-session-789123",
team_id=None,
@@ -3011,7 +3124,7 @@ class TestCLIKeyRegenerationFlow:
)
# Mock cache
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
"sso_complete": True,
@@ -3023,6 +3136,7 @@ class TestCLIKeyRegenerationFlow:
with (
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
patch("litellm.proxy.proxy_server.prisma_client"),
patch(
"litellm.proxy.auth.auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token",
@@ -3086,7 +3200,7 @@ class TestCLIKeyRegenerationFlow:
models=["gpt-4"],
max_budget=100.0,
)
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
"sso_complete": True,
@@ -3097,6 +3211,7 @@ class TestCLIKeyRegenerationFlow:
with (
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
patch("litellm.proxy.proxy_server.prisma_client"),
patch(
"litellm.proxy.auth.auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token",
@@ -3142,7 +3257,7 @@ class TestCLIKeyRegenerationFlow:
"models": ["gpt-4"],
"user_email": "unbudgeted@example.com",
}
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
"sso_complete": True,
@@ -3153,6 +3268,7 @@ class TestCLIKeyRegenerationFlow:
with (
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
patch(
"litellm.proxy.auth.auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token",
return_value=mock_jwt_token,
@@ -4082,7 +4198,7 @@ class TestPKCEFunctionality:
mock_request.query_params = {"state": test_state}
# Mock cache with async methods — use dict format (primary path)
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
test_code_verifier = "test_code_verifier_abc123xyz"
mock_cache.async_get_cache = AsyncMock(
return_value={"code_verifier": test_code_verifier}
@@ -4133,7 +4249,7 @@ class TestPKCEFunctionality:
mock_sso.__exit__ = MagicMock(return_value=False)
test_state = "test456"
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.async_set_cache = AsyncMock()
@@ -4657,7 +4773,7 @@ class TestPKCEFunctionality:
from litellm.proxy._types import ProxyException
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.async_get_cache = AsyncMock(return_value=None) # verifier not found
mock_request = MagicMock(spec=Request)
@@ -4783,7 +4899,7 @@ class TestPKCEFunctionality:
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
# Cache returns an integer — unexpected format
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.async_get_cache = AsyncMock(return_value=12345)
mock_cache.async_delete_cache = AsyncMock()
@@ -4825,7 +4941,7 @@ class TestPKCEFunctionality:
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.async_get_cache = AsyncMock(return_value=None) # verifier not found
mock_request = MagicMock(spec=Request)
@@ -4913,7 +5029,7 @@ class TestPKCEFunctionality:
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
# Cache returns an integer — unexpected format
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.async_get_cache = AsyncMock(return_value=12345)
mock_cache.async_delete_cache = AsyncMock()
@@ -4965,7 +5081,7 @@ class TestPKCEFunctionality:
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
legacy_verifier = "legacy_plain_string_verifier_abc123"
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.async_get_cache = AsyncMock(return_value=legacy_verifier)
mock_request = MagicMock(spec=Request)
@@ -6249,7 +6365,7 @@ class TestCliSsoAttributionMetadata:
provider="generic",
team_ids=[],
)
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": "poll-secret-hash",
"user_code_hash": "user-code-hash",
@@ -6266,6 +6382,7 @@ class TestCliSsoAttributionMetadata:
),
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
patch("litellm.proxy.proxy_server.user_custom_sso", None),
):
await ui_sso.cli_sso_callback(
@@ -6290,7 +6407,7 @@ class TestCliSsoAttributionMetadata:
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://internal-proxy.local/"
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": "poll-secret-hash",
"user_code_hash": "user-code-hash",
@@ -6313,6 +6430,7 @@ class TestCliSsoAttributionMetadata:
) as get_user_info_mock,
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
patch("litellm.proxy.proxy_server.user_custom_sso", None),
patch(
"litellm.proxy.proxy_server.general_settings",
@@ -6359,7 +6477,7 @@ class TestCliSsoAttributionMetadata:
"user_id": "test-user-123",
"employment_type": "contractor",
}
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": "poll-secret-hash",
"user_code_hash": "user-code-hash",
@@ -6387,6 +6505,7 @@ class TestCliSsoAttributionMetadata:
),
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
patch("litellm.proxy.proxy_server.user_custom_sso", None),
patch(
"litellm.proxy.common_utils.html_forms.cli_sso_success.render_cli_sso_success_page",
@@ -6428,7 +6547,7 @@ class TestCliSsoAttributionMetadata:
"org": {"cost_center": "CC-42"},
},
}
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
"sso_complete": True,
@@ -6436,7 +6555,10 @@ class TestCliSsoAttributionMetadata:
"session_data": session_data,
}
- with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache):
+ with (
+ patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
+ ):
result = await cli_poll_key(
key_id=session_key,
team_id=None,
@@ -7287,7 +7409,7 @@ async def test_cli_poll_key_tolerates_missing_user_row():
"models": ["gpt-4"],
}
- mock_cache = MagicMock()
+ mock_cache = MagicMock(redis_cache=None)
mock_cache.get_cache.return_value = {
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
"sso_complete": True,
@@ -7299,6 +7421,7 @@ async def test_cli_poll_key_tolerates_missing_user_row():
with (
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
+ patch("litellm.proxy.proxy_server.cli_sso_session_cache", mock_cache),
patch("litellm.proxy.proxy_server.prisma_client"),
patch(
"litellm.proxy.auth.auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token",
@@ -7633,6 +7756,7 @@ async def test_cli_completion_persists_assertion_under_db_user_id():
user_defined_values=None,
prisma_client=MagicMock(),
user_api_key_cache=MagicMock(),
+ cli_sso_session_cache=MagicMock(),
proxy_logging_obj=MagicMock(),
sso_assertion=assertion,
)
diff --git a/tests/test_litellm/proxy/management_helpers/test_management_helpers_utils.py b/tests/test_litellm/proxy/management_helpers/test_management_helpers_utils.py
index dbbdca65cc6..01e5414a469 100644
--- a/tests/test_litellm/proxy/management_helpers/test_management_helpers_utils.py
+++ b/tests/test_litellm/proxy/management_helpers/test_management_helpers_utils.py
@@ -202,7 +202,8 @@ async def test_add_new_member_clones_default_team_budget_id():
"teams": [test_team_id],
"user_role": "internal_user",
}
- mock_prisma_client.db.litellm_usertable.upsert = AsyncMock(
+ mock_prisma_client.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user_response)
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=mock_user_response
)
@@ -305,7 +306,8 @@ async def test_add_new_member_budget_duration_only_clones_default_max_budget():
"teams": ["team-dc"],
"user_role": "internal_user",
}
- mock_prisma_client.db.litellm_usertable.upsert = AsyncMock(
+ mock_prisma_client.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user_response)
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=mock_user_response
)
mock_default_budget_row = MagicMock()
@@ -388,7 +390,8 @@ async def test_add_new_member_no_budget_when_no_default_and_no_max_budget():
"teams": [test_team_id],
"user_role": "internal_user",
}
- mock_prisma_client.db.litellm_usertable.upsert = AsyncMock(
+ mock_prisma_client.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user_response)
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=mock_user_response
)
@@ -455,7 +458,8 @@ async def test_add_new_member_creates_new_budget_when_max_budget_provided():
"teams": [test_team_id],
"user_role": "internal_user",
}
- mock_prisma_client.db.litellm_usertable.upsert = AsyncMock(
+ mock_prisma_client.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user_response)
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=mock_user_response
)
@@ -531,7 +535,8 @@ async def test_add_new_member_persists_budget_duration():
"teams": ["team-dur"],
"user_role": "internal_user",
}
- mock_prisma_client.db.litellm_usertable.upsert = AsyncMock(
+ mock_prisma_client.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user_response)
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=mock_user_response
)
mock_budget_response = MagicMock()
@@ -594,7 +599,8 @@ async def test_add_new_member_persists_budget_duration_without_max_budget():
"teams": ["team-dur2"],
"user_role": "internal_user",
}
- mock_prisma_client.db.litellm_usertable.upsert = AsyncMock(
+ mock_prisma_client.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user_response)
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=mock_user_response
)
mock_budget_response = MagicMock()
@@ -997,3 +1003,116 @@ async def test_attach_object_permission_to_dict_with_none_object_permission_id()
# Verify no database query was made
mock_prisma_client.db.litellm_objectpermissiontable.find_unique.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_add_new_member_appends_team_only_if_absent_for_existing_user():
+ """Adding an existing user to a team must append the team id only if it is
+ not already present.
+
+ add_new_member is the single writer of user.teams for every team add
+ (/team/member_add, /user/new, SSO, SCIM). An unconditional append let
+ repeated or concurrent adds accumulate duplicate team ids in user.teams,
+ which also breaks auth logic that keys off the number of teams a user
+ belongs to. The append must go through a filtered update that no-ops when
+ the team is already present, and it must not fall through to creating a new
+ user row for a user that already exists.
+ """
+ from litellm.proxy._types import LitellmUserRoles
+
+ new_member = Member(user_id="existing-user", role="user")
+ user_api_key_dict = UserAPIKeyAuth(
+ user_id="admin_user", user_role=LitellmUserRoles.PROXY_ADMIN
+ )
+
+ mock_prisma_client = AsyncMock()
+
+ mock_user_after = MagicMock()
+ mock_user_after.model_dump.return_value = {
+ "user_id": "existing-user",
+ "user_email": None,
+ "teams": ["team-1"],
+ "user_role": "internal_user",
+ }
+ mock_prisma_client.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user_after)
+ mock_prisma_client.db.litellm_usertable.update_many = AsyncMock()
+ # no team default budget and no explicit budget -> no team membership row
+ mock_prisma_client.db.litellm_budgettable.find_unique = AsyncMock(return_value=None)
+
+ result_user, _ = await add_new_member(
+ new_member=new_member,
+ max_budget_in_team=None,
+ prisma_client=mock_prisma_client,
+ team_id="team-1",
+ user_api_key_dict=user_api_key_dict,
+ litellm_proxy_admin_name="admin",
+ )
+
+ assert result_user is not None
+ assert result_user.user_id == "existing-user"
+
+ # the append must be a filtered, idempotent update keyed off the team id, so
+ # a repeated or concurrent add of a team the user already has is a no-op
+ mock_prisma_client.db.litellm_usertable.update_many.assert_called_once()
+ where = mock_prisma_client.db.litellm_usertable.update_many.call_args.kwargs["where"]
+ assert where["user_id"] == "existing-user"
+ assert where["NOT"] == {"teams": {"has": "team-1"}}
+ data = mock_prisma_client.db.litellm_usertable.update_many.call_args.kwargs["data"]
+ assert data == {"teams": {"push": ["team-1"]}}
+
+ # upsert (not an unconditional teams push) is what ensures the row exists, so
+ # its update branch must not carry a teams push that would duplicate
+ mock_prisma_client.db.litellm_usertable.upsert.assert_called_once()
+ upsert_update = mock_prisma_client.db.litellm_usertable.upsert.call_args.kwargs["data"]["update"]
+ assert "teams" not in upsert_update
+
+
+@pytest.mark.asyncio
+async def test_add_new_member_creates_missing_user_atomically_via_upsert():
+ """A brand-new user added to a team must be created via an atomic upsert, not
+ a separate existence check followed by create.
+
+ Concurrent provisioning of the same new user (which SCIM group reconciles do)
+ would race a check-then-create into a duplicate-key failure. The upsert seeds
+ teams on create, and the filtered append is a no-op because the team is
+ already present on the freshly created row.
+ """
+ from litellm.proxy._types import LitellmUserRoles
+
+ new_member = Member(user_id="brand-new-user", role="user")
+ user_api_key_dict = UserAPIKeyAuth(
+ user_id="admin_user", user_role=LitellmUserRoles.PROXY_ADMIN
+ )
+
+ mock_prisma_client = AsyncMock()
+
+ mock_created = MagicMock()
+ mock_created.model_dump.return_value = {
+ "user_id": "brand-new-user",
+ "user_email": None,
+ "teams": ["team-1"],
+ "user_role": "internal_user",
+ }
+ mock_prisma_client.db.litellm_usertable.upsert = AsyncMock(return_value=mock_created)
+ mock_prisma_client.db.litellm_usertable.update_many = AsyncMock()
+ mock_prisma_client.db.litellm_usertable.create = AsyncMock()
+ mock_prisma_client.db.litellm_budgettable.find_unique = AsyncMock(return_value=None)
+
+ result_user, _ = await add_new_member(
+ new_member=new_member,
+ max_budget_in_team=None,
+ prisma_client=mock_prisma_client,
+ team_id="team-1",
+ user_api_key_dict=user_api_key_dict,
+ litellm_proxy_admin_name="admin",
+ )
+
+ assert result_user is not None
+ assert result_user.user_id == "brand-new-user"
+
+ # existence is established by an atomic upsert (create-or-update), never a
+ # non-atomic standalone create that could race under concurrent provisioning
+ mock_prisma_client.db.litellm_usertable.upsert.assert_called_once()
+ mock_prisma_client.db.litellm_usertable.create.assert_not_called()
+ create_data = mock_prisma_client.db.litellm_usertable.upsert.call_args.kwargs["data"]["create"]
+ assert create_data["teams"] == ["team-1"]
diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py
index 8a19c6b4406..5effa3073ee 100644
--- a/tests/test_litellm/proxy/test_proxy_server.py
+++ b/tests/test_litellm/proxy/test_proxy_server.py
@@ -2775,6 +2775,69 @@ async def test_load_config_max_budget_env_var_coerced_to_float(tmp_path, monkeyp
litellm.max_budget = original_max_budget
+def test_max_ui_session_budget_default_is_one_dollar():
+ """LIT-4662: the dashboard session budget default is a product decision; the
+ old 0.25 default locked admins out of auto router Test Connection and the
+ playground mid-session with an error that looked like a hardcoded cap."""
+ assert litellm.max_ui_session_budget == 1.0
+
+
+@pytest.mark.asyncio
+async def test_load_config_max_ui_session_budget_applied_and_coerced(tmp_path, monkeypatch):
+ """
+ max_ui_session_budget configured via os.environ resolves to a string;
+ load_config must coerce it to float so every dashboard session key is
+ minted with a numeric max_budget.
+ """
+ from litellm.proxy.proxy_server import ProxyConfig
+
+ monkeypatch.setenv("UI_SESSION_BUDGET", "2.5")
+ test_config = {
+ "model_list": [],
+ "litellm_settings": {"max_ui_session_budget": "os.environ/UI_SESSION_BUDGET"},
+ }
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(yaml.dump(test_config))
+
+ original_budget = litellm.max_ui_session_budget
+ try:
+ proxy_config = ProxyConfig()
+ await proxy_config.load_config(
+ router=MagicMock(), config_file_path=str(config_file)
+ )
+ assert isinstance(litellm.max_ui_session_budget, float)
+ assert litellm.max_ui_session_budget == 2.5
+ finally:
+ litellm.max_ui_session_budget = original_budget
+
+
+@pytest.mark.asyncio
+async def test_load_config_max_ui_session_budget_none_disables_cap(tmp_path):
+ """
+ max_ui_session_budget: null in config disables the dashboard session cap
+ entirely (session keys minted with no max_budget); load_config must pass
+ None through instead of raising on float(None).
+ """
+ from litellm.proxy.proxy_server import ProxyConfig
+
+ test_config = {
+ "model_list": [],
+ "litellm_settings": {"max_ui_session_budget": None},
+ }
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text(yaml.dump(test_config))
+
+ original_budget = litellm.max_ui_session_budget
+ try:
+ proxy_config = ProxyConfig()
+ await proxy_config.load_config(
+ router=MagicMock(), config_file_path=str(config_file)
+ )
+ assert litellm.max_ui_session_budget is None
+ finally:
+ litellm.max_ui_session_budget = original_budget
+
+
@pytest.mark.asyncio
async def test_load_config_default_internal_user_params_max_budget_scientific_notation(tmp_path):
"""
@@ -9235,6 +9298,85 @@ def test_general_settings_ui_fields_are_db_overridable():
)
+@pytest.mark.asyncio
+async def test_update_config_field_max_ui_session_budget_sets_live_value(monkeypatch):
+ """LIT-4662: the dashboard session budget is editable from the Admin UI General tab.
+ A Dollar field must accept values above 1 (the old Float type capped at 1, which cannot
+ express a dollar budget), apply live via setattr, and persist under litellm_settings."""
+ from unittest.mock import MagicMock
+
+ import litellm.proxy.proxy_server as ps
+ from litellm.proxy._types import (
+ ConfigFieldUpdate,
+ LitellmUserRoles,
+ UserAPIKeyAuth,
+ )
+ from litellm.proxy.proxy_server import update_config_general_settings
+
+ saved: dict = {}
+
+ async def fake_get_config():
+ return {"litellm_settings": {}}
+
+ async def fake_save_config(new_config=None):
+ saved.update(new_config or {})
+
+ monkeypatch.setattr(ps.proxy_config, "get_config", fake_get_config)
+ monkeypatch.setattr(ps.proxy_config, "save_config", fake_save_config)
+ monkeypatch.setattr(ps, "prisma_client", MagicMock())
+ monkeypatch.setattr(litellm, "store_audit_logs", False)
+ monkeypatch.setattr(litellm, "max_ui_session_budget", 1.0)
+
+ admin = UserAPIKeyAuth(api_key="k", user_id="a", user_role=LitellmUserRoles.PROXY_ADMIN)
+ await update_config_general_settings(
+ data=ConfigFieldUpdate(
+ field_name="max_ui_session_budget",
+ field_value=25.0,
+ config_type="general_settings",
+ ),
+ user_api_key_dict=admin,
+ )
+
+ assert litellm.max_ui_session_budget == 25.0
+ assert saved["litellm_settings"]["max_ui_session_budget"] == 25.0
+
+
+@pytest.mark.parametrize("bad_value", [True, "abc", -1, 0, [2.5]])
+def test_validate_max_ui_session_budget_rejects_malformed(bad_value):
+ """A Dollar field accepts only positive numbers; zero would block every dashboard
+ LLM call at mint and non-numerics would break session key generation."""
+ from fastapi import HTTPException
+
+ from litellm.proxy.proxy_server import _validate_general_settings_ui_litellm_value
+
+ with pytest.raises(HTTPException) as exc_info:
+ _validate_general_settings_ui_litellm_value("max_ui_session_budget", bad_value)
+ assert exc_info.value.status_code == 400
+
+
+@pytest.mark.parametrize("empty_value", [None, ""])
+def test_validate_max_ui_session_budget_empty_restores_default(empty_value):
+ """Clearing the field in the UI restores the shipped $1 default rather than None;
+ None would silently remove the session spend guardrail (unlimited budget), which
+ must stay a deliberate config.yaml act (max_ui_session_budget: null)."""
+ from litellm.proxy.proxy_server import _validate_general_settings_ui_litellm_value
+
+ assert _validate_general_settings_ui_litellm_value("max_ui_session_budget", empty_value) == 1.0
+
+
+def test_general_settings_ui_defaults_unchanged_for_existing_fields():
+ """The spec-default mechanism added for max_ui_session_budget must not change what
+ clearing the pre-existing fields restores (None for Float/Select, False for Boolean)."""
+ from litellm.proxy.proxy_server import (
+ _GENERAL_SETTINGS_UI_LITELLM_FIELDS,
+ _general_settings_ui_litellm_default,
+ )
+
+ assert _general_settings_ui_litellm_default(_GENERAL_SETTINGS_UI_LITELLM_FIELDS["budget_exceeded_throttle_percentage"]) is None
+ assert _general_settings_ui_litellm_default(_GENERAL_SETTINGS_UI_LITELLM_FIELDS["enable_anthropic_prompt_caching"]) is False
+ assert _general_settings_ui_litellm_default(_GENERAL_SETTINGS_UI_LITELLM_FIELDS["anthropic_prompt_caching_ttl"]) is None
+
+
@pytest.mark.parametrize(
"field_name, db_value",
[
diff --git a/tests/test_litellm/proxy/test_redis_auth_cache_flag.py b/tests/test_litellm/proxy/test_redis_auth_cache_flag.py
index d0cb5ec5465..849d5494c6e 100644
--- a/tests/test_litellm/proxy/test_redis_auth_cache_flag.py
+++ b/tests/test_litellm/proxy/test_redis_auth_cache_flag.py
@@ -54,8 +54,8 @@ def _patched_init_cache(litellm_settings: dict, cache_params: dict):
_FakeRedisCache (passes the isinstance guard in _init_cache).
3. Extracts enable_redis_auth_cache from litellm_settings and passes it
as the second argument to _init_cache (matching production behaviour).
- 4. Yields (user_api_key_cache, spend_counter_cache) after calling
- _init_cache, then restores everything.
+ 4. Yields (user_api_key_cache, spend_counter_cache, cli_sso_session_cache)
+ after calling _init_cache, then restores everything.
"""
fake_redis = _FakeRedisCache()
@@ -64,19 +64,21 @@ def _patched_init_cache(litellm_settings: dict, cache_params: dict):
fresh_user_cache = DualCache()
fresh_spend_cache = DualCache()
+ fresh_cli_sso_cache = DualCache()
enable_redis_auth_cache = litellm_settings.get("enable_redis_auth_cache", False)
with (
patch.object(ps, "user_api_key_cache", fresh_user_cache),
patch.object(ps, "spend_counter_cache", fresh_spend_cache),
+ patch.object(ps, "cli_sso_session_cache", fresh_cli_sso_cache),
patch.object(ps, "llm_router", None),
# Cache is locally imported inside _init_cache: patch it at source.
patch("litellm.Cache", return_value=mock_litellm_cache),
):
litellm.cache = None
ps.ProxyConfig()._init_cache(cache_params, enable_redis_auth_cache)
- yield fresh_user_cache, fresh_spend_cache
+ yield fresh_user_cache, fresh_spend_cache, fresh_cli_sso_cache
# ---------------------------------------------------------------------------
@@ -90,7 +92,7 @@ class TestRedisAuthCacheFlag:
with _patched_init_cache(
litellm_settings={"enable_redis_auth_cache": True},
cache_params={"type": "redis", "host": "localhost", "port": 6379},
- ) as (user_cache, _):
+ ) as (user_cache, _, _cli_sso_cache):
assert user_cache.redis_cache is not None, (
"Redis should be attached to user_api_key_cache when "
"enable_redis_auth_cache=True"
@@ -101,7 +103,7 @@ class TestRedisAuthCacheFlag:
with _patched_init_cache(
litellm_settings={"enable_redis_auth_cache": False},
cache_params={"type": "redis", "host": "localhost", "port": 6379},
- ) as (user_cache, _):
+ ) as (user_cache, _, _cli_sso_cache):
assert user_cache.redis_cache is None, (
"user_api_key_cache must remain in-memory-only when "
"enable_redis_auth_cache=False"
@@ -112,7 +114,7 @@ class TestRedisAuthCacheFlag:
with _patched_init_cache(
litellm_settings={},
cache_params={"type": "redis", "host": "localhost", "port": 6379},
- ) as (user_cache, _):
+ ) as (user_cache, _, _cli_sso_cache):
assert user_cache.redis_cache is None, (
"user_api_key_cache must remain in-memory-only when "
"enable_redis_auth_cache is absent from litellm_settings"
@@ -129,7 +131,7 @@ class TestRedisAuthCacheFlag:
with _patched_init_cache(
litellm_settings=ls,
cache_params={"type": "redis", "host": "localhost", "port": 6379},
- ) as (_, spend_cache):
+ ) as (_, spend_cache, _cli_sso_cache):
assert spend_cache.redis_cache is not None, (
f"spend_counter_cache must always get Redis "
f"(enable_redis_auth_cache={flag_value!r})"
@@ -140,6 +142,28 @@ class TestRedisAuthCacheFlag:
with _patched_init_cache(
litellm_settings={"enable_redis_auth_cache": False},
cache_params={"type": "redis", "host": "localhost", "port": 6379},
- ) as (user_cache, spend_cache):
+ ) as (user_cache, spend_cache, _cli_sso_cache):
assert spend_cache.redis_cache is not None
assert user_cache.redis_cache is None
+
+ def test_cli_sso_session_cache_always_gets_redis_regardless_of_flag(self):
+ """
+ cli_sso_session_cache must receive Redis regardless of the auth-cache
+ flag so that `lite login` works on multi-worker deployments without
+ enable_redis_auth_cache (regression for the CLI SSO "Invalid CLI login
+ session" bug)
+ """
+ for flag_value in (True, False, None):
+ ls = (
+ {"enable_redis_auth_cache": flag_value}
+ if flag_value is not None
+ else {}
+ )
+ with _patched_init_cache(
+ litellm_settings=ls,
+ cache_params={"type": "redis", "host": "localhost", "port": 6379},
+ ) as (_, _, cli_sso_cache):
+ assert cli_sso_cache.redis_cache is not None, (
+ f"cli_sso_session_cache must always get Redis "
+ f"(enable_redis_auth_cache={flag_value!r})"
+ )
diff --git a/ui/litellm-dashboard/knip.json b/ui/litellm-dashboard/knip.json
index afed6b0f90e..48b39e8122d 100644
--- a/ui/litellm-dashboard/knip.json
+++ b/ui/litellm-dashboard/knip.json
@@ -1,7 +1,7 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["scripts/**/*.{ts,mjs}", "src/components/ui/**/*.{ts,tsx}"],
- "project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}", "scripts/**/*.{ts,mjs}", "e2e_tests/**/*.ts"],
+ "project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}", "scripts/**/*.{ts,mjs}"],
"ignore": ["src/lib/http/schema.d.ts"],
"ignoreDependencies": [
"openapi-typescript",
@@ -10,14 +10,6 @@
"tailwindcss",
"tw-animate-css"
],
- "playwright": {
- "config": [
- "e2e_tests/playwright.config.ts",
- "e2e_tests/serverRootPath.config.ts",
- "e2e_tests/migration.serverRootPath.config.ts"
- ],
- "entry": ["e2e_tests/**/*.spec.ts", "e2e_tests/**/*.setup.ts", "e2e_tests/globalSetup.ts"]
- },
"vitest": {
"config": ["vitest.config.ts"]
},
diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json
index 742c1e4a63f..14c8f0fdc18 100644
--- a/ui/litellm-dashboard/package-lock.json
+++ b/ui/litellm-dashboard/package-lock.json
@@ -47,7 +47,6 @@
},
"devDependencies": {
"@eslint/js": "9.39.2",
- "@playwright/test": "1.58.1",
"@tailwindcss/forms": "0.5.11",
"@tailwindcss/postcss": "4.3.2",
"@testing-library/dom": "10.4.1",
@@ -2723,8 +2722,9 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz",
"integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==",
- "devOptional": true,
"license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
"dependencies": {
"playwright": "1.58.1"
},
@@ -7361,7 +7361,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -10948,8 +10947,9 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
- "devOptional": true,
"license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
"dependencies": {
"playwright-core": "1.58.1"
},
@@ -10967,8 +10967,9 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
- "devOptional": true,
"license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
"bin": {
"playwright-core": "cli.js"
},
diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json
index 0f54c536297..5add2ad4e9e 100644
--- a/ui/litellm-dashboard/package.json
+++ b/ui/litellm-dashboard/package.json
@@ -14,10 +14,6 @@
"test:coverage": "vitest run --coverage",
"format": "prettier --write .",
"format:check": "prettier --check .",
- "e2e": "playwright test --config e2e_tests/playwright.config.ts",
- "e2e:ui": "playwright test --ui --config e2e_tests/playwright.config.ts",
- "e2e:migration": "playwright test e2e_tests/tests/migration/migratedPages.spec.ts --config e2e_tests/playwright.config.ts",
- "e2e:migration:root": "playwright test --config e2e_tests/migration.serverRootPath.config.ts",
"knip": "knip",
"knip:ci": "knip --exclude exports,nsExports,types,nsTypes,enumMembers,classMembers,duplicates",
"knip:fix": "knip --fix",
@@ -63,7 +59,6 @@
},
"devDependencies": {
"@eslint/js": "9.39.2",
- "@playwright/test": "1.58.1",
"@tailwindcss/forms": "0.5.11",
"@tailwindcss/postcss": "4.3.2",
"@testing-library/dom": "10.4.1",
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx
new file mode 100644
index 00000000000..c4cfa98b2a1
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx
@@ -0,0 +1,101 @@
+import { renderWithProviders, screen, within } from "../../../../../tests/test-utils";
+import userEvent from "@testing-library/user-event";
+import { vi } from "vitest";
+import GeneralSettings from "./general_settings";
+import { deleteConfigFieldSetting, getGeneralSettingsCall, updateConfigFieldSetting } from "@/components/networking";
+
+vi.mock("@/components/networking", () => ({
+ getGeneralSettingsCall: vi.fn(),
+ updateConfigFieldSetting: vi.fn().mockResolvedValue({}),
+ deleteConfigFieldSetting: vi.fn().mockResolvedValue({}),
+}));
+
+vi.mock("@/components/router_settings", () => ({ default: () => null }));
+vi.mock("@/components/Settings/RouterSettings/Fallbacks/Fallbacks", () => ({ default: () => null }));
+vi.mock("@/components/routing_groups", () => ({ default: () => null }));
+
+// Mirrors the /config/list ordering: the two prompt-caching rows sit between the
+// General-tab rows in the unfiltered response but are filtered out of the General
+// tab's table, so any index-based lookup into the unfiltered array reads the wrong
+// row for every field rendered after them.
+const SETTINGS_FIXTURE = [
+ {
+ field_name: "budget_exceeded_throttle_percentage",
+ field_type: "Float",
+ field_value: null,
+ field_description: "throttle fraction",
+ stored_in_db: null,
+ field_default_value: null,
+ },
+ {
+ field_name: "enable_anthropic_prompt_caching",
+ field_type: "Boolean",
+ field_value: true,
+ field_description: "prompt caching toggle",
+ stored_in_db: true,
+ field_tab: "prompt_caching",
+ field_default_value: false,
+ },
+ {
+ field_name: "anthropic_prompt_caching_ttl",
+ field_type: "Select",
+ field_value: "5m",
+ field_description: "prompt caching ttl",
+ stored_in_db: true,
+ field_options: ["5m", "1h"],
+ field_tab: "prompt_caching",
+ field_default_value: null,
+ },
+ {
+ field_name: "max_ui_session_budget",
+ field_type: "Dollar",
+ field_value: 7.5,
+ field_description: "dashboard session budget",
+ stored_in_db: true,
+ field_default_value: 1.0,
+ },
+];
+
+const settingsRow = async (fieldName: string) => {
+ const cell = await screen.findByText(fieldName);
+ const row = cell.closest("tr");
+ expect(row).not.toBeNull();
+ return row as HTMLElement;
+};
+
+describe("GeneralSettings General tab", () => {
+ beforeEach(() => {
+ vi.mocked(getGeneralSettingsCall).mockResolvedValue([...SETTINGS_FIXTURE.map((s) => ({ ...s }))]);
+ vi.mocked(updateConfigFieldSetting).mockClear();
+ vi.mocked(deleteConfigFieldSetting).mockClear();
+ });
+
+ it("updates max_ui_session_budget with its own value, not the value at its filtered index", async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await user.click(screen.getByText("General"));
+ const row = await settingsRow("max_ui_session_budget");
+
+ await user.click(within(row).getByRole("button", { name: /update/i }));
+
+ expect(updateConfigFieldSetting).toHaveBeenCalledWith("token", "max_ui_session_budget", 7.5);
+ });
+
+ it("reset shows the field's default value instead of an empty input", async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await user.click(screen.getByText("General"));
+ const row = await settingsRow("max_ui_session_budget");
+ expect(within(row).getByRole("spinbutton")).toHaveValue("7.50");
+
+ const actionCell = row.querySelectorAll("td")[3];
+ const resetIcon = actionCell.querySelector("svg");
+ expect(resetIcon).not.toBeNull();
+ await user.click(resetIcon as unknown as Element);
+
+ expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "max_ui_session_budget");
+ expect(within(row).getByRole("spinbutton")).toHaveValue("1.00");
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx
index dda7a23a8d4..fa3447e0cbf 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx
@@ -41,6 +41,7 @@ export interface generalSettingsItem {
stored_in_db: boolean | null;
field_options?: string[] | null;
field_tab?: string | null;
+ field_default_value?: any;
}
const SettingValueEditor: React.FC<{
@@ -75,6 +76,17 @@ const SettingValueEditor: React.FC<{
/>
);
}
+ if (setting.field_type === "Dollar") {
+ return (
+ onChange(setting.field_name, newValue)}
+ />
+ );
+ }
if (setting.field_type === "Select") {
return (
= ({ accessToken, user
setGeneralSettings(updatedSettings);
};
- const handleUpdateField = (fieldName: string, idx: number) => {
+ const handleUpdateField = (fieldName: string) => {
if (!accessToken) {
return;
}
- let fieldValue = generalSettings[idx].field_value;
+ let fieldValue = generalSettings.find((setting) => setting.field_name === fieldName)?.field_value;
if (fieldValue == null || fieldValue == undefined) {
return;
@@ -194,7 +206,7 @@ const GeneralSettings: React.FC = ({ accessToken, user
}
};
- const handleResetField = (fieldName: string, idx: number) => {
+ const handleResetField = (fieldName: string) => {
if (!accessToken) {
return;
}
@@ -204,7 +216,9 @@ const GeneralSettings: React.FC = ({ accessToken, user
// update value in state
const updatedSettings = generalSettings.map((setting) =>
- setting.field_name === fieldName ? { ...setting, stored_in_db: null, field_value: null } : setting,
+ setting.field_name === fieldName
+ ? { ...setting, stored_in_db: null, field_value: setting.field_default_value ?? null }
+ : setting,
);
setGeneralSettings(updatedSettings);
} catch (error) {
@@ -281,8 +295,8 @@ const GeneralSettings: React.FC = ({ accessToken, user
)}
-
- handleResetField(value.field_name, index)}>
+
+ handleResetField(value.field_name)}>
Reset
diff --git a/ui/litellm-dashboard/tsconfig.json b/ui/litellm-dashboard/tsconfig.json
index 8ca1752013a..5ca97e3e9db 100644
--- a/ui/litellm-dashboard/tsconfig.json
+++ b/ui/litellm-dashboard/tsconfig.json
@@ -23,5 +23,5 @@
"target": "ES2017"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
- "exclude": ["node_modules", "e2e_tests", "scripts"]
+ "exclude": ["node_modules", "scripts"]
}
diff --git a/ui/litellm-dashboard/vitest.config.ts b/ui/litellm-dashboard/vitest.config.ts
index e1c58a0c2b5..da4734eeaf2 100644
--- a/ui/litellm-dashboard/vitest.config.ts
+++ b/ui/litellm-dashboard/vitest.config.ts
@@ -32,7 +32,6 @@ const config: ViteUserConfig = {
"**/*.spec.*",
"tests/**",
- "e2e_tests/**",
"node_modules/**",
".next/**",
@@ -44,7 +43,7 @@ const config: ViteUserConfig = {
"next.config.*",
],
},
- exclude: ["e2e_tests/**", "node_modules/**"],
+ exclude: ["node_modules/**"],
include: ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.test.ts", "tests/**/*.test.tsx"],
},
resolve: {