Merge branch 'litellm_internal_staging' into feature/improve-gigachat-provider

This commit is contained in:
KnyazSh 2026-07-22 21:26:29 +00:00
commit 8ff314f3de
78 changed files with 2472 additions and 509 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = {

View file

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

View file

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

View file

@ -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=(

View file

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

View file

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

View file

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

111
tests/e2e/ui/package-lock.json generated Normal file
View file

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

16
tests/e2e/ui/package.json Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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="<html>Success</html>",
@ -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="<html>Success</html>",
@ -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,
)

View file

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

View file

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

View file

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

View file

@ -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"]
},

View file

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

View file

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

View file

@ -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(<GeneralSettings accessToken="token" userRole="Admin" userID="user" />);
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(<GeneralSettings accessToken="token" userRole="Admin" userID="user" />);
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");
});
});

View file

@ -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 (
<InputNumber
min={0.01}
step={0.25}
prefix="$"
value={setting.field_value}
onChange={(newValue) => onChange(setting.field_name, newValue)}
/>
);
}
if (setting.field_type === "Select") {
return (
<AntdSelect
@ -171,12 +183,12 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ 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<GeneralSettingsPageProps> = ({ accessToken, user
}
};
const handleResetField = (fieldName: string, idx: number) => {
const handleResetField = (fieldName: string) => {
if (!accessToken) {
return;
}
@ -204,7 +216,9 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ 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<GeneralSettingsPageProps> = ({ accessToken, user
)}
</TableCell>
<TableCell>
<Button onClick={() => handleUpdateField(value.field_name, index)}>Update</Button>
<Icon icon={TrashIcon} color="red" onClick={() => handleResetField(value.field_name, index)}>
<Button onClick={() => handleUpdateField(value.field_name)}>Update</Button>
<Icon icon={TrashIcon} color="red" onClick={() => handleResetField(value.field_name)}>
Reset
</Icon>
</TableCell>

View file

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

View file

@ -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: {