From 42f269ccf2af85366b363fd770ab3d9fe4507d00 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 21 Jul 2026 16:11:31 -0700 Subject: [PATCH 1/6] fix(ui): stop dashboard key-edit form 403ing on non-budget saves (#34112) The key-edit form sent budget_limits on every save (the stored windows, or [] when a key has none). The backend treats any budget_limits in a /key/update request as an admin-only budget change, so a non-admin key owner editing a non-budget field (models, MCP servers, alias) always hit 403 with "Only proxy admins, team admins, or org admins can call /key/update". Only include budget_limits when the user actually changed the budget windows, mirroring how the same handler already drops an unchanged allowed_routes. The comparison is on (duration, cap) ignoring the server-owned reset_at and window order; [] is still sent when the user deletes the last window so clearing keeps working. No backend or API behavior changes. --- .../templates/key_edit_view.test.tsx | 68 ++++++++++++++++++- .../components/templates/key_edit_view.tsx | 25 +++++-- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index f8b8e5b6de3..8d7c8e76011 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -662,11 +662,14 @@ describe("KeyEditView", () => { }); }); - it("should resend existing budget windows on submit when they are left untouched", async () => { + it("should omit budget_limits when existing windows are left untouched (issue #33246)", async () => { + // The backend treats any budget_limits in the payload as an admin-only + // budget change, so re-sending untouched windows 403s a non-admin owner. + // Leaving the field off keeps the stored windows and passes the gate. const onSubmitMock = vi.fn().mockResolvedValue(undefined); const keyDataWithWindow = { ...MOCK_KEY_DATA, - budget_limits: [{ budget_duration: "30d", max_budget: 100 }], + budget_limits: [{ budget_duration: "30d", max_budget: 100, reset_at: "2026-08-01T00:00:00" }], }; renderWithProviders( { await waitFor(() => { expect(onSubmitMock).toHaveBeenCalled(); const callArgs = onSubmitMock.mock.calls[0][0]; - expect(callArgs.budget_limits).toEqual([{ budget_duration: "30d", max_budget: 100 }]); + expect(callArgs.budget_limits).toBeUndefined(); + }); + }); + + it("should omit budget_limits on a key that has no windows (issue #33246 repro)", async () => { + // Core repro: a non-admin owner edits a non-budget field on a key with no + // budget windows. The form previously always sent budget_limits: [], which + // the backend read as a budget change and rejected. + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + renderWithProviders( + {}} + onSubmit={onSubmitMock} + accessToken={"test-token"} + userID={"test-user"} + userRole={"admin"} + premiumUser={false} + />, + ); + + const submitButton = await screen.findByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect(callArgs.budget_limits).toBeUndefined(); + }); + }); + + it("should send budget_limits when a window's cap is changed", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + const keyDataWithWindow = { + ...MOCK_KEY_DATA, + budget_limits: [{ budget_duration: "30d", max_budget: 100 }], + }; + renderWithProviders( + {}} + onSubmit={onSubmitMock} + accessToken={"test-token"} + userID={"test-user"} + userRole={"admin"} + premiumUser={false} + />, + ); + + const maxBudgetInput = await screen.findByPlaceholderText("Max spend ($)"); + await userEvent.clear(maxBudgetInput); + await userEvent.type(maxBudgetInput, "200"); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect(callArgs.budget_limits).toEqual([{ budget_duration: "30d", max_budget: 200 }]); }); }); diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index dce7b9bd949..79fa9c63890 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -307,14 +307,29 @@ export function KeyEditView({ } // Reconcile multi-window budget limits from the editor state, dropping - // incomplete entries (no max_budget). Sending [] tells the backend to clear - // all stored windows, so only send it when the user removed every window; - // when entries remain but are still incomplete, omit the field so the saved - // windows are left untouched (JSON.stringify drops the undefined key). + // incomplete entries (no max_budget). The backend treats any budget_limits + // in a /key/update request as an admin-only budget change, so re-sending + // the stored windows on an unrelated edit 403s a non-admin key owner + // (issue #33246). Only send the field when the user actually changed the + // windows, mirroring how allowed_routes is dropped above when unchanged: + // compare on (duration, cap), ignoring server-owned reset_at and order. + // Sending [] clears every window, so send it only when the user removed + // the last one; otherwise leave the field off (JSON.stringify drops the + // undefined key) so an unchanged or incomplete editor state never touches + // storage. + const windowSignature = (windows: Array<{ budget_duration: string; max_budget: number | null }> | undefined) => + (windows ?? []) + .filter((w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined) + .map((w) => `${w.budget_duration}:${w.max_budget}`) + .sort() + .join("|"); const validWindows = budgetLimits.filter( (w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined, ); - if (validWindows.length > 0) { + const budgetLimitsUnchanged = windowSignature(keyData.budget_limits) === windowSignature(validWindows); + if (budgetLimitsUnchanged) { + // no-op: leave budget_limits off the payload + } else if (validWindows.length > 0) { values.budget_limits = validWindows; } else if (budgetLimits.length === 0) { values.budget_limits = []; From 02746eb122bd05c7ef0d47d94a9c5c55cd2b8fa8 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 21 Jul 2026 16:11:38 -0700 Subject: [PATCH 2/6] fix(ui): harden provider logo map typing and bundled asset guard (#34163) Follow-up to the static logo import PR. Types providerLogoMap as Partial> so raw string keys and lookups are compile errors, tightens the resolveLogoSrc passthrough from /_next/ to /_next/static/ so lookalike backend paths still get root-prefixed, adds an enum coverage test that locks the exact set of logoless providers, and makes Logo props a discriminated union so provider and src modes cannot be mixed and src mode requires a label. --- .../components/molecules/logo/Logo.test.tsx | 4 +-- .../src/components/molecules/logo/Logo.tsx | 10 +++--- .../components/provider_info_helpers.test.tsx | 36 +++++++++++++++---- .../src/components/provider_info_helpers.tsx | 4 +-- .../src/components/vector_store_providers.tsx | 10 +++--- .../src/lib/assetPaths.test.ts | 10 +++++- ui/litellm-dashboard/src/lib/assetPaths.ts | 2 +- 7 files changed, 52 insertions(+), 24 deletions(-) diff --git a/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx b/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx index 95221f35b8d..c0b52e03a30 100644 --- a/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx +++ b/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx @@ -20,8 +20,8 @@ describe("Logo", () => { expect(screen.queryByRole("img")).not.toBeInTheDocument(); }); - it("renders a dash avatar when neither provider nor src is given", () => { - render(); + it("renders a dash avatar when src is empty and the label has no characters", () => { + render(); expect(screen.getByText("-")).toBeInTheDocument(); expect(screen.queryByRole("img")).not.toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx b/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx index 6f18c89ae8e..f5fb1f0805a 100644 --- a/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx +++ b/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx @@ -2,12 +2,10 @@ import React, { useState } from "react"; import { getProviderLogoAndName } from "@/components/provider_info_helpers"; import { resolveLogoSrc } from "@/lib/assetPaths"; -interface LogoProps { - provider?: string; - src?: string | null; - label?: string; - className?: string; -} +type LogoProps = { className?: string } & ( + | { provider: string; src?: never; label?: string } + | { provider?: never; src: string | null | undefined; label: string } +); export const Logo: React.FC = ({ provider, src, label, className = "w-4 h-4" }) => { const [erroredSrc, setErroredSrc] = useState(null); diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx index 966bd4e262c..777cdc62987 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx @@ -114,13 +114,35 @@ describe("provider_info_helpers", () => { }); describe("provider logo bundled assets", () => { - it("should expose every provider logo as a truthy bundled URL, never a raw /ui/assets path", () => { - const logos = Object.values(providerLogoMap); - expect(logos.length).toBeGreaterThan(0); - logos.forEach((logo) => { - expect(typeof logo).toBe("string"); - expect(logo.length).toBeGreaterThan(0); - expect(logo.startsWith("/ui/assets/")).toBe(false); + it("should map every provider to a bundled logo except the known logoless set, never a raw /ui/assets path", () => { + const knownLogolessProviders = [ + Providers.AUTO_ROUTER, + Providers.BYTEZ, + Providers.CLARIFAI, + Providers.COMPACTIFAI, + Providers.DATAROBOT, + Providers.DOCKER_MODEL_RUNNER, + Providers.DOTPROMPT, + Providers.EMPOWER, + Providers.GALADRIEL, + Providers.GradientAI, + Providers.HEROKU, + Providers.LEMONADE, + Providers.LLAMAFILE, + Providers.MARITALK, + Providers.NLP_CLOUD, + Providers.NSCALE, + Providers.OVHCLOUD, + Providers.PETALS, + Providers.PG_VECTOR, + Providers.PREDIBASE, + Providers.WANDB, + Providers.ZAI, + ]; + const logolessProviders = Object.values(Providers).filter((provider) => !providerLogoMap[provider]); + expect([...logolessProviders].sort()).toEqual([...knownLogolessProviders].sort()); + Object.values(providerLogoMap).forEach((logo) => { + expect(logo?.startsWith("/ui/assets/")).toBe(false); }); }); diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index f8be4511e3a..fa6b3c79230 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -283,7 +283,7 @@ export const provider_map: Record = { const standaloneSubproviderSlugs = new Set(["bedrock_mantle"]); -export const providerLogoMap: Record = { +export const providerLogoMap: Partial> = { [Providers.A2A_Agent]: a2aAgentLogo.src, [Providers.AI21]: ai21Logo.src, [Providers.AI21_CHAT]: ai21Logo.src, @@ -395,7 +395,7 @@ export const getProviderLogoAndName = (providerValue: string): { logo: string; d // Get the display name from Providers enum and logo from map const displayName = Providers[enumKey as keyof typeof Providers]; - const logo = resolveLogoSrc(providerLogoMap[displayName as keyof typeof providerLogoMap]) ?? ""; + const logo = resolveLogoSrc(providerLogoMap[displayName]) ?? ""; return { logo, displayName }; }; diff --git a/ui/litellm-dashboard/src/components/vector_store_providers.tsx b/ui/litellm-dashboard/src/components/vector_store_providers.tsx index 03878d5d6f1..d8ee2eb4720 100644 --- a/ui/litellm-dashboard/src/components/vector_store_providers.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_providers.tsx @@ -26,12 +26,12 @@ export const vectorStoreProviderMap: Record = { }; export const vectorStoreProviderLogoMap: Record = { - [VectorStoreProviders.Bedrock]: providerLogoMap[Providers.Bedrock], + [VectorStoreProviders.Bedrock]: providerLogoMap[Providers.Bedrock] ?? "", [VectorStoreProviders.PgVector]: postgresqlLogo.src, - [VectorStoreProviders.VertexRagEngine]: providerLogoMap[Providers.Vertex_AI], - [VectorStoreProviders.VertexAiSearch]: providerLogoMap[Providers.Vertex_AI], - [VectorStoreProviders.OpenAI]: providerLogoMap[Providers.OpenAI], - [VectorStoreProviders.Azure]: providerLogoMap[Providers.Azure], + [VectorStoreProviders.VertexRagEngine]: providerLogoMap[Providers.Vertex_AI] ?? "", + [VectorStoreProviders.VertexAiSearch]: providerLogoMap[Providers.Vertex_AI] ?? "", + [VectorStoreProviders.OpenAI]: providerLogoMap[Providers.OpenAI] ?? "", + [VectorStoreProviders.Azure]: providerLogoMap[Providers.Azure] ?? "", [VectorStoreProviders.Milvus]: milvusLogo.src, [VectorStoreProviders.S3Vectors]: s3VectorLogo.src, }; diff --git a/ui/litellm-dashboard/src/lib/assetPaths.test.ts b/ui/litellm-dashboard/src/lib/assetPaths.test.ts index 0ca6c055a0a..adadef5d274 100644 --- a/ui/litellm-dashboard/src/lib/assetPaths.test.ts +++ b/ui/litellm-dashboard/src/lib/assetPaths.test.ts @@ -48,7 +48,7 @@ describe("resolveLogoSrc", () => { expect(resolveLogoSrc("//cdn.example.com/x.svg")).toBe("//cdn.example.com/x.svg"); }); - it("passes bundled /_next/ asset URLs through untouched even under a sub-path mount", async () => { + it("passes bundled /_next/static/ asset URLs through untouched even under a sub-path mount", async () => { const { resolveLogoSrc } = await importWithRoot("/litellm"); expect(resolveLogoSrc("/_next/static/media/openai_small.abc123.svg")).toBe( "/_next/static/media/openai_small.abc123.svg", @@ -56,6 +56,14 @@ describe("resolveLogoSrc", () => { expect(resolveLogoSrc("/litellm-asset-prefix/_next/static/media/openai_small.abc123.svg")).toBe( "/litellm-asset-prefix/_next/static/media/openai_small.abc123.svg", ); + expect(resolveLogoSrc("/litellm/_next/static/media/openai_small.abc123.svg")).toBe( + "/litellm/_next/static/media/openai_small.abc123.svg", + ); + }); + + it("still prefixes a backend path that merely contains a /_next/ lookalike segment", async () => { + const { resolveLogoSrc } = await importWithRoot("/litellm"); + expect(resolveLogoSrc("/ui/assets/logos/_next/logo.svg")).toBe("/litellm/ui/assets/logos/_next/logo.svg"); }); it("roots a local asset path using the live server root path", async () => { diff --git a/ui/litellm-dashboard/src/lib/assetPaths.ts b/ui/litellm-dashboard/src/lib/assetPaths.ts index ec359ff2d86..abce69127cb 100644 --- a/ui/litellm-dashboard/src/lib/assetPaths.ts +++ b/ui/litellm-dashboard/src/lib/assetPaths.ts @@ -23,7 +23,7 @@ export const withServerRoot = (path: string, root: string): string => { export const resolveLogoSrc = (value: string | null | undefined, root: string = serverRootPath): string | undefined => { if (!value) return undefined; if (EXTERNAL_SRC.test(value)) return value; - if (value.includes("/_next/")) return value; + if (value.includes("/_next/static/")) return value; const prefix = normalizeRootPath(root); if (prefix && (value === prefix || value.startsWith(`${prefix}/`))) return value; return withServerRoot(value, root); From e17f3b6e1a30bbceca25b0c34eb5345bc97804e9 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 21 Jul 2026 16:11:47 -0700 Subject: [PATCH 3/6] fix(proxy): populate user_email on UserAPIKeyAuth for JWT auth (#34174) JWT auth built UserAPIKeyAuth without user_email even though the resolved user row and the JWT email claim were both available, so the user_email label on Prometheus metrics and user_api_key_user_email in StandardLogging/SpendLogs metadata were always None for JWT traffic. Plumb user_email through JWTAuthBuilderResult: auth_builder returns the user row email when set, falling back to the user_email_jwt_field claim (covers the scope-based proxy-admin path where no user row is loaded). The JWT branch now stamps it on the proxy-admin return, the standard valid_token, and the auto-registered virtual key object. Resolves LIT-4238 --- litellm/proxy/_types.py | 1 + litellm/proxy/auth/handle_jwt.py | 5 +- litellm/proxy/auth/user_api_key_auth.py | 4 + .../test_user_api_key_auth.py | 1 + .../proxy/auth/test_handle_jwt.py | 112 ++++++++++++++++++ .../proxy/auth/test_user_api_key_auth.py | 98 +++++++++++++++ 6 files changed, 220 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 2762d9b4f72..7df725bf965 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -4052,6 +4052,7 @@ class JWTAuthBuilderResult(TypedDict): token: str team_id: Optional[str] user_id: Optional[str] + user_email: str | None end_user_id: Optional[str] org_id: Optional[str] team_membership: Optional[LiteLLM_TeamMembership] diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index a44318c072c..ff87d0e70da 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -1155,6 +1155,7 @@ class JWTAuthManager: org_id: Optional[str], api_key: str, jwt_valid_token: Optional[dict] = None, + user_email: str | None = None, ) -> Optional[JWTAuthBuilderResult]: """Check admin status and route access permissions""" if not jwt_handler.is_admin(scopes=scopes): @@ -1179,6 +1180,7 @@ class JWTAuthManager: token=api_key, team_id=None, user_id=user_id, + user_email=user_email, end_user_id=None, org_id=org_id, team_membership=None, @@ -2068,7 +2070,7 @@ class JWTAuthManager: # Check admin access admin_result = await JWTAuthManager.check_admin_access( - jwt_handler, scopes, route, user_id, org_id, api_key, jwt_valid_token + jwt_handler, scopes, route, user_id, org_id, api_key, jwt_valid_token, user_email=user_email ) if admin_result: await JWTAuthManager._attach_team_from_header_for_admin( @@ -2303,6 +2305,7 @@ class JWTAuthManager: team_id=team_id, team_object=team_object, user_id=user_id, + user_email=(user_object.user_email if user_object is not None and user_object.user_email else user_email), user_object=user_object, org_id=resolved_org_id, # Use resolved org_id (from alias lookup if applicable) org_object=org_object, diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 4623c3d153c..c185f950395 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1255,6 +1255,7 @@ async def _user_api_key_auth_builder( team_id = result["team_id"] team_object = result["team_object"] user_id = result["user_id"] + user_email = result["user_email"] user_object = result["user_object"] end_user_id = result["end_user_id"] org_id = result["org_id"] @@ -1279,6 +1280,7 @@ async def _user_api_key_auth_builder( api_key=None, user_role=LitellmUserRoles.PROXY_ADMIN, user_id=user_id, + user_email=user_email, team_id=team_id, team_alias=(team_object.team_alias if team_object is not None else None), team_tpm_limit=(team_object.tpm_limit if team_object is not None else None), @@ -1304,6 +1306,7 @@ async def _user_api_key_auth_builder( else LitellmUserRoles.INTERNAL_USER ), user_id=user_id, + user_email=user_email, org_id=org_id, parent_otel_span=parent_otel_span, end_user_id=end_user_id, @@ -1345,6 +1348,7 @@ async def _user_api_key_auth_builder( ) if auto_registered is not None: auto_registered.jwt_claims = jwt_claims + auto_registered.user_email = user_email valid_token = auto_registered api_key = valid_token.token or "" diff --git a/tests/proxy_unit_tests/test_user_api_key_auth.py b/tests/proxy_unit_tests/test_user_api_key_auth.py index 5471d2668e4..59c4caefa33 100644 --- a/tests/proxy_unit_tests/test_user_api_key_auth.py +++ b/tests/proxy_unit_tests/test_user_api_key_auth.py @@ -1115,6 +1115,7 @@ async def test_jwt_non_admin_team_route_access(monkeypatch): "team_id": None, "team_object": None, "user_id": None, + "user_email": None, "user_object": None, "org_id": None, "org_object": None, diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py index 13041950f98..ffc5241d027 100644 --- a/tests/test_litellm/proxy/auth/test_handle_jwt.py +++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py @@ -458,6 +458,118 @@ async def test_auth_builder_non_proxy_admin_user_role(): assert result["user_id"] == "test_user_1" +@pytest.mark.asyncio +@pytest.mark.parametrize( + "row_email,expected_email", + [ + ("row@example.com", "row@example.com"), + (None, "claim@example.com"), + ("", "claim@example.com"), + ], +) +async def test_auth_builder_result_includes_user_email(row_email, expected_email): + """LIT-4238: auth_builder must return user_email (user row wins, JWT claim + is the fallback) so the auth object and metrics get the email.""" + api_key = "test_jwt_token" + request_data = {"model": "gpt-4"} + general_settings = {"enforce_rbac": False} + route = "/chat/completions" + + user_object = LiteLLM_UserTable( + user_id="test_user_1", + user_email=row_email, + user_role=LitellmUserRoles.INTERNAL_USER, + ) + + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth() + + with ( + patch.object(jwt_handler, "auth_jwt", new_callable=AsyncMock) as mock_auth_jwt, + patch.object(JWTAuthManager, "check_rbac_role", new_callable=AsyncMock), + patch.object(jwt_handler, "get_rbac_role", return_value=None), + patch.object(jwt_handler, "get_scopes", return_value=[]), + patch.object(jwt_handler, "get_object_id", return_value=None), + patch.object( + JWTAuthManager, + "get_user_info", + new_callable=AsyncMock, + return_value=("test_user_1", "claim@example.com", True), + ), + patch.object(jwt_handler, "get_org_id", return_value=None), + patch.object(jwt_handler, "get_end_user_id", return_value=None), + patch.object( + JWTAuthManager, + "check_admin_access", + new_callable=AsyncMock, + return_value=None, + ) as mock_check_admin, + patch.object( + JWTAuthManager, + "find_and_validate_specific_team_id", + new_callable=AsyncMock, + return_value=(None, None), + ), + patch.object(JWTAuthManager, "get_all_team_ids", return_value=set()), + patch.object( + JWTAuthManager, + "find_team_with_model_access", + new_callable=AsyncMock, + return_value=(None, None), + ), + patch.object( + JWTAuthManager, + "get_objects", + new_callable=AsyncMock, + return_value=(user_object, None, None, None, user_object.user_id), + ), + patch.object(JWTAuthManager, "map_user_to_teams", new_callable=AsyncMock), + patch.object(JWTAuthManager, "validate_object_id", return_value=True), + ): + mock_auth_jwt.return_value = {"sub": "test_user_1", "scope": ""} + + result = await JWTAuthManager.auth_builder( + api_key=api_key, + jwt_handler=jwt_handler, + request_data=request_data, + general_settings=general_settings, + route=route, + prisma_client=None, + user_api_key_cache=None, + parent_otel_span=None, + proxy_logging_obj=None, + ) + + assert result["user_email"] == expected_email + assert mock_check_admin.call_args.kwargs["user_email"] == "claim@example.com" + + +@pytest.mark.asyncio +async def test_check_admin_access_result_includes_user_email(): + """LIT-4238: the scope-based admin path has no user row, so the JWT claim + email must ride the JWTAuthBuilderResult.""" + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth( + admin_jwt_scope="litellm_proxy_admin", + admin_allowed_routes=["/chat/completions"], + ) + + result = await JWTAuthManager.check_admin_access( + jwt_handler=jwt_handler, + scopes=["litellm_proxy_admin"], + route="/chat/completions", + user_id="admin-user", + user_email="admin@example.com", + org_id=None, + api_key="test_jwt_token", + jwt_valid_token={"sub": "admin-user"}, + ) + + assert result is not None + assert result["is_proxy_admin"] is True + assert result["user_email"] == "admin@example.com" + + @pytest.mark.asyncio async def test_sync_user_role_and_teams(): from unittest.mock import MagicMock diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index d410f7ff72c..2c1948adca1 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -1569,6 +1569,7 @@ class TestJWTOAuth2Coexistence: "token": jwt_token, "team_id": "jwt-team", "user_id": "jwt-human-user", + "user_email": None, "end_user_id": None, "org_id": None, "team_membership": None, @@ -1643,6 +1644,7 @@ class TestJWTOAuth2Coexistence: "token": jwt_token, "team_id": "validated-team", "user_id": "validated-user", + "user_email": "validated@example.com", "end_user_id": "validated-end-user", "org_id": "validated-org", "team_membership": None, @@ -1702,6 +1704,7 @@ class TestJWTOAuth2Coexistence: mock_auto_register.call_args.kwargs["end_user_id"] == "validated-end-user" ) assert result.org_id == "validated-org" + assert result.user_email == "validated@example.com" @pytest.mark.asyncio async def test_routing_override_routes_matching_jwt_to_oauth2(self): @@ -1788,6 +1791,7 @@ class TestJWTOAuth2Coexistence: "token": jwt_token, "team_id": "jwt-team", "user_id": "jwt-user-no-override", + "user_email": None, "end_user_id": None, "org_id": None, "team_membership": None, @@ -1988,6 +1992,7 @@ class TestJWTOAuth2Coexistence: "token": jwt_token, "team_id": "jwt-team", "user_id": "jwt-user-scope-mismatch", + "user_email": None, "end_user_id": None, "org_id": None, "team_membership": None, @@ -2296,6 +2301,7 @@ class TestJWTOAuth2Coexistence: "token": jwt_token, "team_id": None, "user_id": "jwt-admin-user", + "user_email": None, "end_user_id": None, "org_id": None, "team_membership": None, @@ -4255,6 +4261,98 @@ async def test_auth_does_not_rewrite_cached_key_object_back_into_cache(): setattr(_proxy_server_mod, k, v) +class TestJWTAuthUserEmail: + """JWT auth must populate `UserAPIKeyAuth.user_email` (LIT-4238); it feeds + the Prometheus `user_email` label and `user_api_key_user_email` in + StandardLogging/SpendLogs metadata, which were always None for JWT traffic.""" + + def _jwt_request(self, jwt_token): + mock_request = MagicMock() + mock_request.url.path = "/v1/chat/completions" + mock_request.method = "POST" + mock_request.headers = {"authorization": f"Bearer {jwt_token}"} + mock_request.query_params = {} + return mock_request + + async def _run_jwt_auth(self, mock_jwt_result, jwt_token): + with ( + patch( + "litellm.proxy.proxy_server.general_settings", + {"enable_jwt_auth": True}, + ), + patch("litellm.proxy.proxy_server.premium_user", True), + patch("litellm.proxy.proxy_server.master_key", "sk-master"), + patch("litellm.proxy.proxy_server.prisma_client", None), + patch( + "litellm.proxy.auth.user_api_key_auth.JWTAuthManager.auth_builder", + new_callable=AsyncMock, + return_value=mock_jwt_result, + ), + ): + litellm.proxy.proxy_server.jwt_handler.update_environment( + prisma_client=None, + user_api_key_cache=DualCache(), + litellm_jwtauth=LiteLLM_JWTAuth(), + ) + return await user_api_key_auth( + request=self._jwt_request(jwt_token), + api_key=f"Bearer {jwt_token}", + ) + + @pytest.mark.asyncio + async def test_jwt_auth_populates_user_email_on_valid_token(self): + jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature" + mock_jwt_result = { + "is_proxy_admin": False, + "team_object": None, + "user_object": LiteLLM_UserTable( + user_id="jwt-human-user", + user_email="row@example.com", + user_role=LitellmUserRoles.INTERNAL_USER.value, + ), + "end_user_object": None, + "org_object": None, + "token": jwt_token, + "team_id": None, + "user_id": "jwt-human-user", + "user_email": "resolved@example.com", + "end_user_id": None, + "org_id": None, + "team_membership": None, + "jwt_claims": {"sub": "user1"}, + } + + result = await self._run_jwt_auth(mock_jwt_result, jwt_token) + + assert result.user_id == "jwt-human-user" + assert result.user_email == "resolved@example.com" + + @pytest.mark.asyncio + async def test_jwt_auth_populates_user_email_on_proxy_admin(self): + jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature" + mock_jwt_result = { + "is_proxy_admin": True, + "team_object": None, + "user_object": None, + "end_user_object": None, + "org_object": None, + "token": jwt_token, + "team_id": None, + "user_id": "jwt-admin-user", + "user_email": "admin@example.com", + "end_user_id": None, + "org_id": None, + "team_membership": None, + "jwt_claims": {"sub": "user1"}, + } + + result = await self._run_jwt_auth(mock_jwt_result, jwt_token) + + assert result.user_role == LitellmUserRoles.PROXY_ADMIN + assert result.user_id == "jwt-admin-user" + assert result.user_email == "admin@example.com" + + class TestCheckKeyModelBudgetWithFallback: """`_check_key_model_budget_with_fallback` must reroute a request to the first configured `budget_fallbacks` entry still within its own budget, From 8a56899e1e0b650033cbce33b8a026fa02502b7e Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Tue, 21 Jul 2026 16:20:50 -0700 Subject: [PATCH 4/6] test(e2e): cover config and misc management routes for Management/UI coverage (#34120) --- tests/e2e/e2e_http.py | 35 +- .../test_config_misc_endpoints_e2e.py | 698 ++++++++++++++++++ tests/e2e/transport.py | 22 + 3 files changed, 754 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/management/test_config_misc_endpoints_e2e.py diff --git a/tests/e2e/e2e_http.py b/tests/e2e/e2e_http.py index 1c6048a3688..7ec4a439e10 100644 --- a/tests/e2e/e2e_http.py +++ b/tests/e2e/e2e_http.py @@ -62,6 +62,7 @@ R = TypeVar("R", bound=BaseModel) class Success(BaseModel, Generic[R]): kind: Literal["success"] = "success" + status_code: int data: R @@ -159,6 +160,18 @@ def unwrap[R: BaseModel](result: Result[R]) -> R: raise AssertionError(result) +def unwrap_status[R: BaseModel](result: Result[R], expected_status: int) -> R: + """Like unwrap, but also pins the exact HTTP status the success came back on, + for routes whose contract is a specific 2xx (e.g. 201 Created on a submission).""" + match result: + case Success(status_code=status_code, data=data) if status_code == expected_status: + return data + case Success(status_code=status_code): + raise AssertionError(f"expected HTTP {expected_status}, got {status_code}") + case _: + raise AssertionError(result) + + def is_ok[R: BaseModel](result: Result[R]) -> bool: match result: case Success(): @@ -199,7 +212,7 @@ def _classify[R: BaseModel]( if not resp.ok: return UnknownApiError(status_code=resp.status_code, body=resp.text) try: - return Success(data=response_type.model_validate(resp.json())) + return Success(status_code=resp.status_code, data=response_type.model_validate(resp.json())) except Exception as exc: # noqa: BLE001 - any parse/validation failure is a value return ValidationError(message=str(exc)) @@ -286,6 +299,26 @@ def patch[R: BaseModel]( return _classify(resp, response_type) +def put[R: BaseModel]( + url: URL, + *, + headers: BaseModel, + json: BaseModel, + response_type: type[R], + timeout: float = 30.0, +) -> Result[R]: + try: + resp = requests.put( + str(url), + headers=_headers(headers), + json=json.model_dump(by_alias=True, exclude_none=True), + timeout=timeout, + ) + except requests.RequestException as exc: + return NetworkError(message=str(exc)) + return _classify(resp, response_type) + + def probe( url: URL, *, headers: BaseModel, params: BaseModel, timeout: float = 30.0 ) -> ProbeResult: diff --git a/tests/e2e/management/test_config_misc_endpoints_e2e.py b/tests/e2e/management/test_config_misc_endpoints_e2e.py new file mode 100644 index 00000000000..6c4de621271 --- /dev/null +++ b/tests/e2e/management/test_config_misc_endpoints_e2e.py @@ -0,0 +1,698 @@ +"""Live e2e: the config and miscellaneous Management/UI routes. + +One method per registry cell, each asserting the real contract against a live +proxy: read-only inventory routes return their documented shape, stateless +validators compute their verdict from the request, and the write routes persist +so a read-back reflects the change. The two routes that mutate global proxy state +(cache settings and router settings, both driven from the admin UI) are exercised +with a benign, self-restoring change so a shared proxy is left as it was found. +""" + +from __future__ import annotations + +import math +import time +from collections.abc import Callable + +import pytest +from pydantic import BaseModel + +from e2e_config import unique_marker +from e2e_http import NoBody, Success, unwrap, unwrap_status +from lifecycle import ResourceManager +from management_client import ManagementClient +from models import KeyGenerateBody, LiteLLMParamsBody, TeamNewBody + +pytestmark = pytest.mark.e2e + + +def _poll[T](client: ManagementClient, attempt: Callable[[], T | None], failure: str) -> T: + deadline = time.monotonic() + client.proxy.poll_timeout + while time.monotonic() < deadline: + found = attempt() + if found is not None: + return found + time.sleep(client.proxy.poll_interval) + pytest.fail(failure) + + +# ---- callbacks ------------------------------------------------------------- + + +class CallbacksListResponse(BaseModel): + success: list[str] + failure: list[str] + success_and_failure: list[str] + + +# ---- cost estimate --------------------------------------------------------- + + +class CostEstimateBody(BaseModel): + model: str + input_tokens: int + output_tokens: int + num_requests_per_day: int | None = None + + +class CostEstimateResponse(BaseModel): + model: str + input_tokens: int + output_tokens: int + cost_per_request: float + input_cost_per_request: float + output_cost_per_request: float + margin_cost_per_request: float + daily_cost: float | None = None + provider: str | None = None + + +# ---- credential migration check -------------------------------------------- + + +class MigrationReport(BaseModel): + residual_legacy: int + total_undecryptable: int + + +class MigrationCheckResponse(BaseModel): + status: str + report: MigrationReport + + +# ---- tool + workflow inventories ------------------------------------------- + + +class ToolListEntry(BaseModel): + name: str | None = None + + +class ToolListResponse(BaseModel): + tools: list[ToolListEntry] + total: int + + +class WorkflowRunEntry(BaseModel): + workflow_id: str | None = None + + +class WorkflowRunsResponse(BaseModel): + runs: list[WorkflowRunEntry] + count: int + + +# ---- compliance ------------------------------------------------------------ + + +class ComplianceGdprBody(BaseModel): + request_id: str + user_id: str + model: str + timestamp: str + + +class ComplianceCheck(BaseModel): + check_name: str + article: str + passed: bool + detail: str + + +class ComplianceResponse(BaseModel): + compliant: bool + regulation: str + checks: list[ComplianceCheck] + + +# ---- cache settings -------------------------------------------------------- + + +class CacheSettingsValue(BaseModel): + type: str + host: str = "" + port: str = "" + + +class CacheSettingsUpdateBody(BaseModel): + cache_settings: CacheSettingsValue + + +class CacheCurrentValues(BaseModel): + type: str | None = None + host: str | None = None + port: str | None = None + + +class CacheGetResponse(BaseModel): + current_values: CacheCurrentValues + + +class CacheUpdateResponse(BaseModel): + status: str + settings: CacheSettingsValue + + +# ---- fallback management --------------------------------------------------- + + +class FallbackShape(BaseModel): + model: str + fallback_models: list[str] + fallback_type: str + + +class FallbackCreateBody(FallbackShape): + pass + + +class FallbackResponse(FallbackShape): + message: str + + +class FallbackGetParams(BaseModel): + fallback_type: str + + +class FallbackGetResponse(FallbackShape): + pass + + +# ---- jwt key mapping ------------------------------------------------------- + + +class JwtKeyMappingNewBody(BaseModel): + jwt_claim_name: str + jwt_claim_value: str + key: str + description: str + + +class JwtInfoParams(BaseModel): + id: str + + +class JwtDeleteBody(BaseModel): + id: str + + +class JwtKeyMappingResponse(BaseModel): + id: str + jwt_claim_name: str + jwt_claim_value: str + is_active: bool + description: str | None = None + + +# ---- router settings via /config/update ------------------------------------ + + +class RouterSettingsPatch(BaseModel): + num_retries: int + + +class ConfigUpdateBody(BaseModel): + router_settings: RouterSettingsPatch + + +class ConfigUpdateResponse(BaseModel): + message: str + + +class RouterCurrentValues(BaseModel): + num_retries: int | None = None + + +class RouterSettingsResponse(BaseModel): + current_values: RouterCurrentValues + + +# ---- mcp server submission ------------------------------------------------- + + +class McpRegisterBody(BaseModel): + server_name: str + url: str + transport: str + description: str + + +class McpServerResponse(BaseModel): + server_id: str + server_name: str | None = None + approval_status: str + transport: str + url: str | None = None + + +class TestInventoryRoutes: + @pytest.mark.covers("mgmt.callback.list.happy_path") + def test_callbacks_list_reports_active_logging_callbacks(self, client: ManagementClient) -> None: + listing = unwrap( + client.proxy.transport.get( + "/callbacks/list", + headers=client.proxy.transport.master, + params=NoBody(), + response_type=CallbacksListResponse, + ) + ) + every = [*listing.success, *listing.failure, *listing.success_and_failure] + assert every, "/callbacks/list reported no active logging callbacks; the proxy always runs the db logger" + assert "_ProxyDBLogger" in every, ( + f"/callbacks/list omitted the always-on _ProxyDBLogger spend logger; got {every}" + ) + + @pytest.mark.covers("mgmt.tool_management.list.happy_path") + def test_tool_list_returns_catalog_with_consistent_total(self, client: ManagementClient) -> None: + listing = unwrap( + client.proxy.transport.get( + "/v1/tool/list", + headers=client.proxy.transport.master, + params=NoBody(), + response_type=ToolListResponse, + ) + ) + assert listing.total == len(listing.tools), ( + f"/v1/tool/list total {listing.total} disagrees with the {len(listing.tools)} tools returned" + ) + + @pytest.mark.covers("mgmt.workflow.list.happy_path") + def test_workflow_runs_list_returns_consistent_count(self, client: ManagementClient) -> None: + listing = unwrap( + client.proxy.transport.get( + "/v1/workflows/runs", + headers=client.proxy.transport.master, + params=NoBody(), + response_type=WorkflowRunsResponse, + ) + ) + assert listing.count == len(listing.runs), ( + f"/v1/workflows/runs count {listing.count} disagrees with the {len(listing.runs)} runs returned" + ) + + @pytest.mark.covers("mgmt.credential_migration.check.happy_path") + def test_credential_migration_check_reports_residual_scan(self, client: ManagementClient) -> None: + report = unwrap( + client.proxy.transport.get( + "/credentials/migrate-encryption/check", + headers=client.proxy.transport.master, + params=NoBody(), + response_type=MigrationCheckResponse, + ) + ) + assert report.status == "success", f"migrate-encryption/check status {report.status!r}, expected 'success'" + assert report.report.residual_legacy >= 0, ( + f"residual_legacy count is negative ({report.report.residual_legacy}); the scan is broken" + ) + assert report.report.total_undecryptable >= 0, ( + f"total_undecryptable count is negative ({report.report.total_undecryptable}); the scan is broken" + ) + + +class TestCostEstimate: + @pytest.mark.covers("mgmt.cost_tracking.estimate.happy_path") + def test_estimate_computes_cost_from_token_counts(self, client: ManagementClient) -> None: + estimate = unwrap( + client.proxy.transport.post( + "/cost/estimate", + headers=client.proxy.transport.master, + json=CostEstimateBody( + model="gpt-4o-mini", input_tokens=1000, output_tokens=500, num_requests_per_day=100 + ), + response_type=CostEstimateResponse, + ) + ) + assert estimate.input_cost_per_request > 0, ( + f"input cost per request is {estimate.input_cost_per_request}; a priced model must cost more than zero" + ) + assert estimate.output_cost_per_request > 0, ( + f"output cost per request is {estimate.output_cost_per_request}; a priced model must cost more than zero" + ) + expected_per_request = ( + estimate.input_cost_per_request + estimate.output_cost_per_request + estimate.margin_cost_per_request + ) + assert math.isclose(estimate.cost_per_request, expected_per_request, rel_tol=1e-9), ( + f"cost_per_request {estimate.cost_per_request} != input+output+margin {expected_per_request}" + ) + assert estimate.daily_cost is not None and math.isclose( + estimate.daily_cost, estimate.cost_per_request * 100, rel_tol=1e-9 + ), f"daily_cost {estimate.daily_cost} != cost_per_request * 100 requests {estimate.cost_per_request * 100}" + + +class TestComplianceRoutes: + @pytest.mark.covers("mgmt.compliance.gdpr.happy_path") + def test_gdpr_check_derives_verdict_from_the_request(self, client: ManagementClient) -> None: + result = unwrap( + client.proxy.transport.post( + "/compliance/gdpr", + headers=client.proxy.transport.master, + json=ComplianceGdprBody( + request_id=f"e2e-gdpr-{unique_marker()}", + user_id=f"e2e-user-{unique_marker()}", + model="gpt-4o-mini", + timestamp="2026-07-21T00:00:00Z", + ), + response_type=ComplianceResponse, + ) + ) + assert result.regulation == "GDPR", ( + f"/compliance/gdpr reported regulation {result.regulation!r}, expected 'GDPR'" + ) + articles = {check.article for check in result.checks} + assert articles == {"Art. 32", "Art. 5(1)(c)", "Art. 30"}, ( + f"/compliance/gdpr returned articles {articles}, expected the three GDPR articles" + ) + assert result.compliant == all(check.passed for check in result.checks), ( + "the overall compliant verdict must be the conjunction of the individual checks" + ) + assert all(check.check_name and check.detail for check in result.checks), ( + "every compliance check must carry a name and a human-readable detail" + ) + + +class TestCacheSettings: + @pytest.mark.covers("mgmt.cache_settings.update.happy_path") + def test_update_persists_cache_backend_to_get( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + """Exercise the update route without changing global state: capture the live + cache backend and write exactly that back, so the config the proxy ends on is + byte-for-byte the one it started with. A teardown restore of the same captured + settings is the safety net if the body fails partway. The update route is only + meaningful against a configured cache, so an unconfigured proxy fails loudly + here rather than being silently switched to redis.""" + before = self._read_settings(client) + assert before.type is not None, ( + "GET /cache/settings reported no cache type; refusing to invent one and mutate the shared proxy" + ) + captured = CacheSettingsValue(type=before.type, host=before.host or "", port=before.port or "") + resources.defer(lambda: self._write_settings(client, captured)) + + updated = unwrap( + client.proxy.transport.post( + "/cache/settings", + headers=client.proxy.transport.master, + json=CacheSettingsUpdateBody(cache_settings=captured), + response_type=CacheUpdateResponse, + ) + ) + assert updated.status == "success", f"/cache/settings update status {updated.status!r}, expected 'success'" + assert updated.settings.type == captured.type, ( + f"/cache/settings echoed type {updated.settings.type!r}, wrote {captured.type!r}" + ) + + def reflected() -> CacheCurrentValues | None: + current = self._read_settings(client) + return current if current.type == captured.type else None + + after = _poll(client, reflected, f"/cache/settings never reported type {captured.type!r} after the update") + assert after.host == captured.host and after.port == captured.port, ( + f"/cache/settings persisted host/port {after.host!r}/{after.port!r}, " + f"wrote {captured.host!r}/{captured.port!r}" + ) + + @staticmethod + def _read_settings(client: ManagementClient) -> CacheCurrentValues: + return unwrap( + client.proxy.transport.get( + "/cache/settings", + headers=client.proxy.transport.master, + params=NoBody(), + response_type=CacheGetResponse, + ) + ).current_values + + @staticmethod + def _write_settings(client: ManagementClient, settings: CacheSettingsValue) -> None: + _ = unwrap( + client.proxy.transport.post( + "/cache/settings", + headers=client.proxy.transport.master, + json=CacheSettingsUpdateBody(cache_settings=settings), + response_type=CacheUpdateResponse, + ) + ) + + +class TestFallbackManagement: + @pytest.mark.covers("mgmt.fallback_management.update.happy_path") + def test_create_persists_and_is_read_back(self, client: ManagementClient, resources: ResourceManager) -> None: + primary = f"e2e-fallback-primary-{unique_marker()}" + secondary = f"e2e-fallback-secondary-{unique_marker()}" + params = LiteLLMParamsBody(model="openai/gpt-5.5", api_key="e2e-dummy-key") + primary_id = client.proxy.create_model(primary, params) + resources.defer(lambda: client.proxy.delete_model(primary_id)) + secondary_id = client.proxy.create_model(secondary, params) + resources.defer(lambda: client.proxy.delete_model(secondary_id)) + resources.defer(lambda: self._delete_fallback(client, primary)) + + created = unwrap( + client.proxy.transport.post( + "/fallback", + headers=client.proxy.transport.master, + json=FallbackCreateBody(model=primary, fallback_models=[secondary], fallback_type="general"), + response_type=FallbackResponse, + ) + ) + assert created.model == primary and created.fallback_models == [secondary], ( + f"/fallback echoed model={created.model!r} fallbacks={created.fallback_models}, " + f"configured {primary!r} -> [{secondary!r}]" + ) + + def read_back() -> FallbackGetResponse | None: + result = client.proxy.transport.get( + f"/fallback/{primary}", + headers=client.proxy.transport.master, + params=FallbackGetParams(fallback_type="general"), + response_type=FallbackGetResponse, + ) + match result: + case Success(data=data) if secondary in data.fallback_models: + return data + case _: + return None + + got = _poll(client, read_back, f"GET /fallback/{primary} never reported {secondary} after /fallback") + assert got.fallback_models == [secondary], ( + f"GET /fallback/{primary} reports fallbacks {got.fallback_models}, configured [{secondary!r}]" + ) + + @staticmethod + def _delete_fallback(client: ManagementClient, model: str) -> None: + _ = client.proxy.transport.delete( + f"/fallback/{model}", + headers=client.proxy.transport.master, + json=NoBody(), + params=FallbackGetParams(fallback_type="general"), + response_type=NoBody, + ) + + +class TestJwtKeyMapping: + @pytest.mark.covers("mgmt.jwt_key_mapping.new.happy_path") + def test_new_persists_mapping_and_is_read_back( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + key = client.proxy.generate_key(KeyGenerateBody()) + resources.defer(lambda: client.proxy.delete_key(key)) + claim_value = f"e2e_jwt_{unique_marker()}" + + created = unwrap( + client.proxy.transport.post( + "/jwt/key/mapping/new", + headers=client.proxy.transport.master, + json=JwtKeyMappingNewBody( + jwt_claim_name="team_id", + jwt_claim_value=claim_value, + key=key, + description="e2e coverage mapping", + ), + response_type=JwtKeyMappingResponse, + ) + ) + resources.defer(lambda: self._delete_mapping(client, created.id)) + assert created.jwt_claim_value == claim_value and created.is_active, ( + f"/jwt/key/mapping/new returned claim_value={created.jwt_claim_value!r} active={created.is_active}, " + f"configured {claim_value!r} active=True" + ) + + info = unwrap( + client.proxy.transport.get( + "/jwt/key/mapping/info", + headers=client.proxy.transport.master, + params=JwtInfoParams(id=created.id), + response_type=JwtKeyMappingResponse, + ) + ) + assert info.id == created.id and info.jwt_claim_name == "team_id" and info.jwt_claim_value == claim_value, ( + f"/jwt/key/mapping/info reports {info.jwt_claim_name!r}={info.jwt_claim_value!r} for id {info.id}, " + f"created team_id={claim_value!r}" + ) + + @staticmethod + def _delete_mapping(client: ManagementClient, mapping_id: str) -> None: + _ = client.proxy.transport.post( + "/jwt/key/mapping/delete", + headers=client.proxy.transport.master, + json=JwtDeleteBody(id=mapping_id), + response_type=NoBody, + ) + + +class TestRouterSettings: + @pytest.mark.covers("mgmt.router_settings.update.happy_path") + def test_config_update_persists_router_setting_to_get( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + """/config/update is the only write path for router_settings (there is no + dedicated router-settings write route). The change is restored on teardown so + the shared proxy keeps its original retry policy.""" + original = self._read_num_retries(client) + assert original is not None, "GET /router/settings did not report num_retries; cannot prove a change" + resources.defer(lambda: self._write_num_retries(client, original)) + + target = original + 5 + response = unwrap( + client.proxy.transport.post( + "/config/update", + headers=client.proxy.transport.master, + json=ConfigUpdateBody(router_settings=RouterSettingsPatch(num_retries=target)), + response_type=ConfigUpdateResponse, + ) + ) + assert "success" in response.message.lower(), ( + f"/config/update reported {response.message!r}, expected a success message" + ) + + _ = _poll( + client, + lambda: True if self._read_num_retries(client) == target else None, + f"GET /router/settings never reported num_retries {target} after /config/update", + ) + + self._write_num_retries(client, original) + restored = _poll( + client, + lambda: original if self._read_num_retries(client) == original else None, + f"GET /router/settings never returned to the original num_retries {original} after the restore", + ) + assert restored == original, f"router num_retries left at {restored}, expected the original {original}" + + @staticmethod + def _read_num_retries(client: ManagementClient) -> int | None: + return unwrap( + client.proxy.transport.get( + "/router/settings", + headers=client.proxy.transport.master, + params=NoBody(), + response_type=RouterSettingsResponse, + ) + ).current_values.num_retries + + @staticmethod + def _write_num_retries(client: ManagementClient, value: int) -> None: + _ = unwrap( + client.proxy.transport.post( + "/config/update", + headers=client.proxy.transport.master, + json=ConfigUpdateBody(router_settings=RouterSettingsPatch(num_retries=value)), + response_type=ConfigUpdateResponse, + ) + ) + + +class TestMcpServerSubmission: + @pytest.mark.covers("mgmt.mcp_server.register.happy_path") + def test_register_submits_pending_server(self, client: ManagementClient, resources: ResourceManager) -> None: + """A non-admin, team-scoped key submits an MCP server for review; the proxy + stores it as pending_review without loading it into the runtime registry.""" + team_id = client.create_team(TeamNewBody(team_alias=f"e2e-mcp-team-{unique_marker()}")) + resources.defer(lambda: client.delete_team(team_id)) + team_key = client.proxy.generate_key(KeyGenerateBody(team_id=team_id)) + resources.defer(lambda: client.proxy.delete_key(team_key)) + + server_name = f"e2e_mcp_{unique_marker()}" + submitted = unwrap_status( + client.proxy.transport.post( + "/v1/mcp/server/register", + headers=client.proxy.transport.bearer(team_key), + json=McpRegisterBody( + server_name=server_name, + url="https://example.com/mcp", + transport="sse", + description="e2e coverage submission", + ), + response_type=McpServerResponse, + ), + 201, + ) + resources.defer(lambda: self._delete_server(client, submitted.server_id)) + assert submitted.approval_status == "pending_review", ( + f"a user submission must be pending_review, got {submitted.approval_status!r}" + ) + assert submitted.server_name == server_name and submitted.transport == "sse", ( + f"/v1/mcp/server/register echoed name={submitted.server_name!r} transport={submitted.transport!r}, " + f"configured {server_name!r}/sse" + ) + + @pytest.mark.covers("mgmt.mcp_server.approve.persists") + def test_approve_activates_submission_and_persists( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + """An admin approving a pending submission flips it to active, and the change + persists to a fresh read of the server.""" + team_id = client.create_team(TeamNewBody(team_alias=f"e2e-mcp-team-{unique_marker()}")) + resources.defer(lambda: client.delete_team(team_id)) + team_key = client.proxy.generate_key(KeyGenerateBody(team_id=team_id)) + resources.defer(lambda: client.proxy.delete_key(team_key)) + + submitted = unwrap( + client.proxy.transport.post( + "/v1/mcp/server/register", + headers=client.proxy.transport.bearer(team_key), + json=McpRegisterBody( + server_name=f"e2e_mcp_{unique_marker()}", + url="https://example.com/mcp", + transport="sse", + description="e2e coverage submission", + ), + response_type=McpServerResponse, + ) + ) + resources.defer(lambda: self._delete_server(client, submitted.server_id)) + assert submitted.approval_status == "pending_review", ( + f"a fresh submission must be pending_review before approval, got {submitted.approval_status!r}" + ) + + approved = unwrap( + client.proxy.transport.put( + f"/v1/mcp/server/{submitted.server_id}/approve", + headers=client.proxy.transport.master, + json=NoBody(), + response_type=McpServerResponse, + ) + ) + assert approved.approval_status == "active", ( + f"approve must flip the submission to active, got {approved.approval_status!r}" + ) + + fetched = unwrap( + client.proxy.transport.get( + f"/v1/mcp/server/{submitted.server_id}", + headers=client.proxy.transport.master, + params=NoBody(), + response_type=McpServerResponse, + ) + ) + assert fetched.server_id == submitted.server_id and fetched.approval_status == "active", ( + f"GET /v1/mcp/server/{submitted.server_id} reports approval_status {fetched.approval_status!r} " + "after approve, expected 'active'" + ) + + @staticmethod + def _delete_server(client: ManagementClient, server_id: str) -> None: + _ = client.proxy.transport.delete( + f"/v1/mcp/server/{server_id}", + headers=client.proxy.transport.master, + json=NoBody(), + response_type=NoBody, + ) diff --git a/tests/e2e/transport.py b/tests/e2e/transport.py index ce33face1d2..f7061b03a46 100644 --- a/tests/e2e/transport.py +++ b/tests/e2e/transport.py @@ -65,6 +65,10 @@ class Transport(Protocol): self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R] ) -> Result[R]: ... + def put[R: BaseModel]( + self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R] + ) -> Result[R]: ... + def probe(self, path: str, *, params: BaseModel) -> ProbeResult: ... def upload[R: BaseModel]( @@ -159,6 +163,17 @@ class HttpTransport: timeout=self.request_timeout, ) + def put[R: BaseModel]( + self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R] + ) -> Result[R]: + return e2e_http.put( + self._url(path), + headers=headers, + json=json, + response_type=response_type, + timeout=self.request_timeout, + ) + def stream( self, path: str, *, headers: BaseModel, json: BaseModel ) -> StreamingResponse: @@ -319,6 +334,13 @@ class SplitTransport: path, headers=headers, json=json, response_type=response_type ) + def put[R: BaseModel]( + self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R] + ) -> Result[R]: + return self._route(path).put( + path, headers=headers, json=json, response_type=response_type + ) + def stream( self, path: str, *, headers: BaseModel, json: BaseModel ) -> StreamingResponse: From bc374fcd9f0d456c567b4ae9f11f569821105c6a Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Tue, 21 Jul 2026 16:22:47 -0700 Subject: [PATCH 5/6] test(e2e): add Azure AI Foundry and Anthropic /v1/messages coverage for the Rust bridge (#34021) * test(e2e): add Azure AI Foundry and Anthropic /v1/messages coverage * test(e2e): add Azure AI Foundry + Anthropic messages coverage for the Rust bridge * test(e2e): guard against an empty SSE stream in the Azure Foundry tool-use streaming case --- .../test_messages_azure_foundry_e2e.py | 155 ++++++++++++++++++ .../e2e/llm_translation/test_messages_e2e.py | 85 +++++++++- tests/e2e/models.py | 1 + 3 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/llm_translation/test_messages_azure_foundry_e2e.py diff --git a/tests/e2e/llm_translation/test_messages_azure_foundry_e2e.py b/tests/e2e/llm_translation/test_messages_azure_foundry_e2e.py new file mode 100644 index 00000000000..3f2907f202e --- /dev/null +++ b/tests/e2e/llm_translation/test_messages_azure_foundry_e2e.py @@ -0,0 +1,155 @@ +"""Live e2e: POST /v1/messages routed to Azure AI Foundry Anthropic deployments. + +Registers `azure_ai/` deployments at runtime and drives the Messages +endpoint through the gateway across the behaviors an Anthropic client relies on: +a basic completion, a streamed completion, and tool use (non-streaming and +streaming). Auth is the Azure API key (`x-api-key`); the deployment reads +`AZURE_AI_API_BASE` / `AZURE_AI_API_KEY` from the proxy env, so no secret is +sent in the request. +""" + +from __future__ import annotations + +import pytest + +from e2e_config import unique_marker +from e2e_http import StreamingResponse, require_successful_call, unwrap +from endpoints_client import EndpointsClient +from lifecycle import ResourceManager +from models import ( + AnthropicCustomTool, + AnthropicMessagesBody, + ChatMessage, + JsonSchemaProperty, + LiteLLMParamsBody, + ToolInputSchema, +) + +pytestmark = pytest.mark.e2e + +AZURE_FOUNDRY_MODEL = "azure_ai/claude-haiku-4-5" + +WEATHER_TOOL = AnthropicCustomTool( + name="get_weather", + description="Get the current weather for a city.", + input_schema=ToolInputSchema( + properties={"city": JsonSchemaProperty(type="string")}, + required=["city"], + ), +) + + +def _assert_streamed_ok(result: StreamingResponse) -> None: + require_successful_call(result) + assert result.is_streaming, f"response was not streamed: {result.headers}" + assert not result.stream_error, f"stream errored: {result.stream_error}" + assert result.stream_events, "stream produced no SSE events" + assert any("content_block_delta" in event for event in result.stream_events), ( + "stream carried no content deltas" + ) + assert any("message_stop" in event for event in result.stream_events), ( + "stream never reached message_stop" + ) + + +class TestAzureFoundryMessages: + def _register( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> tuple[str, str]: + model = f"e2e-azure-foundry-messages-{unique_marker()}" + model_id = endpoints_client.create_model( + model, + LiteLLMParamsBody( + model=AZURE_FOUNDRY_MODEL, + api_base="os.environ/AZURE_AI_API_BASE", + api_key="os.environ/AZURE_AI_API_KEY", + ), + ) + resources.defer(lambda: endpoints_client.delete_model(model_id)) + return model, resources.key(models=[model]) + + @pytest.mark.covers("llm.messages.azure_foundry.basic.nonstream.works") + def test_basic_nonstream( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model, key = self._register(endpoints_client, resources) + response = unwrap( + endpoints_client.proxy.messages( + key, + AnthropicMessagesBody( + model=model, + max_tokens=64, + messages=[ChatMessage(role="user", content="Reply with one word.")], + ), + ) + ) + assert response.content, f"no content blocks in response: {response}" + text = "".join(block.text or "" for block in response.content if block.type == "text") + assert text.strip(), f"/v1/messages returned no text: {response}" + + @pytest.mark.covers("llm.messages.azure_foundry.basic.stream.works") + def test_basic_stream( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model, key = self._register(endpoints_client, resources) + result = endpoints_client.proxy.messages_stream( + key, + AnthropicMessagesBody( + model=model, + max_tokens=64, + stream=True, + messages=[ChatMessage(role="user", content="Count from one to three.")], + ), + ) + _assert_streamed_ok(result) + + @pytest.mark.covers("llm.messages.azure_foundry.tool_use.nonstream.works") + def test_tool_use_nonstream( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model, key = self._register(endpoints_client, resources) + response = unwrap( + endpoints_client.proxy.messages( + key, + AnthropicMessagesBody( + model=model, + max_tokens=256, + tools=[WEATHER_TOOL], + messages=[ + ChatMessage(role="user", content="What is the weather in Paris? Use the tool.") + ], + ), + ) + ) + assert response.content, f"no content blocks in response: {response}" + assert any(block.type == "tool_use" for block in response.content), ( + f"model did not call the tool: {response}" + ) + + @pytest.mark.covers("llm.messages.azure_foundry.tool_use.stream.works") + def test_tool_use_stream( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model, key = self._register(endpoints_client, resources) + result = endpoints_client.proxy.messages_stream( + key, + AnthropicMessagesBody( + model=model, + max_tokens=256, + stream=True, + tools=[WEATHER_TOOL], + messages=[ + ChatMessage(role="user", content="What is the weather in Paris? Use the tool.") + ], + ), + ) + require_successful_call(result) + assert result.is_streaming, f"response was not streamed: {result.headers}" + assert not result.stream_error, f"stream errored: {result.stream_error}" + assert result.stream_events, "stream produced no SSE events" + assert any("tool_use" in event for event in result.stream_events), ( + "stream carried no tool_use block" + ) + assert any("message_stop" in event for event in result.stream_events), ( + "stream never reached message_stop" + ) diff --git a/tests/e2e/llm_translation/test_messages_e2e.py b/tests/e2e/llm_translation/test_messages_e2e.py index b0a48f22118..a14bf8e82b3 100644 --- a/tests/e2e/llm_translation/test_messages_e2e.py +++ b/tests/e2e/llm_translation/test_messages_e2e.py @@ -1,7 +1,8 @@ """Live e2e: POST /v1/messages (Anthropic Messages API) returns a real completion. Registers an Anthropic deployment at runtime, drives the Messages endpoint through -the gateway, and asserts an assistant message with text came back. Migrated from +the gateway, and asserts an assistant message with text came back, both +non-streaming and streamed. Migrated from litellm-regression-tests/tests/test_inference_endpoints.py. """ @@ -10,18 +11,34 @@ from __future__ import annotations import pytest from e2e_config import unique_marker -from e2e_http import require_successful_call +from e2e_http import require_successful_call, unwrap from endpoints_client import EndpointsClient, MessagesResult from lifecycle import ResourceManager -from models import LiteLLMParamsBody +from models import ( + AnthropicCustomTool, + AnthropicMessagesBody, + ChatMessage, + JsonSchemaProperty, + LiteLLMParamsBody, + ToolInputSchema, +) pytestmark = pytest.mark.e2e +WEATHER_TOOL = AnthropicCustomTool( + name="get_weather", + description="Get the current weather for a city.", + input_schema=ToolInputSchema( + properties={"city": JsonSchemaProperty(type="string")}, + required=["city"], + ), +) + class TestAnthropicMessages: - def test_messages_returns_completion( + def _register( self, endpoints_client: EndpointsClient, resources: ResourceManager - ) -> None: + ) -> tuple[str, str]: model = f"e2e-messages-{unique_marker()}" model_id = endpoints_client.create_model( model, @@ -30,10 +47,66 @@ class TestAnthropicMessages: ), ) resources.defer(lambda: endpoints_client.delete_model(model_id)) - key = resources.key() + return model, resources.key() + + @pytest.mark.covers("llm.messages.anthropic.basic.nonstream.works") + def test_messages_returns_completion( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model, key = self._register(endpoints_client, resources) result = endpoints_client.messages(key, model, "reply with one word") require_successful_call(result) parsed = MessagesResult.model_validate_json(result.body) assert parsed.role == "assistant", f"unexpected role: {result.body[:300]}" assert parsed.text.strip(), f"/v1/messages returned no text: {result.body[:300]}" + + @pytest.mark.covers("llm.messages.anthropic.basic.stream.works") + def test_messages_streams_completion( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model, key = self._register(endpoints_client, resources) + + result = endpoints_client.proxy.messages_stream( + key, + AnthropicMessagesBody( + model=model, + max_tokens=64, + stream=True, + messages=[ChatMessage(role="user", content="Count from one to three.")], + ), + ) + require_successful_call(result) + assert result.is_streaming, f"response was not streamed: {result.headers}" + assert not result.stream_error, f"stream errored: {result.stream_error}" + assert result.stream_events, "stream produced no SSE events" + assert any("content_block_delta" in event for event in result.stream_events), ( + "stream carried no content deltas" + ) + assert any("message_stop" in event for event in result.stream_events), ( + "stream never reached message_stop" + ) + + @pytest.mark.covers("llm.messages.anthropic.tool_use.nonstream.works") + def test_messages_tool_use( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model, key = self._register(endpoints_client, resources) + + response = unwrap( + endpoints_client.proxy.messages( + key, + AnthropicMessagesBody( + model=model, + max_tokens=256, + tools=[WEATHER_TOOL], + messages=[ + ChatMessage(role="user", content="What is the weather in Paris? Use the tool.") + ], + ), + ) + ) + assert response.content, f"no content blocks in response: {response}" + assert any(block.type == "tool_use" for block in response.content), ( + f"model did not call the tool: {response}" + ) diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 37438010c3b..8b6c454fa1e 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -286,6 +286,7 @@ class CountTokensBody(BaseModel): class AnthropicContentBlock(BaseModel): type: str | None = None + text: str | None = None class AnthropicMessagesResponse(BaseModel): From 28e93e42e5c925062712b39d1757cf04139392ed Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 21 Jul 2026 16:23:43 -0700 Subject: [PATCH 6/6] test(ui): run vitest unit tests in GitHub Actions and fix stale key-info tests (#34175) * test(ui): run vitest unit tests in GitHub Actions and fix stale key-info tests The dashboard's vitest suite only ran on CircleCI; GitHub Actions covered the UI build, lint and api-types sync but never the unit tests. Add a UI Unit Tests workflow that runs the suite, sharded across a matrix so the wall-clock is not bound by a single 4-core runner. Porting it surfaced 17 pre-existing failures. Adding the block/unblock key action moved Delete Key and Reset Spend into a "More key actions" dropdown and introduced a React Query hook; KeyInfoHeader's own test was updated but the two KeyInfoView test files were not. Reach those actions through the dropdown and stub the new hook the way the neighbouring hook is already stubbed. The same refactor had quietly hollowed out assertions that still passed: "should not show Reset Spend button for regular key owner" queried for a button role that no longer exists, so it held green regardless of the permission check. Those now open the menu and assert on the menu item, which fails when canResetSpend is forced true. Also add the missing cost-optimization page description; page_utils guards that every navigable page carries one. * ci(ui): scope PR runs to changed tests, run the full suite on staging Running the whole vitest suite on every pull request costs about five minutes, and none of it is recoverable through parallelism: vitest schedules by file and create_mcp_server.test.tsx alone accounts for 252s of the 255s total, so shards and extra cores cannot get under that floor. Measured on this branch, css:false, pool=threads and isolate=false all landed within noise of the baseline. Scope pull requests to tests reachable from the diff instead, which takes 11s here, and keep a full run on pushes to litellm_internal_staging so nothing rots behind a gap in the module graph. Backend-only pull requests match no test files and exit zero; --passWithNoTests states that rather than leaning on it being the current default. The checkout needs full history for --changed to resolve the base commit. --- .github/workflows/test-litellm-ui-unit.yml | 57 +++++++++++++++++++ .../src/components/page_metadata.ts | 1 + .../KeyInfoView.handleKeyUpdate.test.tsx | 7 +++ .../templates/key_info_view.test.tsx | 52 ++++++++--------- 4 files changed, 91 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/test-litellm-ui-unit.yml diff --git a/.github/workflows/test-litellm-ui-unit.yml b/.github/workflows/test-litellm-ui-unit.yml new file mode 100644 index 00000000000..f0f9f504752 --- /dev/null +++ b/.github/workflows/test-litellm-ui-unit.yml @@ -0,0 +1,57 @@ +name: UI Unit Tests +permissions: + contents: read + +on: + pull_request: + branches: + - main + - litellm_internal_staging + - litellm_oss_staging + - "litellm_**" + push: + branches: + - litellm_internal_staging + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + ui-unit-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: ui/litellm-dashboard + + steps: + - name: Checkout repository + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: ui/litellm-dashboard/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run UI unit tests (Vitest) + env: + CI: "true" + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if [ -n "$BASE_SHA" ]; then + echo "Pull request: running only tests related to changes since $BASE_SHA" + npm run test -- --run --changed "$BASE_SHA" --passWithNoTests \ + --pool forks --poolOptions.forks.maxForks=4 + else + echo "Push to $GITHUB_REF_NAME: running the full suite" + npm run test -- --run --pool forks --poolOptions.forks.maxForks=4 + fi diff --git a/ui/litellm-dashboard/src/components/page_metadata.ts b/ui/litellm-dashboard/src/components/page_metadata.ts index 845e868b917..0f2ff639bb3 100644 --- a/ui/litellm-dashboard/src/components/page_metadata.ts +++ b/ui/litellm-dashboard/src/components/page_metadata.ts @@ -19,6 +19,7 @@ export const pageDescriptions: Record = { "tool-policies": "Configure tool use policies and permissions", "vector-stores": "Manage vector databases for embeddings", new_usage: "View usage analytics and metrics", + "cost-optimization": "Track and configure cost-saving features: prompt compression, caching, and auto routing", logs: "Access request and response logs", "guardrails-monitor": "Monitor guardrail performance and view logs", users: "Manage internal user accounts and permissions", diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index 0bf0a48f285..6cf8a514e8b 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -267,6 +267,13 @@ vi.mock("@/app/(dashboard)/hooks/keys/useResetKeySpend", () => ({ }), })); +vi.mock("@/app/(dashboard)/hooks/keys/useSetKeyBlockedState", () => ({ + useSetKeyBlockedState: vi.fn().mockReturnValue({ + mutate: vi.fn(), + isPending: false, + }), +})); + // useQueryClient also needs a provider; the delete-path invalidation is covered in key_info_view.test.tsx vi.mock("@tanstack/react-query", async (importOriginal) => { const actual = await importOriginal(); diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx index 5e9156fcacf..e46c6fd5c30 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx @@ -147,6 +147,10 @@ describe("KeyInfoView", () => { showSSOBanner: false, }; + const openMoreKeyActions = async () => { + await userEvent.click(await screen.findByRole("button", { name: /more key actions/i })); + }; + it("should render tags", async () => { vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock); @@ -203,8 +207,9 @@ describe("KeyInfoView", () => { await waitFor(() => { expect(screen.getByText("Regenerate Key")).toBeInTheDocument(); - expect(screen.getByText("Delete Key")).toBeInTheDocument(); }); + await openMoreKeyActions(); + expect(await screen.findByRole("menuitem", { name: /delete key/i })).toBeInTheDocument(); }); it("should allow team admin to modify key", async () => { @@ -248,8 +253,9 @@ describe("KeyInfoView", () => { await waitFor(() => { expect(screen.getByText("Regenerate Key")).toBeInTheDocument(); - expect(screen.getByText("Delete Key")).toBeInTheDocument(); }); + await openMoreKeyActions(); + expect(await screen.findByRole("menuitem", { name: /delete key/i })).toBeInTheDocument(); }); it("should allow owner to modify their own key", async () => { @@ -272,8 +278,9 @@ describe("KeyInfoView", () => { await waitFor(() => { expect(screen.getByText("Regenerate Key")).toBeInTheDocument(); - expect(screen.getByText("Delete Key")).toBeInTheDocument(); }); + await openMoreKeyActions(); + expect(await screen.findByRole("menuitem", { name: /delete key/i })).toBeInTheDocument(); }); it("should not allow other user to modify key", async () => { @@ -295,7 +302,7 @@ describe("KeyInfoView", () => { await waitFor(() => { expect(screen.queryByText("Regenerate Key")).not.toBeInTheDocument(); - expect(screen.queryByText("Delete Key")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /more key actions/i })).not.toBeInTheDocument(); }); }); @@ -319,7 +326,7 @@ describe("KeyInfoView", () => { await waitFor(() => { expect(screen.queryByText("Regenerate Key")).not.toBeInTheDocument(); - expect(screen.queryByText("Delete Key")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /more key actions/i })).not.toBeInTheDocument(); }); }); @@ -363,7 +370,7 @@ describe("KeyInfoView", () => { await waitFor(() => { expect(screen.queryByText("Regenerate Key")).not.toBeInTheDocument(); - expect(screen.queryByText("Delete Key")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /more key actions/i })).not.toBeInTheDocument(); }); }); @@ -573,9 +580,8 @@ describe("KeyInfoView", () => { />, ); - await waitFor(() => { - expect(screen.getByRole("button", { name: /reset spend/i })).toBeInTheDocument(); - }); + await openMoreKeyActions(); + expect(await screen.findByRole("menuitem", { name: /reset spend/i })).toBeInTheDocument(); }); it("should show Reset Spend button for team admin of key's team", async () => { @@ -614,9 +620,8 @@ describe("KeyInfoView", () => { />, ); - await waitFor(() => { - expect(screen.getByRole("button", { name: /reset spend/i })).toBeInTheDocument(); - }); + await openMoreKeyActions(); + expect(await screen.findByRole("menuitem", { name: /reset spend/i })).toBeInTheDocument(); }); it("should not show Reset Spend button for regular key owner", async () => { @@ -638,9 +643,9 @@ describe("KeyInfoView", () => { />, ); - await waitFor(() => { - expect(screen.queryByRole("button", { name: /reset spend/i })).not.toBeInTheDocument(); - }); + await openMoreKeyActions(); + expect(await screen.findByRole("menuitem", { name: /delete key/i })).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: /reset spend/i })).not.toBeInTheDocument(); }); }); @@ -663,11 +668,8 @@ describe("KeyInfoView", () => { />, ); - await waitFor(() => { - expect(screen.getByRole("button", { name: /reset spend/i })).toBeInTheDocument(); - }); - - await userEvent.click(screen.getByRole("button", { name: /reset spend/i })); + await openMoreKeyActions(); + await userEvent.click(await screen.findByRole("menuitem", { name: /reset spend/i })); await waitFor(() => { expect(screen.getByText("Reset Key Spend")).toBeInTheDocument(); @@ -694,11 +696,8 @@ describe("KeyInfoView", () => { />, ); - await waitFor(() => { - expect(screen.getByRole("button", { name: /reset spend/i })).toBeInTheDocument(); - }); - - await userEvent.click(screen.getByRole("button", { name: /reset spend/i })); + await openMoreKeyActions(); + await userEvent.click(await screen.findByRole("menuitem", { name: /reset spend/i })); await waitFor(() => { expect(screen.getByText("Reset Key Spend")).toBeInTheDocument(); @@ -808,7 +807,8 @@ describe("KeyInfoView", () => { />, ); - await userEvent.click(await screen.findByRole("button", { name: /delete key/i })); + await openMoreKeyActions(); + await userEvent.click(await screen.findByRole("menuitem", { name: /delete key/i })); const confirmInput = await screen.findByPlaceholderText(MOCK_KEY_DATA.key_alias); await userEvent.type(confirmInput, MOCK_KEY_DATA.key_alias);