From d7ca77e25507d42feaafa76b2ddf093a1c0c2888 Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Thu, 24 Sep 2026 14:23:00 +0200 Subject: [PATCH 1/2] feat(keys): filter /key/list by tag Adds a tag query parameter that returns only keys whose metadata.tags holds that exact tag name. It is ANDed on top of the caller's visibility, so non-admins still only see keys they could already see --- .../key_management_endpoints.py | 14 ++++- .../key_management_endpoints.py | 11 ++++ .../test_key_management_endpoints.py | 57 ++++++++++++++++++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 + 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index d417ec1479f..cbc30496bab 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -158,6 +158,7 @@ from litellm.types.proxy.management_endpoints.key_management_endpoints import ( CustomKeyPolicyRequest, FailedKeyUpdate, KeySearchWhere, + KeyTagWhere, SuccessfulKeyUpdate, ) from litellm.types.router import Deployment @@ -6525,6 +6526,7 @@ async def list_keys( project_id: str | None = Query(None, description="Filter keys by project ID"), access_group_id: str | None = Query(None, description="Filter keys by access group ID"), agent_id: str | None = Query(None, description="Filter keys by agent ID"), + tag: str | None = Query(None, description="Filter keys by tag name. Exact match on an entry of metadata.tags"), substring_matching: bool = Query( False, description="If true, match key_alias (any caller) and user_id (proxy admins only) as case-insensitive substrings instead of exact values. Defaults to false: /key/list matched these exactly before substring search was added, and an exact user_id filter must never return another user's keys.", @@ -6655,6 +6657,7 @@ async def list_keys( project_id=project_id, access_group_id=access_group_id, agent_id=agent_id, + tag=tag, use_substring_matching=use_substring_matching, use_key_alias_substring_matching=use_key_alias_substring_matching, expires_filter=expires if isinstance(expires, str) else None, @@ -6900,6 +6903,11 @@ def _build_key_search_where(search: str) -> KeySearchWhere: return search_where +def _build_key_tag_where(tag: str) -> KeyTagWhere: + tag_where: Final[KeyTagWhere] = {"metadata": {"path": ("tags",), "array_contains": json.dumps((tag,))}} + return tag_where + + def _build_key_filter_conditions( user_id: str | None, team_id: str | None, @@ -6913,6 +6921,7 @@ def _build_key_filter_conditions( project_id: str | None = None, access_group_id: str | None = None, agent_id: str | None = None, + tag: str | None = None, use_substring_matching: bool = False, use_key_alias_substring_matching: bool = False, expires_filter: str | None = None, @@ -7005,7 +7014,7 @@ def _build_key_filter_conditions( elif len(or_conditions) == 1: where.update(or_conditions[0]) - # Apply team_id, project_id and access_group_id as global AND filters so they + # Apply team_id, project_id, access_group_id and tag as global AND filters so they # narrow results across all visibility conditions (own keys, team keys, etc.) now: Final = datetime.now(timezone.utc) status_where: Final = _build_status_where_clause(status_filter, now) @@ -7025,6 +7034,7 @@ def _build_key_filter_conditions( *(({"project_id": project_id},) if project_id else ()), *(({"access_group_ids": {"hasSome": [access_group_id]}},) if access_group_id else ()), *(({"agent_id": agent_id},) if agent_id and isinstance(agent_id, str) else ()), + *((_build_key_tag_where(tag),) if tag and isinstance(tag, str) else ()), *( (_build_expires_where_clause(expires_filter, now),) if expires_filter is not None and expires_filter in VALID_EXPIRES_FILTER_VALUES @@ -7059,6 +7069,7 @@ async def _list_key_helper( project_id: str | None = None, access_group_id: str | None = None, agent_id: str | None = None, + tag: str | None = None, use_substring_matching: bool = False, use_key_alias_substring_matching: bool = False, expires_filter: str | None = None, @@ -7099,6 +7110,7 @@ async def _list_key_helper( project_id=project_id, access_group_id=access_group_id, agent_id=agent_id, + tag=tag, use_substring_matching=use_substring_matching, use_key_alias_substring_matching=use_key_alias_substring_matching, expires_filter=expires_filter, diff --git a/litellm/types/proxy/management_endpoints/key_management_endpoints.py b/litellm/types/proxy/management_endpoints/key_management_endpoints.py index 001bc3c0d51..a8993615d45 100644 --- a/litellm/types/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/types/proxy/management_endpoints/key_management_endpoints.py @@ -29,6 +29,17 @@ class KeySearchWhere(TypedDict): OR: ReadOnly[tuple[KeyTokenWhere, KeyAliasContainsWhere]] +class JsonPathArrayContains(TypedDict): + path: ReadOnly[tuple[str, ...]] + array_contains: ReadOnly[str] + + +class KeyTagWhere(TypedDict): + """Prisma filter behind `/key/list?tag=`: keys whose metadata.tags array holds the exact tag.""" + + metadata: ReadOnly[JsonPathArrayContains] + + class BulkUpdateKeyRequestItem(BaseModel): """One /key/bulk_update item; only the fields it carries are written.""" diff --git a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py index 5ea38ce23d5..3ebf3ed8ac1 100644 --- a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py @@ -17182,6 +17182,12 @@ def _prisma_where_matches(row, where): elif field == "OR": if not any(_prisma_where_matches(row, child) for child in expected): return False + elif isinstance(expected, dict) and "array_contains" in expected: + value = getattr(row, field) + for key in expected["path"]: + value = value.get(key) if isinstance(value, dict) else None + if not isinstance(value, list) or not all(item in value for item in json.loads(expected["array_contains"])): + return False elif isinstance(expected, dict): value = getattr(row, field) if "in" in expected and value not in expected["in"]: @@ -17222,14 +17228,14 @@ _TEAM_A_KEYS = ( ) -def _list_team_a_keys_as(user_role, members_with_roles, query): +def _list_team_a_keys_as(user_role, members_with_roles, query, rows=_TEAM_A_KEYS): from fastapi import FastAPI from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.management_endpoints.key_management_endpoints import router mock_prisma_client = AsyncMock() - mock_prisma_client.db.litellm_verificationtoken = _InMemoryVerificationTokenTable(_TEAM_A_KEYS) + mock_prisma_client.db.litellm_verificationtoken = _InMemoryVerificationTokenTable(rows) mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock( return_value=LiteLLM_UserTable(user_id="alice", teams=["team-a"], organization_memberships=[]) ) @@ -17276,6 +17282,53 @@ def test_list_keys_key_alias_stays_exact_without_substring_matching(): ) == ["tok-alice-first"] +def _tagged_team_key(token, key_alias, user_id, metadata): + return LiteLLM_VerificationToken( + token=token, key_alias=key_alias, user_id=user_id, team_id="team-a", metadata=metadata + ) + + +_TAGGED_TEAM_A_KEYS = ( + _tagged_team_key("tok-alice-batch", "alice-batch", "alice", {"tags": ["prod-batch", "nightly"]}), + _tagged_team_key("tok-bob-batch", "bob-batch", "bob", {"tags": ["prod-batch"]}), + _tagged_team_key("tok-svc-batch", "svc-batch", None, {"tags": ["prod-batch"]}), + _tagged_team_key("tok-alice-similar", "alice-similar", "alice", {"tags": ["prod-batch-2"]}), + _tagged_team_key("tok-alice-scalar", "alice-scalar", "alice", {"tags": "prod-batch"}), + _tagged_team_key("tok-alice-untagged", "alice-untagged", "alice", {}), +) + + +def test_list_keys_tag_returns_only_keys_carrying_that_exact_tag(): + keys = _list_team_a_keys_as( + LitellmUserRoles.INTERNAL_USER, _ALICE_TEAM_ADMIN, "tag=prod-batch", rows=_TAGGED_TEAM_A_KEYS + ) + assert keys == ["tok-alice-batch", "tok-bob-batch", "tok-svc-batch"] + + +def test_list_keys_tag_composes_with_other_filters(): + keys = _list_team_a_keys_as( + LitellmUserRoles.INTERNAL_USER, + _ALICE_TEAM_ADMIN, + "tag=prod-batch&key_alias=alice&substring_matching=true", + rows=_TAGGED_TEAM_A_KEYS, + ) + assert keys == ["tok-alice-batch"] + + +def test_list_keys_tag_stays_within_caller_visibility(): + keys = _list_team_a_keys_as( + LitellmUserRoles.INTERNAL_USER, _ALICE_TEAM_MEMBER, "tag=prod-batch", rows=_TAGGED_TEAM_A_KEYS + ) + assert keys == ["tok-alice-batch", "tok-svc-batch"] + + +def test_list_keys_tag_without_keys_returns_none(): + keys = _list_team_a_keys_as( + LitellmUserRoles.INTERNAL_USER, _ALICE_TEAM_ADMIN, "tag=unused-tag", rows=_TAGGED_TEAM_A_KEYS + ) + assert keys == [] + + @pytest.mark.asyncio async def test_list_keys_search_is_honored_for_non_admin(): """LIT-4741: unlike substring_matching, `search` is not admin-gated. A non-admin's diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index b72f2503e5d..83e66b24fe6 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -61186,6 +61186,8 @@ export interface operations { access_group_id?: string | null; /** @description Filter keys by agent ID */ agent_id?: string | null; + /** @description Filter keys by tag name. Exact match on an entry of metadata.tags */ + tag?: string | null; /** @description If true, match key_alias (any caller) and user_id (proxy admins only) as case-insensitive substrings instead of exact values. Defaults to false: /key/list matched these exactly before substring search was added, and an exact user_id filter must never return another user's keys. */ substring_matching?: boolean; /** @description Filter keys by expiration. 'expired' returns keys whose expires is in the past; 'active' returns keys that never expire or expire in the future. Omit to return keys regardless of expiration. */ From 22d3a9bbafeb9bcb98999f664e55e76e969f8f9c Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Thu, 24 Sep 2026 14:23:00 +0200 Subject: [PATCH 2/2] feat(tags): show a tag's virtual keys on the tag details page The tag details page gets a Virtual Keys card listing the keys that carry the tag, each linking to its key page, with loading, error and empty states --- .../(dashboard)/hooks/keys/useKeys.test.ts | 16 ++++ .../src/app/(dashboard)/hooks/keys/useKeys.ts | 2 + .../_components/TagKeysSection.test.tsx | 84 +++++++++++++++++++ .../_components/TagKeysSection.tsx | 69 +++++++++++++++ .../_components/tag_info.integration.test.tsx | 70 +++++++++++++++- .../tag-management/_components/tag_info.tsx | 3 + 6 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagKeysSection.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagKeysSection.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts index 84be7e2ef49..ca916dd8918 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts @@ -424,6 +424,22 @@ describe("useKeys", () => { expect(result.current.data?.keys[0].project_id).toBe("project-1"); }); + it("should pass the tag filter to the API", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockKeysResponse, + }); + + const { result } = renderHook(() => useKeys(1, 10, { tag: "prod batch" }), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const callUrl = new URL(mockFetch.mock.calls[0][0], "http://localhost"); + expect(callUrl.searchParams.get("tag")).toBe("prod batch"); + }); + it("should pass both projectID and teamID filters to the API", async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts index 7e7089e685f..106c791420f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts @@ -37,6 +37,7 @@ export interface KeyListCallOptions { teamID?: string | null; projectID?: string | null; agentID?: string | null; + tag?: string | null; selectedKeyAlias?: string | null; userID?: string | null; keyHash?: string | null; @@ -59,6 +60,7 @@ const keyListCall = async (accessToken: string, page: number, pageSize: number, team_id: options.teamID, project_id: options.projectID, agent_id: options.agentID, + tag: options.tag, organization_id: options.organizationID, key_alias: options.selectedKeyAlias, key_hash: options.keyHash, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagKeysSection.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagKeysSection.test.tsx new file mode 100644 index 00000000000..b1f31888386 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagKeysSection.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { KeyResponse } from "@/components/key_team_helpers/key_list"; + +import TagKeysSection, { TAG_KEYS_PAGE_SIZE } from "./TagKeysSection"; + +const mockUseKeys = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ + useKeys: (...args: unknown[]) => mockUseKeys(...args), +})); + +const makeKey = (overrides: Partial): KeyResponse => + ({ token: "tok", key_alias: "", key_name: "sk-...0000", team_id: null, spend: 0, ...overrides }) as KeyResponse; + +const loaded = (keys: KeyResponse[], totalCount = keys.length) => ({ + data: { keys, total_count: totalCount, current_page: 1, total_pages: 1 }, + isLoading: false, + isError: false, +}); + +describe("TagKeysSection", () => { + beforeEach(() => { + mockUseKeys.mockReset(); + }); + + it("should request the first page of keys filtered by the tag name", () => { + mockUseKeys.mockReturnValue(loaded([])); + render(); + expect(mockUseKeys).toHaveBeenCalledWith(1, TAG_KEYS_PAGE_SIZE, { tag: "prod-batch" }); + }); + + it("should list each key with its alias linking to the key's page, its team and its spend", () => { + const teamKey: Partial = { token: "tok-1", key_alias: "batch-key-1", team_id: "team-a", spend: 1.5 }; + mockUseKeys.mockReturnValue(loaded([makeKey(teamKey), makeKey({ token: "tok-2", key_alias: "batch-key-2" })])); + render(); + + expect(screen.getByRole("link", { name: "batch-key-1" })).toHaveAttribute("href", "/ui/api-keys?key=tok-1"); + expect(screen.getByRole("link", { name: "batch-key-2" })).toHaveAttribute("href", "/ui/api-keys?key=tok-2"); + const firstRow = screen.getByRole("row", { name: /batch-key-1/ }); + expect(within(firstRow).getByText("team-a")).toBeInTheDocument(); + expect(within(firstRow).getByText("1.5000")).toBeInTheDocument(); + expect(within(screen.getByRole("row", { name: /batch-key-2/ })).getByText("-")).toBeInTheDocument(); + }); + + it("should fall back to the masked key name when a key has no alias", () => { + mockUseKeys.mockReturnValue(loaded([makeKey({ token: "tok-3", key_alias: "", key_name: "sk-...wxyz" })])); + render(); + expect(screen.getByRole("link", { name: "sk-...wxyz" })).toHaveAttribute("href", "/ui/api-keys?key=tok-3"); + }); + + it("should say no virtual keys use the tag when none carry it", () => { + mockUseKeys.mockReturnValue(loaded([])); + render(); + expect(screen.getByText("No virtual keys use this tag")).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); + + it("should show a loading message while the keys are loading", () => { + mockUseKeys.mockReturnValue({ data: undefined, isLoading: true, isError: false }); + render(); + expect(screen.getByText("Loading virtual keys...")).toBeInTheDocument(); + expect(screen.queryByText("No virtual keys use this tag")).not.toBeInTheDocument(); + }); + + it("should show an error message instead of the empty message when the keys fail to load", () => { + mockUseKeys.mockReturnValue({ data: undefined, isLoading: false, isError: true }); + render(); + expect(screen.getByText("Could not load the virtual keys for this tag")).toBeInTheDocument(); + expect(screen.queryByText("No virtual keys use this tag")).not.toBeInTheDocument(); + }); + + it("should say how many keys are shown when the tag has more keys than one page", () => { + mockUseKeys.mockReturnValue(loaded([makeKey({ token: "tok-1", key_alias: "batch-key-1" })], 250)); + render(); + expect(screen.getByText("Showing the 1 most recently created of 250 keys")).toBeInTheDocument(); + }); + + it("should not show a count note when every key fits on the page", () => { + mockUseKeys.mockReturnValue(loaded([makeKey({ token: "tok-1", key_alias: "batch-key-1" })])); + render(); + expect(screen.queryByText(/most recently created/)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagKeysSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagKeysSection.tsx new file mode 100644 index 00000000000..63498827454 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagKeysSection.tsx @@ -0,0 +1,69 @@ +"use client"; + +import React from "react"; + +import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { Card, CardContent, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { keyDetailHref } from "@/utils/entityLinks"; + +export const TAG_KEYS_PAGE_SIZE = 100; + +interface TagKeysSectionProps { + tagName: string; +} + +const TagKeysSection: React.FC = ({ tagName }) => { + const { data, isLoading, isError } = useKeys(1, TAG_KEYS_PAGE_SIZE, { tag: tagName }); + const keys = data?.keys ?? []; + const totalCount = data?.total_count ?? 0; + + const renderBody = () => { + if (isLoading) return

Loading virtual keys...

; + if (isError) return

Could not load the virtual keys for this tag

; + if (keys.length === 0) return

No virtual keys use this tag

; + return ( + <> + {totalCount > keys.length && ( +

+ Showing the {keys.length} most recently created of {totalCount} keys +

+ )} + + + + Key + Team + Spend (USD) + + + + {keys.map((key) => ( + + + + {key.key_alias || key.key_name} + + + {key.team_id ?? "-"} + {formatNumberWithCommas(key.spend, 4)} + + ))} + +
+ + ); + }; + + return ( + + + Virtual Keys + {renderBody()} + + + ); +}; + +export default TagKeysSection; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.integration.test.tsx index c23f7112f31..e75f2488313 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.integration.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { tagInfoCall, tagUpdateCall } from "@/components/networking"; @@ -10,6 +11,14 @@ import TagInfoView from "./tag_info"; vi.mock("@/components/networking", () => ({ tagInfoCall: vi.fn(), tagUpdateCall: vi.fn(), + getProxyBaseUrl: () => "", + getGlobalLitellmHeaderName: () => "Authorization", + deriveErrorMessage: (errorData: unknown) => JSON.stringify(errorData), + handleError: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "sk-test" }), })); vi.mock("@/components/organisms/create_key_button", () => ({ @@ -34,9 +43,20 @@ const tag: Tag = { litellm_budget_table: { max_budget: 10, budget_duration: "7d", tpm_limit: 1000, rpm_limit: 60 }, }; +const keyListFetch = vi.fn(); + +const renderTagInfo = (editTag: boolean) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); +}; + const renderEditor = async () => { const user = userEvent.setup(); - render(); + renderTagInfo(true); const nameInput = await screen.findByLabelText("Tag Name"); return { user, nameInput }; }; @@ -46,6 +66,11 @@ describe("TagInfoView save payload", () => { vi.clearAllMocks(); mockTagInfoCall.mockResolvedValue({ "prod-tag": tag }); mockTagUpdateCall.mockResolvedValue(undefined); + keyListFetch.mockResolvedValue({ + ok: true, + json: async () => ({ keys: [], total_count: 0, current_page: 1, total_pages: 0 }), + }); + vi.stubGlobal("fetch", keyListFetch); }); it("should send the edited fields and omit the budget fields while the budget section is collapsed", async () => { @@ -150,3 +175,46 @@ describe("TagInfoView save payload", () => { expect(mockTagUpdateCall).not.toHaveBeenCalled(); }); }); + +describe("TagInfoView virtual keys", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockTagInfoCall.mockResolvedValue({ "prod-tag": tag }); + vi.stubGlobal("fetch", keyListFetch); + }); + + it("should list the keys that /key/list returns for this tag, each linking to its key page", async () => { + keyListFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + keys: [{ token: "tok-1", key_alias: "batch-key-1", key_name: "sk-...0001", team_id: "team-a", spend: 2 }], + total_count: 1, + current_page: 1, + total_pages: 1, + }), + }); + renderTagInfo(false); + + expect(await screen.findByRole("link", { name: "batch-key-1" })).toHaveAttribute("href", "/ui/api-keys?key=tok-1"); + const requestUrl = new URL(keyListFetch.mock.calls[0][0], "http://localhost"); + expect(requestUrl.pathname).toBe("/key/list"); + expect(requestUrl.searchParams.get("tag")).toBe("prod-tag"); + }); + + it("should say no virtual keys use the tag when /key/list returns none", async () => { + keyListFetch.mockResolvedValue({ + ok: true, + json: async () => ({ keys: [], total_count: 0, current_page: 1, total_pages: 0 }), + }); + renderTagInfo(false); + + expect(await screen.findByText("No virtual keys use this tag")).toBeInTheDocument(); + }); + + it("should show an error in the section when /key/list fails", async () => { + keyListFetch.mockResolvedValue({ ok: false, json: async () => ({ error: "boom" }) }); + renderTagInfo(false); + + expect(await screen.findByText("Could not load the virtual keys for this tag")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx index 1648a99bb0e..39170ad59ef 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tag_info.tsx @@ -22,6 +22,7 @@ import { Textarea } from "@/components/ui/textarea"; import { useZodForm } from "@/lib/forms/useZodForm"; import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; import { CheckIcon, ChevronRight, CopyIcon } from "lucide-react"; +import TagKeysSection from "./TagKeysSection"; const tagEditShape = { name: z.string().min(1, "Please input a tag name"), @@ -337,6 +338,8 @@ const TagInfoView: React.FC = ({ tagId, onClose, accessToken, )} + + )}