From 91675a02d737ace9d3d6cbf4c388ba4c9bb3406b Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 10 Aug 2026 12:57:17 -0700 Subject: [PATCH 01/11] fix(email): handle trailing slash in PROXY_BASE_URL for invitation links Problem: When PROXY_BASE_URL has a trailing slash (e.g., http://example.com/), the invitation link becomes http://example.com//ui/onboarding?invitation_id=... with a double slash, which can cause routing issues and direct users to the swagger page instead of the onboarding page. Additionally, if invitation creation fails for any reason (no user_id, prisma unavailable, etc.), the email falls back to base_url which points to swagger at the root path instead of the onboarding page. Changes: - Strip trailing slashes from base_url before constructing invitation links - Add comprehensive debug/warning logging to trace invitation link construction - Log when fallbacks occur so admins can diagnose why users land on swagger - Add info-level logging for successful invitation link creation This ensures: 1. No double-slash URLs regardless of PROXY_BASE_URL format 2. Clear visibility when invitation creation fails and why 3. Easier debugging of email delivery issues --- .../send_emails/base_email.py | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py index e7898cac5658..27428dd880f1 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py @@ -824,12 +824,21 @@ async def _get_invitation_link(self, user_id: Optional[str], base_url: str) -> s """ Get invitation link for the user """ + verbose_proxy_logger.debug( + f"Getting invitation link for user_id: {user_id}, base_url: {base_url}" + ) + # Early validation if not user_id: - verbose_proxy_logger.debug("No user_id provided for invitation link") + verbose_proxy_logger.warning( + "No user_id provided for invitation link. Email will link to base URL instead of onboarding page" + ) return base_url if not await self._is_prisma_client_available(): + verbose_proxy_logger.warning( + "Prisma client not available. Email will link to base URL instead of onboarding page" + ) return base_url # Wait for any concurrent invitation creation to complete @@ -839,11 +848,15 @@ async def _get_invitation_link(self, user_id: Optional[str], base_url: str) -> s invitation = await self._get_or_create_invitation(user_id) if not invitation: verbose_proxy_logger.warning( - f"Failed to get/create invitation for user_id: {user_id}" + f"Failed to get/create invitation for user_id: {user_id}. Email will link to base URL instead of onboarding page" ) return base_url - return self._construct_invitation_link(invitation.id, base_url) + invitation_link = self._construct_invitation_link(invitation.id, base_url) + verbose_proxy_logger.info( + f"Successfully created invitation link for user_id: {user_id}: {invitation_link}" + ) + return invitation_link async def _is_prisma_client_available(self) -> bool: """Check if Prisma client is available""" @@ -921,7 +934,12 @@ def _construct_invitation_link(self, invitation_id: str, base_url: str) -> str: # http://localhost:4000/ui/onboarding?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b """ - return f"{base_url}/ui/onboarding?invitation_id={invitation_id}" + base_url = base_url.rstrip("/") + invitation_link = f"{base_url}/ui/onboarding?invitation_id={invitation_id}" + verbose_proxy_logger.debug( + f"Constructed invitation link: {invitation_link} from base_url: {base_url}, invitation_id: {invitation_id}" + ) + return invitation_link async def send_email( self, From 062797b979ad30d9f46a813e94085a695f19e892 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 10 Aug 2026 13:08:24 -0700 Subject: [PATCH 02/11] feat(ui): add eye icon to toggle credential visibility in email alerts Add eye/eye-off toggle buttons to all email alert configuration fields so admins can reveal masked credentials to verify values without editing Changes: - Add useState hook to track visibility state per field - Add Eye/EyeOff icons from lucide-react - Wrap input in relative container with toggle button - Toggle input type between password and text - Add test coverage for visibility toggle behavior --- .../src/components/email_settings.test.tsx | 20 +++++++++ .../src/components/email_settings.tsx | 43 +++++++++++++++---- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/ui/litellm-dashboard/src/components/email_settings.test.tsx b/ui/litellm-dashboard/src/components/email_settings.test.tsx index bd09ca0abc4e..87a8e5311e38 100644 --- a/ui/litellm-dashboard/src/components/email_settings.test.tsx +++ b/ui/litellm-dashboard/src/components/email_settings.test.tsx @@ -124,4 +124,24 @@ describe("EmailSettings", () => { expect(screen.getByText("email event settings")).toBeInTheDocument(); }); + + it("toggles credential visibility when eye icon is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const passwordInput = inputNamed("SMTP_PASSWORD"); + expect(passwordInput).toHaveAttribute("type", "password"); + + const showButtons = screen.getAllByLabelText("Show credential"); + expect(showButtons.length).toBeGreaterThan(0); + + await user.click(showButtons[0]); + + expect(passwordInput).toHaveAttribute("type", "text"); + + const hideButton = screen.getByLabelText("Hide credential"); + await user.click(hideButton); + + expect(passwordInput).toHaveAttribute("type", "password"); + }); }); diff --git a/ui/litellm-dashboard/src/components/email_settings.tsx b/ui/litellm-dashboard/src/components/email_settings.tsx index 85fb4ce1ca3a..a9426d6abdfb 100644 --- a/ui/litellm-dashboard/src/components/email_settings.tsx +++ b/ui/litellm-dashboard/src/components/email_settings.tsx @@ -1,7 +1,8 @@ -import React from "react"; +import React, { useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { Eye, EyeOff } from "lucide-react"; import NotificationManager from "./molecules/notifications_manager"; import { serviceHealthCheck, setCallbacksCall } from "./networking"; import { EmailEventSettings } from "./email_events"; @@ -30,6 +31,15 @@ const FIELD_HELP: Record = { const PREMIUM_ONLY_FIELDS = ["EMAIL_LOGO_URL", "EMAIL_SUPPORT_CONTACT"]; const EmailSettings: React.FC = ({ accessToken, premiumUser, alerts }) => { + const [visibleFields, setVisibleFields] = useState>({}); + + const toggleFieldVisibility = (key: string) => { + setVisibleFields((prev) => ({ + ...prev, + [key]: !prev[key], + })); + }; + const handleSaveEmailSettings = async () => { if (!accessToken) { return; @@ -99,6 +109,7 @@ const EmailSettings: React.FC = ({ accessToken, premiumUser,
{Object.entries(alert.variables ?? {}).map(([key, value]) => { const isLocked = !premiumUser && PREMIUM_ONLY_FIELDS.includes(key); + const isVisible = visibleFields[key] || false; return (
{isLocked ? ( @@ -113,13 +124,29 @@ const EmailSettings: React.FC = ({ accessToken, premiumUser, ) : (

{key}

)} - +
+ + +
{FIELD_HELP[key]}
); From 00eda73929e06ac90ed2c38629aff1cfc1e65216 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 10 Aug 2026 13:19:39 -0700 Subject: [PATCH 03/11] refactor(ui): use InputGroup component for eye icon in email alerts Replace absolute positioning with shadcn InputGroup components for proper integration of the eye icon inside the input box Changes: - Use InputGroup, InputGroupInput, InputGroupAddon, InputGroupButton - Eye icon now properly integrated inside input border - Remove custom absolute positioning hack - Update test to handle input-group-control data-slot selector --- .../src/components/email_settings.test.tsx | 4 +- .../src/components/email_settings.tsx | 37 +++++++++---------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/ui/litellm-dashboard/src/components/email_settings.test.tsx b/ui/litellm-dashboard/src/components/email_settings.test.tsx index 87a8e5311e38..198577e405e2 100644 --- a/ui/litellm-dashboard/src/components/email_settings.test.tsx +++ b/ui/litellm-dashboard/src/components/email_settings.test.tsx @@ -29,7 +29,9 @@ const alerts = [ { name: "slack", variables: { SLACK_WEBHOOK_URL: "https://hooks.example.com" } }, ]; -const inputNamed = (name: string) => document.querySelector(`input[name="${name}"]`)!; +const inputNamed = (name: string) => + document.querySelector(`input[name="${name}"][data-slot="input-group-control"]`) || + document.querySelector(`input[name="${name}"]`)!; describe("EmailSettings", () => { beforeEach(() => { diff --git a/ui/litellm-dashboard/src/components/email_settings.tsx b/ui/litellm-dashboard/src/components/email_settings.tsx index a9426d6abdfb..2c5b46ebdbd3 100644 --- a/ui/litellm-dashboard/src/components/email_settings.tsx +++ b/ui/litellm-dashboard/src/components/email_settings.tsx @@ -1,7 +1,12 @@ import React, { useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group"; import { Eye, EyeOff } from "lucide-react"; import NotificationManager from "./molecules/notifications_manager"; import { serviceHealthCheck, setCallbacksCall } from "./networking"; @@ -124,29 +129,23 @@ const EmailSettings: React.FC = ({ accessToken, premiumUser, ) : (

{key}

)} -
- + - -
+ + toggleFieldVisibility(key)} + aria-label={isVisible ? "Hide credential" : "Show credential"} + > + {isVisible ? : } + + +
{FIELD_HELP[key]}
); From f52e1d6d15f9babbcb9e9ecb71d291aa25b6c83f Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 10 Aug 2026 13:19:54 -0700 Subject: [PATCH 04/11] security: remove invitation_id from logs to prevent credential exposure Logging the full invitation URL with invitation_id exposes a bearer credential. If logs are accessible to unauthorized principals, they could use the logged invitation_id to claim the invitation, set a password, and hijack the account Removed: - Debug log showing full invitation_link with invitation_id - Debug log showing base_url and invitation_id parameters - Info log showing full invitation_link Kept only: - Info log confirming invitation creation succeeded (no credential) - Warning logs for error paths (no credential exposure) --- .../enterprise_callbacks/send_emails/base_email.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py index 27428dd880f1..3d74c027d8b0 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py @@ -824,10 +824,6 @@ async def _get_invitation_link(self, user_id: Optional[str], base_url: str) -> s """ Get invitation link for the user """ - verbose_proxy_logger.debug( - f"Getting invitation link for user_id: {user_id}, base_url: {base_url}" - ) - # Early validation if not user_id: verbose_proxy_logger.warning( @@ -854,7 +850,7 @@ async def _get_invitation_link(self, user_id: Optional[str], base_url: str) -> s invitation_link = self._construct_invitation_link(invitation.id, base_url) verbose_proxy_logger.info( - f"Successfully created invitation link for user_id: {user_id}: {invitation_link}" + f"Successfully created invitation link for user_id: {user_id}" ) return invitation_link @@ -932,13 +928,10 @@ def _construct_invitation_link(self, invitation_id: str, base_url: str) -> str: """ Construct invitation link for the user - # http://localhost:4000/ui/onboarding?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b + # http://localhost:4000/ui/onboarding/?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b """ base_url = base_url.rstrip("/") - invitation_link = f"{base_url}/ui/onboarding?invitation_id={invitation_id}" - verbose_proxy_logger.debug( - f"Constructed invitation link: {invitation_link} from base_url: {base_url}, invitation_id: {invitation_id}" - ) + invitation_link = f"{base_url}/ui/onboarding/?invitation_id={invitation_id}" return invitation_link async def send_email( From 9d9435b270ec77ba70dce3ea91e054353fa2ff9a Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 10 Aug 2026 13:40:04 -0700 Subject: [PATCH 05/11] fix: remove trailing slash before query params in all URL constructions URLs should not have a trailing slash before the query string. Changed from patterns like /ui/?login=success to /ui?login=success Fixed in: - Email invitation links (/ui/onboarding?invitation_id=...) - SSO login redirects (/ui?login=success) - Onboarding token links (/ui/onboarding?token=...) --- docker/e2e-4workers/README.md | 40 ++++++++ docker/e2e-4workers/docker-compose.yml | 96 +++++++++++++++++++ .../send_emails/base_email.py | 4 +- litellm-config.yml | 4 + litellm/proxy/proxy_server.py | 34 ++----- 5 files changed, 152 insertions(+), 26 deletions(-) create mode 100644 docker/e2e-4workers/README.md create mode 100644 docker/e2e-4workers/docker-compose.yml create mode 100644 litellm-config.yml diff --git a/docker/e2e-4workers/README.md b/docker/e2e-4workers/README.md new file mode 100644 index 000000000000..9724b6fa3d2a --- /dev/null +++ b/docker/e2e-4workers/README.md @@ -0,0 +1,40 @@ +# e2e-4workers + +Isolated local Docker deployment of the litellm gateway at `num_workers=4`, for running `tests/e2e/` against it and comparing behavior against the `berrie-litellm-stage` EKS cluster. Stage runs the same `litellm_internal_staging` code but at 1 worker / 1 replica, so this stack isolates the multi-worker / shared-state surface. + +## What this is not + +Not the same topology as EKS. Three variables differ at once: 4 gunicorn UvicornWorkers in one container vs N single-worker pods, standalone Redis vs ElastiCache cluster+TLS, and local Postgres vs Aurora IAM. The useful signal is narrow: a test that passes on EKS-1-worker but fails here is a candidate multi-worker / shared-state bug (per-worker router reload, 4x budget rescheduler, `/metrics` multiproc aggregation, cross-worker cache coherence, the LIT-4909 fresh-worker-serves-400 path). + +The image is built locally on arm64 (Apple Silicon); EKS runs amd64. Python behavior is arch-independent, so this is a behavior comparison, not a perf one. + +## Build the gateway image from the staging ref + +Both sides must run identical code, so build from `litellm_internal_staging`, not your working branch: + +```bash +git fetch berri litellm_internal_staging +git worktree add --detach ../litellm-staging-wt berri/litellm_internal_staging +docker build -t litellm-gateway:staging-27d2fa84 ../litellm-staging-wt +``` + +The tag encodes the staging SHA; bump it when you rebuild from a newer staging commit and update `docker-compose.yml`. + +## Run + +Provider keys are read from `tests/e2e/.env` (the same file the suite loads host-side). + +```bash +docker compose -f docker/e2e-4workers/docker-compose.yml up -d +docker compose -f docker/e2e-4workers/docker-compose.yml logs -f gateway # look for "with 4 workers" +curl -fs http://localhost:4000/health/liveliness + +LITELLM_PROXY_URL=http://localhost:4000 REDIS_CLUSTER=false REDIS_SSL=false \ + uv run pytest tests/e2e/llm_translation tests/e2e/quota_management -v +``` + +Tear down (drop the DB too): + +```bash +docker compose -f docker/e2e-4workers/docker-compose.yml down -v +``` diff --git a/docker/e2e-4workers/docker-compose.yml b/docker/e2e-4workers/docker-compose.yml new file mode 100644 index 000000000000..d776de279317 --- /dev/null +++ b/docker/e2e-4workers/docker-compose.yml @@ -0,0 +1,96 @@ +# Isolated local Docker deployment of the litellm gateway at num_workers=4, +# for running tests/e2e/ against it and comparing behavior against the +# berrie-litellm-stage EKS cluster (which runs the same litellm_internal_staging +# code at 1 worker / 1 replica). +# +# The gateway image is built out-of-band from a worktree at the staging ref so +# both sides run identical code: +# git worktree add --detach ../../../litellm-staging-wt berri/litellm_internal_staging +# docker build -t litellm-gateway:staging-27d2fa84 ../../../litellm-staging-wt +# +# Bring up: docker compose -f docker/e2e-4workers/docker-compose.yml up -d +# Run suite: LITELLM_PROXY_URL=http://localhost:4000 uv run pytest tests/e2e/... -v +# +# This is NOT the same topology as EKS: 4 gunicorn UvicornWorkers in one +# container vs N single-worker pods, standalone Redis vs ElastiCache cluster+TLS, +# local Postgres vs Aurora IAM. Tests that pass on EKS-1-worker but fail here are +# candidate multi-worker / shared-state issues. +name: litellm-e2e-4workers + +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: litellm + POSTGRES_USER: llmproxy + POSTGRES_PASSWORD: dbpassword9090 + ports: + - "5433:5432" # host 5433 to avoid clashing with a root-compose litellm_db on 5432 + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -d litellm -U llmproxy"] + interval: 2s + timeout: 5s + retries: 15 + + redis: + image: redis:7 + command: ["redis-server", "--save", "", "--appendonly", "no"] + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 5s + retries: 15 + + jaeger: + image: jaegertracing/all-in-one:1.60 + environment: + COLLECTOR_OTLP_ENABLED: "true" + ports: + - "16686:16686" # query UI/API (E2E_OTEL_QUERY_URL) + - "4318:4318" # OTLP HTTP ingest + + gateway: + image: litellm-gateway:staging-27d2fa84 + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + # Provider keys (OPENAI/ANTHROPIC/GEMINI/AZURE_AI/VERTEXAI/AWS/DD/...) come + # from the suite's own env file; the overrides below win over it for the + # in-container service coordinates and the multi-worker knobs. + env_file: + - ../../tests/e2e/.env + environment: + DATABASE_URL: "postgresql://llmproxy:dbpassword9090@db:5432/litellm" + LITELLM_MASTER_KEY: "sk-1234" + STORE_MODEL_IN_DB: "True" + REDIS_HOST: "redis" + REDIS_PORT: "6379" + REDIS_SSL: "false" + REDIS_CLUSTER: "false" + # >1 worker -> prometheus needs a shared multiproc dir; /tmp is writable. + PROMETHEUS_MULTIPROC_DIR: "/tmp/litellm_prom" + # otel callback exports OTLP to the local jaeger instead of a cluster one. + OTEL_EXPORTER: "otlp_http" + OTEL_ENDPOINT: "http://jaeger:4318/v1/traces" + command: ["--config", "/app/config.yaml", "--port", "4000", "--num_workers", "4"] + volumes: + - ./config.yaml:/app/config.yaml:ro + ports: + - "4010:4000" # host 4010 to avoid clashing with a local proxy already on 4000 + healthcheck: + test: + - CMD-SHELL + - python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')" + interval: 10s + timeout: 10s + retries: 12 + start_period: 60s + +volumes: + pgdata: diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py index 3d74c027d8b0..a2adf63cbd3f 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py @@ -928,10 +928,10 @@ def _construct_invitation_link(self, invitation_id: str, base_url: str) -> str: """ Construct invitation link for the user - # http://localhost:4000/ui/onboarding/?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b + # http://localhost:4000/ui/onboarding?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b """ base_url = base_url.rstrip("/") - invitation_link = f"{base_url}/ui/onboarding/?invitation_id={invitation_id}" + invitation_link = f"{base_url}/ui/onboarding?invitation_id={invitation_id}" return invitation_link async def send_email( diff --git a/litellm-config.yml b/litellm-config.yml new file mode 100644 index 000000000000..6b5bf704a2ae --- /dev/null +++ b/litellm-config.yml @@ -0,0 +1,4 @@ +general_settings: + database_url: os.environ/DATABASE_URL + master_key: "sk-1234" + diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 07eaed9fe456..8956c66d0b77 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -13833,11 +13833,8 @@ async def login(request: Request): # Build redirect URL litellm_dashboard_ui = get_custom_url(str(request.base_url)) - if litellm_dashboard_ui.endswith("/"): - litellm_dashboard_ui += "ui/" - else: - litellm_dashboard_ui += "/ui/" - litellm_dashboard_ui += "?login=success" + litellm_dashboard_ui = litellm_dashboard_ui.rstrip("/") + litellm_dashboard_ui += "/ui?login=success" # Honor a same-origin return_to preserved by the sign-in page (e.g. the aggregate DCR connect flow's # authorize round-trip), mirroring the SSO callback; otherwise land on the dashboard. Gated by @@ -13907,11 +13904,8 @@ async def login_v2(request: Request): jwt_token: Final = encode_ui_session_jwt(returned_ui_token_object, cast(str, master_key)) litellm_dashboard_ui = get_custom_url(str(request.base_url)) - if litellm_dashboard_ui.endswith("/"): - litellm_dashboard_ui += "ui/" - else: - litellm_dashboard_ui += "/ui/" - litellm_dashboard_ui += "?login=success" + litellm_dashboard_ui = litellm_dashboard_ui.rstrip("/") + litellm_dashboard_ui += "/ui?login=success" # Token is included in the response body so the UI can set a JS-accessible # cookie even when a reverse proxy (e.g. nginx-ingress) adds HttpOnly to the @@ -13980,11 +13974,8 @@ async def login_v3(request: Request): jwt_token: Final = encode_ui_session_jwt(returned_ui_token_object, cast(str, master_key)) litellm_dashboard_ui = get_custom_url(str(request.base_url)) - if litellm_dashboard_ui.endswith("/"): - litellm_dashboard_ui += "ui/" - else: - litellm_dashboard_ui += "/ui/" - litellm_dashboard_ui += "?login=success" + litellm_dashboard_ui = litellm_dashboard_ui.rstrip("/") + litellm_dashboard_ui += "/ui?login=success" # Store JWT behind a single-use opaque code (60s TTL) code: Final = secrets.token_urlsafe(32) @@ -14132,10 +14123,8 @@ async def onboarding(invite_link: str, request: Request): raise HTTPException(status_code=401, detail={"error": "User does not exist in db."}) litellm_dashboard_ui = get_custom_url(str(request.base_url)) - if litellm_dashboard_ui.endswith("/"): - litellm_dashboard_ui += "ui/onboarding" - else: - litellm_dashboard_ui += "/ui/onboarding" + litellm_dashboard_ui = litellm_dashboard_ui.rstrip("/") + litellm_dashboard_ui += "/ui/onboarding" import jwt user_email: Final = user_obj.user_email @@ -14391,11 +14380,8 @@ async def claim_onboarding_link(data: InvitationClaim, request: Request): ) from e litellm_dashboard_ui = get_custom_url(str(request.base_url)) - if litellm_dashboard_ui.endswith("/"): - litellm_dashboard_ui += "ui/" - else: - litellm_dashboard_ui += "/ui/" - litellm_dashboard_ui += "?login=success" + litellm_dashboard_ui = litellm_dashboard_ui.rstrip("/") + litellm_dashboard_ui += "/ui?login=success" return { "login_url": litellm_dashboard_ui, "token": jwt_token, From b66edf275ef3cd233a8ad9c95622d115fa5f4114 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 10 Aug 2026 14:02:17 -0700 Subject: [PATCH 06/11] fix(email): use dedicated invitation_link parameter for Accept Invitation button The email template button now explicitly uses {invitation_link} instead of relying on {base_url} being overwritten. This ensures the Accept Invitation button takes users to the onboarding page where they can create their password and login --- docker/e2e-4workers/README.md | 40 -------- docker/e2e-4workers/docker-compose.yml | 96 ------------------- .../send_emails/base_email.py | 1 + litellm-config.yml | 4 - .../email_templates/user_invitation_email.py | 2 +- 5 files changed, 2 insertions(+), 141 deletions(-) delete mode 100644 docker/e2e-4workers/README.md delete mode 100644 docker/e2e-4workers/docker-compose.yml delete mode 100644 litellm-config.yml diff --git a/docker/e2e-4workers/README.md b/docker/e2e-4workers/README.md deleted file mode 100644 index 9724b6fa3d2a..000000000000 --- a/docker/e2e-4workers/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# e2e-4workers - -Isolated local Docker deployment of the litellm gateway at `num_workers=4`, for running `tests/e2e/` against it and comparing behavior against the `berrie-litellm-stage` EKS cluster. Stage runs the same `litellm_internal_staging` code but at 1 worker / 1 replica, so this stack isolates the multi-worker / shared-state surface. - -## What this is not - -Not the same topology as EKS. Three variables differ at once: 4 gunicorn UvicornWorkers in one container vs N single-worker pods, standalone Redis vs ElastiCache cluster+TLS, and local Postgres vs Aurora IAM. The useful signal is narrow: a test that passes on EKS-1-worker but fails here is a candidate multi-worker / shared-state bug (per-worker router reload, 4x budget rescheduler, `/metrics` multiproc aggregation, cross-worker cache coherence, the LIT-4909 fresh-worker-serves-400 path). - -The image is built locally on arm64 (Apple Silicon); EKS runs amd64. Python behavior is arch-independent, so this is a behavior comparison, not a perf one. - -## Build the gateway image from the staging ref - -Both sides must run identical code, so build from `litellm_internal_staging`, not your working branch: - -```bash -git fetch berri litellm_internal_staging -git worktree add --detach ../litellm-staging-wt berri/litellm_internal_staging -docker build -t litellm-gateway:staging-27d2fa84 ../litellm-staging-wt -``` - -The tag encodes the staging SHA; bump it when you rebuild from a newer staging commit and update `docker-compose.yml`. - -## Run - -Provider keys are read from `tests/e2e/.env` (the same file the suite loads host-side). - -```bash -docker compose -f docker/e2e-4workers/docker-compose.yml up -d -docker compose -f docker/e2e-4workers/docker-compose.yml logs -f gateway # look for "with 4 workers" -curl -fs http://localhost:4000/health/liveliness - -LITELLM_PROXY_URL=http://localhost:4000 REDIS_CLUSTER=false REDIS_SSL=false \ - uv run pytest tests/e2e/llm_translation tests/e2e/quota_management -v -``` - -Tear down (drop the DB too): - -```bash -docker compose -f docker/e2e-4workers/docker-compose.yml down -v -``` diff --git a/docker/e2e-4workers/docker-compose.yml b/docker/e2e-4workers/docker-compose.yml deleted file mode 100644 index d776de279317..000000000000 --- a/docker/e2e-4workers/docker-compose.yml +++ /dev/null @@ -1,96 +0,0 @@ -# Isolated local Docker deployment of the litellm gateway at num_workers=4, -# for running tests/e2e/ against it and comparing behavior against the -# berrie-litellm-stage EKS cluster (which runs the same litellm_internal_staging -# code at 1 worker / 1 replica). -# -# The gateway image is built out-of-band from a worktree at the staging ref so -# both sides run identical code: -# git worktree add --detach ../../../litellm-staging-wt berri/litellm_internal_staging -# docker build -t litellm-gateway:staging-27d2fa84 ../../../litellm-staging-wt -# -# Bring up: docker compose -f docker/e2e-4workers/docker-compose.yml up -d -# Run suite: LITELLM_PROXY_URL=http://localhost:4000 uv run pytest tests/e2e/... -v -# -# This is NOT the same topology as EKS: 4 gunicorn UvicornWorkers in one -# container vs N single-worker pods, standalone Redis vs ElastiCache cluster+TLS, -# local Postgres vs Aurora IAM. Tests that pass on EKS-1-worker but fail here are -# candidate multi-worker / shared-state issues. -name: litellm-e2e-4workers - -services: - db: - image: postgres:16 - environment: - POSTGRES_DB: litellm - POSTGRES_USER: llmproxy - POSTGRES_PASSWORD: dbpassword9090 - ports: - - "5433:5432" # host 5433 to avoid clashing with a root-compose litellm_db on 5432 - volumes: - - pgdata:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -d litellm -U llmproxy"] - interval: 2s - timeout: 5s - retries: 15 - - redis: - image: redis:7 - command: ["redis-server", "--save", "", "--appendonly", "no"] - ports: - - "6379:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 2s - timeout: 5s - retries: 15 - - jaeger: - image: jaegertracing/all-in-one:1.60 - environment: - COLLECTOR_OTLP_ENABLED: "true" - ports: - - "16686:16686" # query UI/API (E2E_OTEL_QUERY_URL) - - "4318:4318" # OTLP HTTP ingest - - gateway: - image: litellm-gateway:staging-27d2fa84 - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - # Provider keys (OPENAI/ANTHROPIC/GEMINI/AZURE_AI/VERTEXAI/AWS/DD/...) come - # from the suite's own env file; the overrides below win over it for the - # in-container service coordinates and the multi-worker knobs. - env_file: - - ../../tests/e2e/.env - environment: - DATABASE_URL: "postgresql://llmproxy:dbpassword9090@db:5432/litellm" - LITELLM_MASTER_KEY: "sk-1234" - STORE_MODEL_IN_DB: "True" - REDIS_HOST: "redis" - REDIS_PORT: "6379" - REDIS_SSL: "false" - REDIS_CLUSTER: "false" - # >1 worker -> prometheus needs a shared multiproc dir; /tmp is writable. - PROMETHEUS_MULTIPROC_DIR: "/tmp/litellm_prom" - # otel callback exports OTLP to the local jaeger instead of a cluster one. - OTEL_EXPORTER: "otlp_http" - OTEL_ENDPOINT: "http://jaeger:4318/v1/traces" - command: ["--config", "/app/config.yaml", "--port", "4000", "--num_workers", "4"] - volumes: - - ./config.yaml:/app/config.yaml:ro - ports: - - "4010:4000" # host 4010 to avoid clashing with a local proxy already on 4000 - healthcheck: - test: - - CMD-SHELL - - python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')" - interval: 10s - timeout: 10s - retries: 12 - start_period: 60s - -volumes: - pgdata: diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py index a2adf63cbd3f..4be09670e92f 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py @@ -99,6 +99,7 @@ async def send_user_invitation_email(self, event: WebhookEvent): email_html_content = USER_INVITATION_EMAIL_TEMPLATE.format( email_logo_url=email_params.logo_url, recipient_email=email_params.recipient_email, + invitation_link=email_params.base_url, base_url=email_params.base_url, email_support_contact=email_params.support_contact, email_footer=email_params.signature, diff --git a/litellm-config.yml b/litellm-config.yml deleted file mode 100644 index 6b5bf704a2ae..000000000000 --- a/litellm-config.yml +++ /dev/null @@ -1,4 +0,0 @@ -general_settings: - database_url: os.environ/DATABASE_URL - master_key: "sk-1234" - diff --git a/litellm/integrations/email_templates/user_invitation_email.py b/litellm/integrations/email_templates/user_invitation_email.py index 9ad00999eaa8..33904608741e 100644 --- a/litellm/integrations/email_templates/user_invitation_email.py +++ b/litellm/integrations/email_templates/user_invitation_email.py @@ -131,7 +131,7 @@
From 9923a72f480992cb1f36cf7fba12435baf144a67 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 10 Aug 2026 15:14:17 -0700 Subject: [PATCH 07/11] fix(email): stop legacy invitation email from duplicating the v2 email When a new user is created with send_invite_email=true, both the modern enterprise (v2) email logger and the legacy v1 SlackAlerting path fired unconditionally, so a v2 deployment sent two invitation emails: the modern "Accept Invitation" one and the legacy "Get Started here" one built off the bare base URL. Route the v2 send through a single helper that returns whether any registered enterprise email logger actually delivered, and only fall back to the legacy email when v2 did not send (enterprise package absent, no email logger configured, or every send raised). This keeps the legacy path as a real fallback for OSS deployments without regressing them. Also fix the legacy fallback itself so it is not broken when it does run: build a proper /ui/onboarding?invitation_id=... link (looked up or created for the user, trailing slash stripped before the query) instead of the bare base URL, and relabel the button from "Get Started here" to "Accept Invitation". Adds regression tests covering both that v2 suppresses the legacy duplicate on success and that a failing v2 send still falls back to legacy. --- .../SlackAlerting/slack_alerting.py | 48 +++++++- .../integrations/email_templates/templates.py | 2 +- .../hooks/user_management_event_hooks.py | 68 +++++++---- .../proxy/hooks/test_send_invite_email.py | 111 +++++++++++++++++- 4 files changed, 204 insertions(+), 25 deletions(-) diff --git a/litellm/integrations/SlackAlerting/slack_alerting.py b/litellm/integrations/SlackAlerting/slack_alerting.py index 771d7876fea2..b8ba4eac3485 100644 --- a/litellm/integrations/SlackAlerting/slack_alerting.py +++ b/litellm/integrations/SlackAlerting/slack_alerting.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal from openai import APIError +from pydantic import TypeAdapter import litellm import litellm.litellm_core_utils @@ -33,10 +34,14 @@ from litellm.proxy._types import ( AlertType, CallInfo, + InvitationModel, + InvitationNew, Litellm_EntityType, + UserAPIKeyAuth, VirtualKeyEvent, WebhookEvent, ) +from litellm.repositories.table_repositories import InvitationLinkRepository from litellm.repositories.team_repository import TeamRepository from litellm.repositories.user_repository import UserRepository from litellm.types.integrations.slack_alerting import * @@ -1081,6 +1086,44 @@ async def _check_if_using_premium_email_feature( if email_logo_url is not None or email_support_contact is not None: raise ValueError(f"Trying to Customize Email Alerting\n {CommonProxyErrors.not_premium_user.value}") + async def _construct_user_invitation_link(self, recipient_user_id: str | None, base_url: str) -> str: + from litellm.proxy.management_helpers.user_invitation import ( + create_invitation_for_user, + ) + from litellm.proxy.proxy_server import prisma_client + + if recipient_user_id is None or prisma_client is None: + return base_url + + try: + existing_invitations: Final = TypeAdapter(list[InvitationModel]).validate_python( + await InvitationLinkRepository(prisma_client).table.find_many( # pyright: ignore[reportAny] # untyped prisma boundary (any-ok), result validated by TypeAdapter + where={"user_id": recipient_user_id}, + order={"created_at": "desc"}, + ), + from_attributes=True, + ) + invitation: Final = ( + existing_invitations[0] + if existing_invitations + else TypeAdapter(InvitationModel).validate_python( + await create_invitation_for_user( + data=InvitationNew(user_id=recipient_user_id), + user_api_key_dict=UserAPIKeyAuth(user_id=recipient_user_id), + ), + from_attributes=True, + ) + ) + except Exception as e: + verbose_proxy_logger.error( + "Error creating invitation link for user_id %s: %s", + recipient_user_id, + str(e), + ) + return base_url + + return f"{base_url.rstrip('/')}/ui/onboarding?invitation_id={invitation.id}" + async def send_key_created_or_user_invited_email(self, webhook_event: WebhookEvent) -> bool: try: from litellm.proxy.utils import send_email @@ -1139,11 +1182,14 @@ async def send_key_created_or_user_invited_email(self, webhook_event: WebhookEve team_row: Final = await TeamRepository(prisma_client).table.find_unique(where={"team_id": team_id}) if team_row is not None: team_name = team_row.team_alias or "-" + invitation_link: Final = await self._construct_user_invitation_link( + recipient_user_id=recipient_user_id, base_url=base_url + ) email_html_content = USER_INVITED_EMAIL_TEMPLATE.format( email_logo_url=email_logo_url, recipient_email=recipient_email, team_name=team_name, - base_url=base_url, + base_url=invitation_link, email_support_contact=email_support_contact, ) else: diff --git a/litellm/integrations/email_templates/templates.py b/litellm/integrations/email_templates/templates.py index f73e0f758add..935067c97fc6 100644 --- a/litellm/integrations/email_templates/templates.py +++ b/litellm/integrations/email_templates/templates.py @@ -54,7 +54,7 @@ You were invited to use OpenAI Proxy API for team {team_name}

- Get Started here

+ Accept Invitation

If you have any questions, please send an email to {email_support_contact}

diff --git a/litellm/proxy/hooks/user_management_event_hooks.py b/litellm/proxy/hooks/user_management_event_hooks.py index a5568a450f0b..e482c4fe54b1 100644 --- a/litellm/proxy/hooks/user_management_event_hooks.py +++ b/litellm/proxy/hooks/user_management_event_hooks.py @@ -96,42 +96,66 @@ async def async_send_user_invitation_email( key_alias=response.key_alias, ) + sent_via_v2: Final = await UserManagementEventHooks._send_v2_user_invitation_emails( + event=event, send_invite_email=data.send_invite_email + ) + ######################################################### - ########## V2 USER INVITATION EMAIL ################ + ########## LEGACY V1 USER INVITATION EMAIL (FALLBACK) #### ######################################################### + if data.send_invite_email is True and not sent_via_v2: + await UserManagementEventHooks.send_legacy_v1_user_invitation_email( + data=data, + response=response, + user_api_key_dict=user_api_key_dict, + event=event, + ) + + @staticmethod + async def _send_v2_user_invitation_emails(event: WebhookEvent, send_invite_email: bool | None) -> bool: + """ + Send the modern (V2) invitation email via any registered enterprise email logger. + + Returns True if at least one logger delivered, so the caller only falls back to + the legacy email when V2 did not send (enterprise package absent, no email logger + configured, or every send raised). + """ + if send_invite_email is not True: + return False + try: from litellm_enterprise.enterprise_callbacks.send_emails.base_email import ( BaseEmailLogger, ) - - use_enterprise_email_hooks = True except ImportError: verbose_proxy_logger.warning( "Defaulting to using Legacy Email Hooks." + CommonProxyErrors.missing_enterprise_package.value ) - use_enterprise_email_hooks = False + return False - if use_enterprise_email_hooks and (data.send_invite_email is True): - initialized_email_loggers: Final = litellm.logging_callback_manager.get_custom_loggers_for_type( + email_loggers: Final = [ + email_logger + for email_logger in litellm.logging_callback_manager.get_custom_loggers_for_type( callback_type=BaseEmailLogger ) - if len(initialized_email_loggers) > 0: - for email_logger in initialized_email_loggers: - if isinstance(email_logger, BaseEmailLogger): - await email_logger.send_user_invitation_email( - event=event, - ) + if isinstance(email_logger, BaseEmailLogger) + ] + if len(email_loggers) == 0: + return False + + send_outcomes: Final = await asyncio.gather( + *[email_logger.send_user_invitation_email(event=event) for email_logger in email_loggers], + return_exceptions=True, + ) + for outcome in send_outcomes: + if isinstance(outcome, BaseException): + verbose_proxy_logger.error( + "Error sending v2 user invitation email for user_id=%s: %s", + event.user_id, + str(outcome), + ) - ######################################################### - ########## LEGACY V1 USER INVITATION EMAIL ################ - ######################################################### - if data.send_invite_email is True: - await UserManagementEventHooks.send_legacy_v1_user_invitation_email( - data=data, - response=response, - user_api_key_dict=user_api_key_dict, - event=event, - ) + return any(not isinstance(outcome, BaseException) for outcome in send_outcomes) @staticmethod async def send_legacy_v1_user_invitation_email( diff --git a/tests/test_litellm/proxy/hooks/test_send_invite_email.py b/tests/test_litellm/proxy/hooks/test_send_invite_email.py index 3b8f00d577a9..c916af5c128a 100644 --- a/tests/test_litellm/proxy/hooks/test_send_invite_email.py +++ b/tests/test_litellm/proxy/hooks/test_send_invite_email.py @@ -9,7 +9,6 @@ GenerateKeyResponse, UserAPIKeyAuth, ) -import builtins import sys from types import SimpleNamespace @@ -92,6 +91,116 @@ async def test_v1_user_creation_sends_email_when_send_invite_email_true(): mock_slack_alerting.send_key_created_or_user_invited_email.assert_called_once() +@pytest.mark.asyncio +async def test_v2_invitation_email_suppresses_legacy_duplicate(): + """ + Regression: when a V2 enterprise email logger is registered and sends + successfully, the modern invitation email is sent and the legacy V1 email is + NOT also sent, so the invited user does not receive a duplicate. + """ + pytest.importorskip("litellm_enterprise") + from litellm_enterprise.enterprise_callbacks.send_emails.base_email import ( + BaseEmailLogger, + ) + + class RecordingEmailLogger(BaseEmailLogger): + def __init__(self): + super().__init__() + self.sent_events = [] + + async def send_user_invitation_email(self, event): + self.sent_events.append(event) + + recording_logger = RecordingEmailLogger() + mock_slack_alerting = MagicMock() + mock_slack_alerting.send_key_created_or_user_invited_email = AsyncMock() + mock_proxy_logging_obj = MagicMock() + mock_proxy_logging_obj.slack_alerting_instance = mock_slack_alerting + + with patch( + "litellm.logging_callback_manager.get_custom_loggers_for_type", + return_value=[recording_logger], + ): + mock_proxy_server = SimpleNamespace( + general_settings={"alerting": ["email"]}, + proxy_logging_obj=mock_proxy_logging_obj, + litellm_proxy_admin_name="admin-user", + ) + with patch.dict(sys.modules, {"litellm.proxy.proxy_server": mock_proxy_server}): + data = NewUserRequest( + user_email="test@example.com", + send_invite_email=True, + ) + response = NewUserResponse( + user_id="test-user", + user_email="test@example.com", + key="sk-test-key", + ) + user_api_key_dict = UserAPIKeyAuth(user_id="admin-user", api_key="admin-key") + await UserManagementEventHooks.async_send_user_invitation_email( + data=data, + response=response, + user_api_key_dict=user_api_key_dict, + ) + + assert len(recording_logger.sent_events) == 1 + mock_slack_alerting.send_key_created_or_user_invited_email.assert_not_called() + + +@pytest.mark.asyncio +async def test_v2_invitation_email_failure_falls_back_to_legacy(): + """ + Regression: when a V2 enterprise email logger is registered but its send + raises (e.g. misconfigured SMTP), the legacy V1 email still fires as a + fallback so the invited user is not left with zero emails. + """ + pytest.importorskip("litellm_enterprise") + from litellm_enterprise.enterprise_callbacks.send_emails.base_email import ( + BaseEmailLogger, + ) + + class FailingEmailLogger(BaseEmailLogger): + def __init__(self): + super().__init__() + + async def send_user_invitation_email(self, event): + raise RuntimeError("smtp misconfigured") + + failing_logger = FailingEmailLogger() + mock_slack_alerting = MagicMock() + mock_slack_alerting.send_key_created_or_user_invited_email = AsyncMock() + mock_proxy_logging_obj = MagicMock() + mock_proxy_logging_obj.slack_alerting_instance = mock_slack_alerting + + with patch( + "litellm.logging_callback_manager.get_custom_loggers_for_type", + return_value=[failing_logger], + ): + mock_proxy_server = SimpleNamespace( + general_settings={"alerting": ["email"]}, + proxy_logging_obj=mock_proxy_logging_obj, + litellm_proxy_admin_name="admin-user", + ) + with patch.dict(sys.modules, {"litellm.proxy.proxy_server": mock_proxy_server}): + data = NewUserRequest( + user_email="test@example.com", + send_invite_email=True, + ) + response = NewUserResponse( + user_id="test-user", + user_email="test@example.com", + key="sk-test-key", + ) + user_api_key_dict = UserAPIKeyAuth(user_id="admin-user", api_key="admin-key") + await UserManagementEventHooks.async_send_user_invitation_email( + data=data, + response=response, + user_api_key_dict=user_api_key_dict, + ) + + mock_slack_alerting.send_key_created_or_user_invited_email.assert_called_once() + + @pytest.mark.asyncio async def test_v1_key_generation_sends_email_when_send_invite_email_true(): """ From 113402936545c9bb1e84baf057c3f027dc6b8194 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 10 Aug 2026 15:32:28 -0700 Subject: [PATCH 08/11] fix(ui): only mask and toggle sensitive email fields The eye-icon refactor masked every email setting field (SMTP_HOST, SMTP_PORT, EMAIL_LOGO_URL) as a password and gave each one a show/hide toggle, so the first "Show credential" button belonged to SMTP_HOST rather than the password. That broke the credential-visibility unit test and hid non-secret values from admins for no reason. Render the masked input and the eye toggle only for sensitive keys (PASSWORD/SECRET/KEY/TOKEN); everything else stays a plain text input. This restores the intended behavior and makes the unit test pass. --- .../src/components/email_settings.tsx | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/ui/litellm-dashboard/src/components/email_settings.tsx b/ui/litellm-dashboard/src/components/email_settings.tsx index 2c5b46ebdbd3..6f1c3b1f8462 100644 --- a/ui/litellm-dashboard/src/components/email_settings.tsx +++ b/ui/litellm-dashboard/src/components/email_settings.tsx @@ -1,12 +1,7 @@ import React, { useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupInput, -} from "@/components/ui/input-group"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; import { Eye, EyeOff } from "lucide-react"; import NotificationManager from "./molecules/notifications_manager"; import { serviceHealthCheck, setCallbacksCall } from "./networking"; @@ -35,6 +30,8 @@ const FIELD_HELP: Record = { const PREMIUM_ONLY_FIELDS = ["EMAIL_LOGO_URL", "EMAIL_SUPPORT_CONTACT"]; +const SENSITIVE_FIELD_PATTERN = /(PASSWORD|SECRET|KEY|TOKEN)/i; + const EmailSettings: React.FC = ({ accessToken, premiumUser, alerts }) => { const [visibleFields, setVisibleFields] = useState>({}); @@ -114,6 +111,7 @@ const EmailSettings: React.FC = ({ accessToken, premiumUser,
{Object.entries(alert.variables ?? {}).map(([key, value]) => { const isLocked = !premiumUser && PREMIUM_ONLY_FIELDS.includes(key); + const isSensitive = SENSITIVE_FIELD_PATTERN.test(key); const isVisible = visibleFields[key] || false; return (
@@ -133,18 +131,20 @@ const EmailSettings: React.FC = ({ accessToken, premiumUser, - - toggleFieldVisibility(key)} - aria-label={isVisible ? "Hide credential" : "Show credential"} - > - {isVisible ? : } - - + {isSensitive && ( + + toggleFieldVisibility(key)} + aria-label={isVisible ? "Hide credential" : "Show credential"} + > + {isVisible ? : } + + + )}
{FIELD_HELP[key]}
From 26116276c05d2b9ed2a18cd42bf7a337c5f58319 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 10 Aug 2026 15:44:12 -0700 Subject: [PATCH 09/11] test: update login redirect expectations to /ui without trailing slash The /login, /v2/login and onboarding routes now build the dashboard redirect as /ui?login=success (no trailing slash before the query), matching the URL construction fix on this branch. Update the pinned test expectations that still asserted the old /ui/?login=success so they match the code. --- .../proxy/proxy_server/test_routes_login_sso.py | 14 +++++++------- .../proxy/proxy_server/test_routes_onboarding.py | 2 +- tests/test_litellm/proxy/test_proxy_server.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_login_sso.py b/tests/test_litellm/proxy/proxy_server/test_routes_login_sso.py index a75d5bd57303..af37dbe85fe3 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_login_sso.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_login_sso.py @@ -130,7 +130,7 @@ def test_fallback_login_invalid_method_405(client): def test_login_form_success_redirects_with_token_cookie(client, monkeypatch): - """Pin: POST /login with valid form returns a 303 redirect to /ui/ and + """Pin: POST /login with valid form returns a 303 redirect to /ui and sets the 'token' cookie.""" _install_login_mocks(monkeypatch) response = client.post( @@ -142,7 +142,7 @@ def test_login_form_success_redirects_with_token_cookie(client, monkeypatch): set_cookie = response.headers.get("set-cookie", "") shape = { "status": response.status_code, - "location_has_ui": "/ui/" in location, + "location_has_ui": "/ui" in location, "location_has_login_success": "login=success" in location, "has_token_cookie": "token=" in set_cookie, } @@ -190,7 +190,7 @@ def test_v2_login_success_returns_token_and_redirect(client, monkeypatch): body = response.json() set_cookie = response.headers.get("set-cookie", "") shape = { - "redirect_url_has_ui": "/ui/" in body.get("redirect_url", ""), + "redirect_url_has_ui": "/ui" in body.get("redirect_url", ""), "redirect_url_has_login_success": "login=success" in body.get("redirect_url", ""), "token_in_body": bool(body.get("token")), "token_cookie_set": "token=" in set_cookie, @@ -359,7 +359,7 @@ def test_v3_login_exchange_success_returns_token_and_redirect(client, monkeypatc cached_payload = { "token": "jwt-token-xyz", - "redirect_url": "https://litellm.example.invalid/ui/?login=success", + "redirect_url": "https://litellm.example.invalid/ui?login=success", } fake_cache = MagicMock() fake_cache.async_get_cache = AsyncMock(return_value=cached_payload) @@ -382,7 +382,7 @@ def test_v3_login_exchange_success_returns_token_and_redirect(client, monkeypatc } assert shape == { "token": "jwt-token-xyz", - "redirect_url": "https://litellm.example.invalid/ui/?login=success", + "redirect_url": "https://litellm.example.invalid/ui?login=success", "token_cookie_set": True, "cache_deleted_once": True, } @@ -443,7 +443,7 @@ def test_login_form_survives_stale_control_plane_return_to(client, monkeypatch): assert response.status_code == 303, "login must not break on a stale return_to cookie" location = response.headers.get("location", "") assert "old-cp.example.com" not in location - assert "/ui/" in location + assert "/ui" in location def test_login_form_ignores_open_redirect_return_to(client, monkeypatch): @@ -459,4 +459,4 @@ def test_login_form_ignores_open_redirect_return_to(client, monkeypatch): assert response.status_code == 303 location = response.headers.get("location", "") assert "evil.example.com" not in location - assert "/ui/" in location # dashboard fallback + assert "/ui" in location # dashboard fallback diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_onboarding.py b/tests/test_litellm/proxy/proxy_server/test_routes_onboarding.py index 35ae9a3568e9..5cc22cca7a00 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_onboarding.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_onboarding.py @@ -243,7 +243,7 @@ async def _fake_session_token(user_obj): assert set(body.keys()) == {"login_url", "token", "user_email", "user"} assert body["token"] == "session-jwt-token" assert body["user_email"] == "alice@example.com" - assert body["login_url"].endswith("/ui/?login=success") + assert body["login_url"].endswith("/ui?login=success") def test_claim_onboarding_link_invalid_invite_401(client, monkeypatch, mock_prisma): diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index efd2ccb3e539..7338ffd622d6 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -112,7 +112,7 @@ def test_login_v2_returns_redirect_url_and_sets_cookie(monkeypatch): assert response.status_code == 200 assert response.json() == { - "redirect_url": "http://testserver/ui/?login=success", + "redirect_url": "http://testserver/ui?login=success", "token": "signed-token", } assert response.cookies.get("token") == "signed-token" From 9f6287b37b45ab2967cb21d7404e744ad0adf422 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 10 Aug 2026 15:51:06 -0700 Subject: [PATCH 10/11] chore: suppress BLE001 on best-effort invitation-link fallback The broad except in _construct_user_invitation_link is intentional: any DB or invitation-creation failure falls back to the base URL. Annotate it so the strict-rule budget gate does not count it as a new blind-except. --- litellm/integrations/SlackAlerting/slack_alerting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/integrations/SlackAlerting/slack_alerting.py b/litellm/integrations/SlackAlerting/slack_alerting.py index b8ba4eac3485..a6f7eb10b562 100644 --- a/litellm/integrations/SlackAlerting/slack_alerting.py +++ b/litellm/integrations/SlackAlerting/slack_alerting.py @@ -1114,7 +1114,7 @@ async def _construct_user_invitation_link(self, recipient_user_id: str | None, b from_attributes=True, ) ) - except Exception as e: + except Exception as e: # noqa: BLE001 # best-effort link build; any DB/creation failure falls back to base_url verbose_proxy_logger.error( "Error creating invitation link for user_id %s: %s", recipient_user_id, From 435054196b69155808ddeedbd12d74c4ffed3710 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Mon, 10 Aug 2026 16:00:46 -0700 Subject: [PATCH 11/11] chore: satisfy LIT002 in invitation-email helpers Build the enterprise email logger set as a tuple and unpack a generator into asyncio.gather instead of list literals, and mark the two prisma find_many dict kwargs (which the client requires as dicts) with reasoned mutable-ok. --- litellm/integrations/SlackAlerting/slack_alerting.py | 4 ++-- litellm/proxy/hooks/user_management_event_hooks.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/litellm/integrations/SlackAlerting/slack_alerting.py b/litellm/integrations/SlackAlerting/slack_alerting.py index a6f7eb10b562..7edfb93e581a 100644 --- a/litellm/integrations/SlackAlerting/slack_alerting.py +++ b/litellm/integrations/SlackAlerting/slack_alerting.py @@ -1098,8 +1098,8 @@ async def _construct_user_invitation_link(self, recipient_user_id: str | None, b try: existing_invitations: Final = TypeAdapter(list[InvitationModel]).validate_python( await InvitationLinkRepository(prisma_client).table.find_many( # pyright: ignore[reportAny] # untyped prisma boundary (any-ok), result validated by TypeAdapter - where={"user_id": recipient_user_id}, - order={"created_at": "desc"}, + where={"user_id": recipient_user_id}, # mutable-ok: prisma find_many requires a dict where filter + order={"created_at": "desc"}, # mutable-ok: prisma find_many requires a dict order arg ), from_attributes=True, ) diff --git a/litellm/proxy/hooks/user_management_event_hooks.py b/litellm/proxy/hooks/user_management_event_hooks.py index e482c4fe54b1..929df2a778c1 100644 --- a/litellm/proxy/hooks/user_management_event_hooks.py +++ b/litellm/proxy/hooks/user_management_event_hooks.py @@ -133,18 +133,18 @@ async def _send_v2_user_invitation_emails(event: WebhookEvent, send_invite_email ) return False - email_loggers: Final = [ + email_loggers: Final = tuple( email_logger for email_logger in litellm.logging_callback_manager.get_custom_loggers_for_type( callback_type=BaseEmailLogger ) if isinstance(email_logger, BaseEmailLogger) - ] + ) if len(email_loggers) == 0: return False send_outcomes: Final = await asyncio.gather( - *[email_logger.send_user_invitation_email(event=event) for email_logger in email_loggers], + *(email_logger.send_user_invitation_email(event=event) for email_logger in email_loggers), return_exceptions=True, ) for outcome in send_outcomes: