From f5e46f621a7be979e5818218cc7a13b7c170006f Mon Sep 17 00:00:00 2001 From: Chesars Date: Sat, 14 Feb 2026 21:50:12 -0300 Subject: [PATCH 01/97] feat: support per-request enable_json_schema_validation for thread safety Allow passing enable_json_schema_validation as a parameter to completion() and acompletion() instead of only relying on the global litellm.enable_json_schema_validation flag. The per-request value takes priority when provided; otherwise falls back to the global (backward compatible). This makes JSON schema validation safe for concurrent usage in FastAPI and other multi-threaded environments. --- litellm/main.py | 5 + litellm/types/utils.py | 1 + litellm/utils.py | 13 +- .../test_json_schema_validation.py | 139 ++++++++++++++++++ 4 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 tests/litellm/litellm_core_utils/test_json_schema_validation.py diff --git a/litellm/main.py b/litellm/main.py index bca023e65ec..362e5b8b263 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -416,6 +416,8 @@ async def acompletion( # noqa: PLR0915 web_search_options: Optional[OpenAIWebSearchOptions] = None, # Session management shared_session: Optional["ClientSession"] = None, + # Per-request JSON schema validation (overrides litellm.enable_json_schema_validation) + enable_json_schema_validation: Optional[bool] = None, **kwargs, ) -> Union[ModelResponse, CustomStreamWrapper]: """ @@ -560,6 +562,7 @@ async def acompletion( # noqa: PLR0915 "thinking": thinking, "web_search_options": web_search_options, "shared_session": shared_session, + "enable_json_schema_validation": enable_json_schema_validation, } if custom_llm_provider is None: _, custom_llm_provider, _, _ = get_llm_provider( @@ -1045,6 +1048,8 @@ def completion( # type: ignore # noqa: PLR0915 thinking: Optional[AnthropicThinkingParam] = None, # Session management shared_session: Optional["ClientSession"] = None, + # Per-request JSON schema validation (overrides litellm.enable_json_schema_validation) + enable_json_schema_validation: Optional[bool] = None, **kwargs, ) -> Union[ModelResponse, CustomStreamWrapper]: """ diff --git a/litellm/types/utils.py b/litellm/types/utils.py index e1f780ffcc3..72705b82fc2 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2939,6 +2939,7 @@ all_litellm_params = ( "shared_session", "search_tool_name", "order", + "enable_json_schema_validation", ] + list(StandardCallbackDynamicParams.__annotations__.keys()) + list(CustomPricingLiteLLMParams.model_fields.keys()) diff --git a/litellm/utils.py b/litellm/utils.py index 6fdd2d88bca..0af844772b9 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -1318,7 +1318,18 @@ def post_call_processing( ### POST-CALL RULES ### rules_obj.post_call_rules(input=model_response, model=model) ### JSON SCHEMA VALIDATION ### - if litellm.enable_json_schema_validation is True: + # Per-request flag takes priority over global flag + _per_request_validation = ( + optional_params.get("enable_json_schema_validation") + if optional_params is not None + else None + ) + _enable_json_schema_validation = ( + _per_request_validation + if _per_request_validation is not None + else litellm.enable_json_schema_validation + ) + if _enable_json_schema_validation is True: try: if ( optional_params is not None diff --git a/tests/litellm/litellm_core_utils/test_json_schema_validation.py b/tests/litellm/litellm_core_utils/test_json_schema_validation.py new file mode 100644 index 00000000000..859a1238a61 --- /dev/null +++ b/tests/litellm/litellm_core_utils/test_json_schema_validation.py @@ -0,0 +1,139 @@ +""" +Tests for per-request enable_json_schema_validation parameter. + +Ensures the per-request flag overrides the global litellm.enable_json_schema_validation, +making JSON schema validation thread-safe for concurrent usage. + +Related issue: https://github.com/BerriAI/litellm/issues/XXXX +""" + +import json + +import pytest + +import litellm +from litellm.types.utils import ModelResponse +from litellm.utils import Rules, post_call_processing + + +def _make_response(content: dict) -> ModelResponse: + """Create a ModelResponse with the given content as JSON string.""" + response = ModelResponse() + response.choices[0].message.content = json.dumps(content) + return response + + +def _mock_completion(): + """Mock function with __name__ == 'completion' for post_call_processing.""" + pass + + +_mock_completion.__name__ = "completion" + +# Schema that requires 'title' (string) and 'rating' (integer) +STRICT_SCHEMA = { + "type": "json_schema", + "json_schema": { + "name": "MovieReview", + "schema": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "rating": {"type": "integer"}, + }, + "required": ["title", "rating"], + }, + }, +} + +# Response that does NOT match the schema (wrong field names) +INVALID_RESPONSE = _make_response({"name": "test", "age": 25}) + +# Response that matches the schema +VALID_RESPONSE = _make_response({"title": "Inception", "rating": 9}) + + +@pytest.fixture(autouse=True) +def _reset_global_flag(): + """Reset the global flag before and after each test.""" + original = litellm.enable_json_schema_validation + litellm.enable_json_schema_validation = False + yield + litellm.enable_json_schema_validation = original + + +class TestPerRequestJsonSchemaValidation: + """Test that per-request enable_json_schema_validation overrides the global flag.""" + + def test_global_off_no_per_request_skips_validation(self): + """Global OFF + no per-request flag -> no validation (default behavior).""" + litellm.enable_json_schema_validation = False + # Should NOT raise even though response doesn't match schema + post_call_processing( + INVALID_RESPONSE, + "test-model", + {"response_format": STRICT_SCHEMA}, + _mock_completion, + Rules(), + ) + + def test_per_request_on_overrides_global_off(self): + """Global OFF + per-request ON -> validation runs and catches invalid response.""" + litellm.enable_json_schema_validation = False + with pytest.raises(litellm.JSONSchemaValidationError): + post_call_processing( + INVALID_RESPONSE, + "test-model", + { + "response_format": STRICT_SCHEMA, + "enable_json_schema_validation": True, + }, + _mock_completion, + Rules(), + ) + + def test_per_request_off_overrides_global_on(self): + """Global ON + per-request OFF -> validation skipped (per-request wins).""" + litellm.enable_json_schema_validation = True + # Should NOT raise because per-request says False + post_call_processing( + INVALID_RESPONSE, + "test-model", + { + "response_format": STRICT_SCHEMA, + "enable_json_schema_validation": False, + }, + _mock_completion, + Rules(), + ) + + def test_global_on_no_per_request_validates(self): + """Global ON + no per-request flag -> validation runs (backward compatible).""" + litellm.enable_json_schema_validation = True + with pytest.raises(litellm.JSONSchemaValidationError): + post_call_processing( + INVALID_RESPONSE, + "test-model", + {"response_format": STRICT_SCHEMA}, + _mock_completion, + Rules(), + ) + + def test_valid_response_passes_with_per_request_on(self): + """Per-request ON + valid response -> no error raised.""" + post_call_processing( + VALID_RESPONSE, + "test-model", + { + "response_format": STRICT_SCHEMA, + "enable_json_schema_validation": True, + }, + _mock_completion, + Rules(), + ) + + def test_per_request_flag_is_in_all_litellm_params(self): + """Ensure the param is registered so it doesn't leak to provider APIs.""" + from litellm.types.utils import all_litellm_params + + assert "enable_json_schema_validation" in all_litellm_params From fd4dc028bcd65c8be3476fbcdebc66124d54d66e Mon Sep 17 00:00:00 2001 From: Chesars Date: Wed, 18 Feb 2026 16:22:55 -0300 Subject: [PATCH 02/97] fix(anthropic): remove hardcoded reasoning summary in adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter was injecting `summary: "detailed"` into the reasoning config when routing Anthropic thinking requests to OpenAI's Responses API. Per the OpenAI spec, reasoning.summary is opt-in — it should not be added unless the user explicitly requests it. --- .../adapters/handler.py | 12 +----- ...erimental_pass_through_messages_handler.py | 39 ++++++++++++++++++- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index 73e74c228ba..e6a73fdbb59 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -44,8 +44,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: For OpenAI models, Chat Completions typically does not return reasoning text (only token accounting). To return a thinking-like content block in the - Anthropic response format, we route the request through OpenAI's Responses API - and request a reasoning summary. + Anthropic response format, we route the request through OpenAI's Responses API. """ custom_llm_provider = completion_kwargs.get("custom_llm_provider") if custom_llm_provider is None: @@ -80,16 +79,7 @@ class LiteLLMMessagesToCompletionTransformationHandler: if isinstance(reasoning_effort, str) and reasoning_effort: completion_kwargs["reasoning_effort"] = { "effort": reasoning_effort, - "summary": "detailed", } - elif isinstance(reasoning_effort, dict): - if ( - "summary" not in reasoning_effort - and "generate_summary" not in reasoning_effort - ): - updated_reasoning_effort = dict(reasoning_effort) - updated_reasoning_effort["summary"] = "detailed" - completion_kwargs["reasoning_effort"] = updated_reasoning_effort @staticmethod def _prepare_completion_kwargs( diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py index 376d14416a3..a4d5ade568b 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py @@ -177,8 +177,8 @@ def test_openai_model_with_thinking_converts_to_reasoning_effort(): # Verify reasoning_effort is set (converted from thinking) assert "reasoning_effort" in call_kwargs, "reasoning_effort should be passed to completion" - # reasoning_effort is transformed into a dict with effort and summary fields - expected_reasoning_effort = {"effort": "minimal", "summary": "detailed"} + # reasoning_effort is transformed into a dict with effort field only (no hardcoded summary) + expected_reasoning_effort = {"effort": "minimal"} assert call_kwargs["reasoning_effort"] == expected_reasoning_effort, \ f"reasoning_effort should be {expected_reasoning_effort} for budget_tokens=1024, got {call_kwargs.get('reasoning_effort')}" @@ -218,3 +218,38 @@ class TestThinkingParameterTransformation: assert result == {"reasoning_effort": "minimal"} assert "thinking" not in result + + +class TestNoHardcodedReasoningSummary: + """Tests for issue #20998: adapter must not hardcode reasoning summary. + + Per OpenAI spec, reasoning.summary is opt-in. The adapter should not + inject summary='detailed' when the user didn't request it. + """ + + def test_no_summary_added_when_not_requested(self): + """reasoning_effort dict should only contain 'effort', no 'summary'.""" + from litellm.llms.anthropic.experimental_pass_through.adapters.handler import ( + LiteLLMMessagesToCompletionTransformationHandler, + ) + + thinking = {"type": "enabled", "budget_tokens": 5000} + completion_kwargs = {"model": "openai/gpt-5.1", "reasoning_effort": "medium"} + LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed( + completion_kwargs, thinking=thinking + ) + assert completion_kwargs["reasoning_effort"] == {"effort": "medium"} + assert "summary" not in completion_kwargs["reasoning_effort"] + + def test_model_prefixed_with_responses(self): + """Model should be prefixed with 'responses/' for Responses API routing.""" + from litellm.llms.anthropic.experimental_pass_through.adapters.handler import ( + LiteLLMMessagesToCompletionTransformationHandler, + ) + + thinking = {"type": "enabled", "budget_tokens": 5000} + completion_kwargs = {"model": "openai/gpt-5.1", "reasoning_effort": "medium"} + LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed( + completion_kwargs, thinking=thinking + ) + assert completion_kwargs["model"] == "responses/openai/gpt-5.1" From 5596545fb99743b0e8870c5a5b1a30aa88ccd02d Mon Sep 17 00:00:00 2001 From: Chesars Date: Thu, 19 Feb 2026 15:01:41 -0300 Subject: [PATCH 03/97] fix(gemini): correct streaming finish_reason for tool calls Gemini returns finishReason="STOP" even when tool calls are present, and sends tool_calls and finishReason in separate streaming chunks. The ModelResponseIterator now tracks tool_calls across chunks and correctly maps finish_reason to "tool_calls" per the OpenAI spec. Fixes #21041 --- .../vertex_and_google_ai_studio_gemini.py | 35 +++ ...emini_streaming_tool_call_finish_reason.py | 232 ++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 tests/test_litellm/llms/vertex_ai/gemini/test_gemini_streaming_tool_call_finish_reason.py diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index bef83b6d35e..3c06ba94450 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -2864,6 +2864,7 @@ class ModelResponseIterator: self.logging_obj = logging_obj self.is_function_call = check_is_function_call(logging_obj) self.cumulative_tool_call_index: int = 0 + self.has_seen_tool_calls: bool = False def chunk_parser(self, chunk: dict) -> Optional["ModelResponseStream"]: try: @@ -2902,6 +2903,40 @@ class ModelResponseIterator: cumulative_tool_call_index=self.cumulative_tool_call_index, ) + # Track whether tool_calls have been seen across streaming chunks. + # Gemini sends tool_calls and finishReason in separate chunks, + # so we need to remember if earlier chunks contained tool_calls + # to correctly set finish_reason="tool_calls" per the OpenAI spec. + if not self.has_seen_tool_calls: + for choice in model_response.choices: + if hasattr(choice, "delta") and choice.delta and choice.delta.tool_calls: + self.has_seen_tool_calls = True + break + + # Handle final chunk with finishReason but no content. + # _process_candidates skips candidates without "content", + # so the finish_reason from the final chunk is lost. + if not model_response.choices and _candidates: + from litellm.types.utils import Delta, StreamingChoices + + for candidate in _candidates: + finish_reason_str = candidate.get("finishReason") + if finish_reason_str is not None: + if self.has_seen_tool_calls: + mapped_finish_reason = "tool_calls" + else: + mapped_finish_reason = VertexGeminiConfig._check_finish_reason( + None, finish_reason_str + ) + choice = StreamingChoices( + finish_reason=mapped_finish_reason, + index=candidate.get("index", 0), + delta=Delta(content=None, role=None), + logprobs=None, + enhancements=None, + ) + model_response.choices.append(choice) + setattr(model_response, "vertex_ai_grounding_metadata", grounding_metadata) # type: ignore setattr(model_response, "vertex_ai_url_context_metadata", url_context_metadata) # type: ignore setattr(model_response, "vertex_ai_safety_ratings", safety_ratings) # type: ignore diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_gemini_streaming_tool_call_finish_reason.py b/tests/test_litellm/llms/vertex_ai/gemini/test_gemini_streaming_tool_call_finish_reason.py new file mode 100644 index 00000000000..3f8efd47fa3 --- /dev/null +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_gemini_streaming_tool_call_finish_reason.py @@ -0,0 +1,232 @@ +""" +Tests for Gemini streaming tool call finish_reason mapping. + +Gemini returns finishReason: "STOP" even when tool calls are present. +Per the OpenAI spec, finish_reason must be "tool_calls" when the model +called a tool. The ModelResponseIterator must track tool_calls across +streaming chunks and correctly set finish_reason on the final chunk. + +Ref: https://github.com/BerriAI/litellm/issues/21041 +""" + +from unittest.mock import MagicMock + +from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + ModelResponseIterator, +) + + +def _make_logging_obj(**kwargs): + """Create a minimal mock logging object for ModelResponseIterator.""" + logging_obj = MagicMock() + logging_obj.optional_params = kwargs.get("optional_params", {}) + return logging_obj + + +def test_streaming_tool_call_finish_reason_is_tool_calls(): + """ + When Gemini streams tool calls across two chunks: + - Chunk 1: has tool call parts, no finishReason + - Chunk 2: has finishReason="STOP", no content + + The final chunk must have finish_reason="tool_calls" (not "stop"). + """ + logging_obj = _make_logging_obj() + iterator = ModelResponseIterator( + streaming_response=iter([]), + sync_stream=True, + logging_obj=logging_obj, + ) + + # Chunk 1: tool call with no finishReason + chunk_with_tool_calls = { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "get_current_weather", + "args": {"location": "Boston, MA"}, + } + } + ], + "role": "model", + }, + "index": 0, + } + ], + } + + # Chunk 2: finishReason="STOP" with no content + chunk_with_finish_reason = { + "candidates": [ + { + "finishReason": "STOP", + "index": 0, + } + ], + "usageMetadata": { + "promptTokenCount": 50, + "candidatesTokenCount": 20, + "totalTokenCount": 70, + }, + } + + # Process chunk 1 + response1 = iterator.chunk_parser(chunk_with_tool_calls) + assert response1 is not None + assert len(response1.choices) == 1 + assert response1.choices[0].delta.tool_calls is not None + assert response1.choices[0].finish_reason == "tool_calls" + assert iterator.has_seen_tool_calls is True + + # Process chunk 2 (final chunk) + response2 = iterator.chunk_parser(chunk_with_finish_reason) + assert response2 is not None + assert len(response2.choices) == 1 + assert response2.choices[0].finish_reason == "tool_calls" + + +def test_streaming_no_tool_calls_finish_reason_is_stop(): + """ + When Gemini streams a regular text response (no tool calls), + the final chunk with finishReason="STOP" should map to "stop". + """ + logging_obj = _make_logging_obj() + iterator = ModelResponseIterator( + streaming_response=iter([]), + sync_stream=True, + logging_obj=logging_obj, + ) + + # Chunk 1: text content, no finishReason + chunk_with_text = { + "candidates": [ + { + "content": { + "parts": [{"text": "Hello! How can I help?"}], + "role": "model", + }, + "index": 0, + } + ], + } + + # Chunk 2: finishReason="STOP" with no content + chunk_with_finish_reason = { + "candidates": [ + { + "finishReason": "STOP", + "index": 0, + } + ], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 8, + "totalTokenCount": 18, + }, + } + + # Process chunk 1 + response1 = iterator.chunk_parser(chunk_with_text) + assert response1 is not None + assert len(response1.choices) == 1 + assert iterator.has_seen_tool_calls is False + + # Process chunk 2 + response2 = iterator.chunk_parser(chunk_with_finish_reason) + assert response2 is not None + assert len(response2.choices) == 1 + assert response2.choices[0].finish_reason == "stop" + + +def test_streaming_multiple_tool_calls_finish_reason(): + """ + When Gemini streams multiple tool calls across chunks, + the final finish_reason must still be "tool_calls". + """ + logging_obj = _make_logging_obj() + iterator = ModelResponseIterator( + streaming_response=iter([]), + sync_stream=True, + logging_obj=logging_obj, + ) + + # Chunk 1: first tool call + chunk_tool_1 = { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "get_weather", + "args": {"location": "NYC"}, + } + }, + { + "functionCall": { + "name": "get_time", + "args": {"timezone": "EST"}, + } + }, + ], + "role": "model", + }, + "index": 0, + } + ], + } + + # Chunk 2: finishReason="STOP" with no content + chunk_finish = { + "candidates": [ + { + "finishReason": "STOP", + "index": 0, + } + ], + "usageMetadata": { + "promptTokenCount": 50, + "candidatesTokenCount": 30, + "totalTokenCount": 80, + }, + } + + response1 = iterator.chunk_parser(chunk_tool_1) + assert response1 is not None + assert iterator.has_seen_tool_calls is True + + response2 = iterator.chunk_parser(chunk_finish) + assert response2 is not None + assert len(response2.choices) == 1 + assert response2.choices[0].finish_reason == "tool_calls" + + +def test_streaming_content_filter_finish_reason_preserved(): + """ + When Gemini returns finishReason due to content filtering (not STOP), + and no tool calls were seen, the content_filter reason should be preserved. + """ + logging_obj = _make_logging_obj() + iterator = ModelResponseIterator( + streaming_response=iter([]), + sync_stream=True, + logging_obj=logging_obj, + ) + + # Chunk with finishReason="SAFETY" and no content + chunk_safety = { + "candidates": [ + { + "finishReason": "SAFETY", + "index": 0, + } + ], + } + + response = iterator.chunk_parser(chunk_safety) + assert response is not None + assert len(response.choices) == 1 + assert response.choices[0].finish_reason == "content_filter" From 34d09a314ca5a60d7cd64a7f1e531213c01c5d90 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Mon, 23 Feb 2026 14:47:07 -0800 Subject: [PATCH 04/97] virtual-keys-team-table --- .../src/components/team/TeamInfo.test.tsx | 59 +- .../src/components/team/TeamInfo.tsx | 12 + .../team/TeamVirtualKeysTable.test.tsx | 192 +++++ .../components/team/TeamVirtualKeysTable.tsx | 757 ++++++++++++++++++ .../team/tabVisibilityUtils.test.ts | 19 +- .../src/components/team/tabVisibilityUtils.ts | 6 +- 6 files changed, 1040 insertions(+), 5 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx create mode 100644 ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index cc0483aafd6..75fe76902bd 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -100,12 +100,28 @@ vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({ }), })); +vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ + useKeys: vi.fn().mockReturnValue({ + data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + }), +})); + +vi.mock("../key_team_helpers/filter_helpers", () => ({ + fetchAllKeyAliases: vi.fn().mockResolvedValue([]), + fetchAllOrganizations: vi.fn().mockResolvedValue([]), +})); + import { useAllProxyModels } from "@/app/(dashboard)/hooks/models/useModels"; +import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; const mockUseAllProxyModels = vi.mocked(useAllProxyModels); +const mockUseKeys = vi.mocked(useKeys); const mockUseTeam = vi.mocked(useTeam); const mockUseOrganization = vi.mocked(useOrganization); const mockUseCurrentUser = vi.mocked(useCurrentUser); @@ -180,6 +196,12 @@ describe("TeamInfoView", () => { data: { models: [] }, isLoading: false, } as any); + mockUseKeys.mockReturnValue({ + data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] }); vi.mocked(networking.getPoliciesList).mockResolvedValue({ policies: [] }); @@ -558,7 +580,42 @@ describe("TeamInfoView", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Virtual Keys")).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Virtual Keys" })).toBeInTheDocument(); + }); + }); + + it("should show Virtual Keys tab when user cannot edit team", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("tab", { name: "Virtual Keys" })).toBeInTheDocument(); + }); + }); + + it("should display X Members in Virtual Keys tab when navigated to", async () => { + const user = userEvent.setup(); + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + mockUseKeys.mockReturnValue({ + data: { keys: [], total_count: 5, current_page: 1, total_pages: 1 }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const virtualKeysTab = screen.getByRole("tab", { name: "Virtual Keys" }); + await user.click(virtualKeysTab); + + await waitFor(() => { + expect(screen.getByText("5 Members")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index ea7a9a1c460..62208b4186d 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -48,6 +48,7 @@ import { TEAM_INFO_TAB_LABELS, } from "./tabVisibilityUtils"; import TeamMembersComponent from "./TeamMemberTab"; +import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable"; export interface TeamMembership { user_id: string; @@ -726,6 +727,17 @@ const TeamInfoView: React.FC = ({ ), }, + { + key: TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS, + label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS], + children: ( + + ), + }, { key: TEAM_INFO_TAB_KEYS.MEMBERS, label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBERS], diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx new file mode 100644 index 00000000000..e4b7b00fb58 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx @@ -0,0 +1,192 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi, MockedFunction } from "vitest"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable"; +import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { KeyResponse } from "../key_team_helpers/key_list"; +import { Organization } from "../networking"; + +vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ + useKeys: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: vi.fn(), +})); + +vi.mock("../key_team_helpers/filter_helpers", () => ({ + fetchAllKeyAliases: vi.fn().mockResolvedValue([]), + fetchAllOrganizations: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../key_team_helpers/fetch_available_models_team_key", () => ({ + getModelDisplayName: vi.fn((model: string) => model), +})); + +vi.mock("../templates/key_info_view", () => ({ + default: vi.fn(({ onClose }: { onClose: () => void }) => ( +
+ Key Info View + +
+ )), +})); + +const mockUseKeys = useKeys as MockedFunction; +const mockUseAuthorized = useAuthorized as MockedFunction; + +const createMockKey = (overrides: Partial = {}): KeyResponse => + ({ + token: "sk-test123", + token_id: "key-1", + key_alias: "alice_key_team1", + key_name: "sk-...abc", + user_id: "user-1", + organization_id: null, + user: { user_id: "user-1", user_email: "alice@example.com" }, + created_at: "2024-01-01T00:00:00Z", + team_id: "team-1", + spend: 0, + max_budget: 100, + models: ["gpt-4"], + ...overrides, + } as KeyResponse); + +const mockOrganization: Organization = { + organization_id: "org-123", + organization_alias: "Test Org", + budget_id: "budget-1", + metadata: {}, + models: [], + spend: 0, + model_spend: {}, + created_at: "", + created_by: "", + updated_at: "", + updated_by: "", + litellm_budget_table: {}, + teams: [], + users: [], + members: [], +}; + +describe("TeamVirtualKeysTable", () => { + const defaultProps = { + teamId: "team-1", + teamAlias: "team1", + organization: null as Organization | null, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token" } as any); + mockUseKeys.mockReturnValue({ + data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + }); + + it("should render successfully", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("0 Members")).toBeInTheDocument(); + }); + }); + + it("should display X Members instead of Showing X of Y results", async () => { + mockUseKeys.mockReturnValue({ + data: { + keys: [createMockKey(), createMockKey({ token: "sk-2", token_id: "key-2" })], + total_count: 2, + current_page: 1, + total_pages: 1, + } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("2 Members")).toBeInTheDocument(); + }); + expect(screen.queryByText(/Showing.*results/)).not.toBeInTheDocument(); + }); + + it("should display 1 Member when singular", async () => { + mockUseKeys.mockReturnValue({ + data: { + keys: [createMockKey()], + total_count: 1, + current_page: 1, + total_pages: 1, + } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("1 Member")).toBeInTheDocument(); + }); + }); + + it("should call useKeys with expand user to fetch user email", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenCalledWith( + 1, + 50, + expect.objectContaining({ + teamID: "team-1", + expand: "user", + }) + ); + }); + }); + + it("should enrich keys with organization_id when organization is provided", async () => { + const keyWithoutOrg = createMockKey({ organization_id: null }); + mockUseKeys.mockReturnValue({ + data: { + keys: [keyWithoutOrg], + total_count: 1, + current_page: 1, + total_pages: 1, + } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders( + + ); + + await waitFor(() => { + expect(screen.getByText("1 Member")).toBeInTheDocument(); + }); + // Key with org_id should display in table - org-123 from organization + await waitFor(() => { + expect(screen.getByText("org-123")).toBeInTheDocument(); + }); + }); + + it("should show table with Key ID column header", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("0 Members")).toBeInTheDocument(); + }); + expect(screen.getByText("Key ID")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx new file mode 100644 index 00000000000..ce998d18677 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -0,0 +1,757 @@ +// TO-DO: Standardize tables eventually + +"use client"; +import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + PaginationState, + SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { + Badge, + Button, + Icon, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, + Text, +} from "@tremor/react"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { Popover, Skeleton, Tooltip } from "antd"; +import React, { useEffect, useMemo, useState } from "react"; +import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; +import { KeyResponse, Team } from "../key_team_helpers/key_list"; +import FilterComponent, { FilterOption } from "../molecules/filter"; +import { Organization } from "../networking"; +import KeyInfoView from "../templates/key_info_view"; +import { useQuery } from "@tanstack/react-query"; +import { fetchAllKeyAliases, fetchAllOrganizations } from "../key_team_helpers/filter_helpers"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +interface TeamVirtualKeysTableProps { + teamId: string; + teamAlias?: string; + organization: Organization | null; +} + +/** + * TeamVirtualKeysTable – variant of VirtualKeysTable scoped to a single team. + * Displays all virtual keys belonging to the team with same format and styling. + */ +export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVirtualKeysTableProps) { + const { accessToken } = useAuthorized(); + const [selectedKey, setSelectedKey] = useState(null); + const [sorting, setSorting] = useState([ + { id: "created_at", desc: true }, + ]); + const [tablePagination, setTablePagination] = useState({ + pageIndex: 0, + pageSize: 50, + }); + const [filters, setFilters] = useState>({ + "Organization ID": "", + "Key Alias": "", + "User ID": "", + "Sort By": "created_at", + "Sort Order": "desc", + }); + + const sortBy = sorting.length > 0 ? sorting[0].id : "created_at"; + const sortOrder = sorting.length > 0 ? (sorting[0].desc ? "desc" : "asc") : "desc"; + + const { + data: keys, + isPending: isLoading, + isFetching, + refetch, + } = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize, { + teamID: teamId, + sortBy: sortBy || undefined, + sortOrder: sortOrder || undefined, + organizationID: filters["Organization ID"] || undefined, + selectedKeyAlias: filters["Key Alias"] || undefined, + userID: filters["User ID"] || undefined, + expand: "user", + }); + + const totalCount = keys?.total_count || 0; + const displayKeys = useMemo(() => { + const kList = keys?.keys || []; + const orgId = organization?.organization_id; + if (!orgId) return kList; + return kList.map((k: KeyResponse) => ({ + ...k, + organization_id: k.organization_id || orgId, + })); + }, [keys?.keys, organization?.organization_id]); + const [expandedAccordions, setExpandedAccordions] = useState>({}); + + const currentTeam: Team = useMemo( + () => ({ + team_id: teamId, + team_alias: teamAlias || teamId, + models: [], + max_budget: null, + budget_duration: null, + tpm_limit: null, + rpm_limit: null, + organization_id: organization?.organization_id || "", + created_at: "", + keys: [], + members_with_roles: [], + spend: 0, + }), + [teamId, teamAlias, organization], + ); + + const allKeyAliasesQuery = useQuery({ + queryKey: ["allKeyAliases"], + queryFn: async () => fetchAllKeyAliases(accessToken), + enabled: !!accessToken, + }); + const allKeyAliases = allKeyAliasesQuery.data || []; + + const allOrganizationsQuery = useQuery({ + queryKey: ["allOrganizations"], + queryFn: async () => fetchAllOrganizations(accessToken), + enabled: !!accessToken, + }); + const allOrganizations = allOrganizationsQuery.data || []; + + useEffect(() => { + if (refetch) { + const handleStorageChange = () => refetch(); + window.addEventListener("storage", handleStorageChange); + return () => window.removeEventListener("storage", handleStorageChange); + } + }, [refetch]); + + const handleFilterChange = (newFilters: Record, skipDebounce = false) => { + setFilters((prev) => ({ + ...prev, + "Organization ID": newFilters["Organization ID"] ?? prev["Organization ID"], + "Key Alias": newFilters["Key Alias"] ?? prev["Key Alias"], + "User ID": newFilters["User ID"] ?? prev["User ID"], + "Sort By": newFilters["Sort By"] ?? prev["Sort By"] ?? "created_at", + "Sort Order": newFilters["Sort Order"] ?? prev["Sort Order"] ?? "desc", + })); + if (!skipDebounce) { + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + } + }; + + const handleFilterReset = () => { + setFilters({ + "Organization ID": "", + "Key Alias": "", + "User ID": "", + "Sort By": "created_at", + "Sort Order": "desc", + }); + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + }; + + const filterOptions: FilterOption[] = useMemo( + () => [ + { + name: "Organization ID", + label: "Organization ID", + isSearchable: true, + searchFn: async (searchText: string) => { + if (!allOrganizations.length) return []; + const filtered = allOrganizations.filter( + (org) => org.organization_id?.toLowerCase().includes(searchText.toLowerCase()) ?? false, + ); + return filtered + .filter((org) => org.organization_id != null) + .map((org) => ({ + label: `${org.organization_id || "Unknown"} (${org.organization_id})`, + value: org.organization_id as string, + })); + }, + }, + { + name: "Key Alias", + label: "Key Alias", + isSearchable: true, + searchFn: async (searchText: string) => { + const filtered = allKeyAliases.filter((alias) => + alias.toLowerCase().includes(searchText.toLowerCase()), + ); + return filtered.map((alias) => ({ label: alias, value: alias })); + }, + }, + { name: "User ID", label: "User ID", isSearchable: false }, + ], + [allOrganizations, allKeyAliases], + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + id: "token", + accessorKey: "token", + header: "Key ID", + size: 100, + enableSorting: true, + cell: (info) => { + const value = info.getValue() as string; + const width = info.cell.column.getSize(); + return ( + + + + ); + }, + }, + { + id: "key_alias", + accessorKey: "key_alias", + header: "Key Alias", + size: 150, + enableSorting: true, + cell: (info) => { + const value = info.getValue() as string; + const width = info.cell.column.getSize(); + return ( + + + {value ?? "-"} + + + ); + }, + }, + { + id: "key_name", + accessorKey: "key_name", + header: "Secret Key", + size: 120, + enableSorting: false, + cell: (info) => {info.getValue() as string}, + }, + { + id: "organization_id", + accessorKey: "organization_id", + header: "Organization ID", + size: 140, + enableSorting: false, + cell: (info) => (info.getValue() ? info.renderValue() : "-"), + }, + { + id: "user_email", + accessorKey: "user", + header: "User Email", + size: 160, + enableSorting: false, + cell: (info) => { + const user = info.getValue() as { user_email?: string } | undefined; + const value = user?.user_email; + const width = info.cell.column.getSize(); + return ( + + + {value ?? "-"} + + + ); + }, + }, + { + id: "user_id", + accessorKey: "user_id", + header: "User ID", + size: 70, + enableSorting: false, + cell: (info) => { + const userId = info.getValue() as string | null; + const displayValue = userId === "default_user_id" ? "Default Proxy Admin" : userId; + const width = info.cell.column.getSize(); + return ( + + + {displayValue ?? "-"} + + + ); + }, + }, + { + id: "created_at", + accessorKey: "created_at", + header: "Created At", + size: 120, + enableSorting: true, + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleDateString() : "-"; + }, + }, + { + id: "created_by", + accessorKey: "created_by", + header: "Created By", + size: 70, + enableSorting: false, + cell: (info) => { + const value = info.getValue() as string | null; + const displayValue = value === "default_user_id" ? "Default Proxy Admin" : value; + const width = info.cell.column.getSize(); + return ( + + + {displayValue ?? "-"} + + + ); + }, + }, + { + id: "updated_at", + accessorKey: "updated_at", + header: "Updated At", + size: 120, + enableSorting: true, + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleDateString() : "Never"; + }, + }, + { + id: "last_active", + accessorKey: "last_active", + header: () => ( + + Last Active + + + + + ), + size: 130, + enableSorting: false, + cell: (info) => { + const value = info.getValue(); + if (!value) return "Unknown"; + const date = new Date(value as string); + return ( + + {date.toLocaleDateString()} + + ); + }, + }, + { + id: "expires", + accessorKey: "expires", + header: "Expires", + size: 120, + enableSorting: false, + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleDateString() : "Never"; + }, + }, + { + id: "spend", + accessorKey: "spend", + header: "Spend (USD)", + size: 100, + enableSorting: true, + cell: (info) => formatNumberWithCommas(info.getValue() as number, 4), + }, + { + id: "max_budget", + accessorKey: "max_budget", + header: "Budget (USD)", + size: 110, + enableSorting: true, + cell: (info) => { + const maxBudget = info.getValue() as number | null; + if (maxBudget === null) return "Unlimited"; + return `$${formatNumberWithCommas(maxBudget)}`; + }, + }, + { + id: "budget_reset_at", + accessorKey: "budget_reset_at", + header: "Budget Reset", + size: 130, + enableSorting: false, + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleString() : "Never"; + }, + }, + { + id: "models", + accessorKey: "models", + header: "Models", + size: 200, + enableSorting: false, + cell: (info) => { + const models = info.getValue() as string[]; + return ( +
+ {Array.isArray(models) ? ( +
+ {models.length === 0 ? ( + + All Proxy Models + + ) : ( + <> +
+ {models.length > 3 && ( +
+ + setExpandedAccordions((prev) => ({ + ...prev, + [info.row.id]: !prev[info.row.id], + })) + } + /> +
+ )} +
+ {models.slice(0, 3).map((model, index) => + model === "all-proxy-models" ? ( + + All Proxy Models + + ) : ( + + + {model.length > 30 + ? `${getModelDisplayName(model).slice(0, 30)}...` + : getModelDisplayName(model)} + + + ), + )} + {models.length > 3 && !expandedAccordions[info.row.id] && ( + + + +{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"} + + + )} + {expandedAccordions[info.row.id] && ( +
+ {models.slice(3).map((model, index) => + model === "all-proxy-models" ? ( + + All Proxy Models + + ) : ( + + + {model.length > 30 + ? `${getModelDisplayName(model).slice(0, 30)}...` + : getModelDisplayName(model)} + + + ), + )} +
+ )} +
+
+ + )} +
+ ) : null} +
+ ); + }, + }, + { + id: "rate_limits", + header: "Rate Limits", + size: 140, + enableSorting: false, + cell: ({ row }) => { + const key = row.original; + return ( +
+
TPM: {key.tpm_limit !== null ? key.tpm_limit : "Unlimited"}
+
RPM: {key.rpm_limit !== null ? key.rpm_limit : "Unlimited"}
+
+ ); + }, + }, + ], + [expandedAccordions], + ); + + const table = useReactTable({ + data: displayKeys, + columns, + columnResizeMode: "onChange", + columnResizeDirection: "ltr", + state: { sorting, pagination: tablePagination }, + onSortingChange: (updaterOrValue) => { + const newSorting = + typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; + setSorting(newSorting); + if (newSorting?.length > 0) { + const sortState = newSorting[0]; + handleFilterChange( + { + ...filters, + "Sort By": sortState.id, + "Sort Order": sortState.desc ? "desc" : "asc", + }, + true, + ); + } + }, + onPaginationChange: setTablePagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + enableSorting: true, + manualSorting: false, + manualPagination: true, + pageCount: Math.ceil(totalCount / tablePagination.pageSize), + }); + + const { pageIndex, pageSize } = table.getState().pagination; + return ( +
+ {selectedKey ? ( + setSelectedKey(null)} + keyData={selectedKey} + teams={[currentTeam]} + onDelete={refetch} + /> + ) : ( +
+
+ +
+ +
+ {isLoading || isFetching ? ( + + ) : ( + + {totalCount} Member{totalCount !== 1 ? "s" : ""} + + )} + +
+ {isLoading || isFetching ? ( + + ) : ( + + Page {pageIndex + 1} of {table.getPageCount()} + + )} + + {isLoading || isFetching ? ( + + ) : ( + + )} + + {isLoading || isFetching ? ( + + ) : ( + + )} +
+
+
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + { + const resizer = document.querySelector( + `[data-header-id="${header.id}"] .resizer`, + ); + if (resizer) (resizer as HTMLElement).style.opacity = "0.5"; + }} + onMouseLeave={() => { + const resizer = document.querySelector( + `[data-header-id="${header.id}"] .resizer`, + ); + if (resizer && !header.column.getIsResizing()) + (resizer as HTMLElement).style.opacity = "0"; + }} + onClick={ + header.column.getCanSort() + ? header.column.getToggleSortingHandler() + : undefined + } + > +
+
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+ {header.id !== "actions" && header.column.getCanSort() && ( +
+ {header.column.getIsSorted() ? ( + { + asc: , + desc: , + }[header.column.getIsSorted() as string] + ) : ( + + )} +
+ )} +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={`resizer ${table.options.columnResizeDirection} ${ + header.column.getIsResizing() ? "isResizing" : "" + }`} + style={{ + position: "absolute", + right: 0, + top: 0, + height: "100%", + width: "5px", + background: header.column.getIsResizing() ? "#3b82f6" : "transparent", + cursor: "col-resize", + userSelect: "none", + touchAction: "none", + opacity: header.column.getIsResizing() ? 1 : 0, + }} + /> +
+ + ))} + + ))} + + + {isLoading || isFetching ? ( + + +
+

Loading keys...

+
+
+
+ ) : displayKeys.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + 3 + ? "px-0" + : "" + }`} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + +
+

No keys found

+
+
+
+ )} +
+
+
+
+
+
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts index 5a09b4fa36c..8b9a0402c9a 100644 --- a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts +++ b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts @@ -11,6 +11,7 @@ describe("team_info_tabs", () => { describe("TEAM_INFO_TAB_LABELS", () => { it("should have label for every tab key", () => { expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.OVERVIEW]).toBe("Overview"); + expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]).toBe("Virtual Keys"); expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBERS]).toBe("Members"); expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS]).toBe("Member Permissions"); expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.SETTINGS]).toBe("Settings"); @@ -18,15 +19,16 @@ describe("team_info_tabs", () => { }); describe("getTeamInfoVisibleTabs", () => { - it("returns only overview when user cannot edit team", () => { + it("returns overview and virtual keys when user cannot edit team", () => { const tabs = getTeamInfoVisibleTabs(false); - expect(tabs).toEqual([TEAM_INFO_TAB_KEYS.OVERVIEW]); + expect(tabs).toEqual([TEAM_INFO_TAB_KEYS.OVERVIEW, TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]); }); it("returns all tabs when user can edit team", () => { const tabs = getTeamInfoVisibleTabs(true); expect(tabs).toEqual([ TEAM_INFO_TAB_KEYS.OVERVIEW, + TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS, TEAM_INFO_TAB_KEYS.MEMBERS, TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS, TEAM_INFO_TAB_KEYS.SETTINGS, @@ -55,6 +57,19 @@ describe("team_info_tabs", () => { expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.OVERVIEW, true)).toBe(true); }); + it("always returns true for virtual keys tab regardless of edit permission", () => { + expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS, false)).toBe(true); + expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS, true)).toBe(true); + }); + + it("returns false for member permissions tab when user cannot edit", () => { + expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS, false)).toBe(false); + }); + + it("returns true for member permissions tab when user can edit", () => { + expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS, true)).toBe(true); + }); + it("returns false for members tab when user cannot edit", () => { expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.MEMBERS, false)).toBe(false); }); diff --git a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts index d77230ea09b..dd0e54baf36 100644 --- a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts +++ b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts @@ -5,6 +5,7 @@ export const TEAM_INFO_TAB_KEYS = { OVERVIEW: "overview", + VIRTUAL_KEYS: "virtual-keys", MEMBERS: "members", MEMBER_PERMISSIONS: "member-permissions", SETTINGS: "settings", @@ -12,6 +13,7 @@ export const TEAM_INFO_TAB_KEYS = { export const TEAM_INFO_TAB_LABELS: Record = { [TEAM_INFO_TAB_KEYS.OVERVIEW]: "Overview", + [TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]: "Virtual Keys", [TEAM_INFO_TAB_KEYS.MEMBERS]: "Members", [TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS]: "Member Permissions", [TEAM_INFO_TAB_KEYS.SETTINGS]: "Settings", @@ -19,11 +21,11 @@ export const TEAM_INFO_TAB_LABELS: Record = { /** * Returns the list of tab keys that should be visible based on permissions. - * - Overview: always visible + * - Overview, Virtual Keys: always visible * - Members, Member Permissions, Settings: only when canEditTeam is true */ export function getTeamInfoVisibleTabs(canEditTeam: boolean): readonly string[] { - const baseTabs = [TEAM_INFO_TAB_KEYS.OVERVIEW]; + const baseTabs = [TEAM_INFO_TAB_KEYS.OVERVIEW, TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]; if (canEditTeam) { return [ ...baseTabs, From 6d98622923f95c6e047c910637cdf92b3f055f77 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Mon, 23 Feb 2026 15:21:49 -0800 Subject: [PATCH 05/97] filters working properly for team virtual keys --- .../key_team_helpers/filter_helpers.ts | 77 +++++++- .../src/components/team/TeamInfo.test.tsx | 71 +++++++- .../team/TeamVirtualKeysTable.test.tsx | 170 +++++++++++++++++- .../components/team/TeamVirtualKeysTable.tsx | 133 ++++++++------ 4 files changed, 390 insertions(+), 61 deletions(-) diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts index fa12a75aacd..08eccc4ed2c 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts +++ b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts @@ -1,7 +1,82 @@ -import { teamListCall, organizationListCall, keyAliasesCall } from "../networking" +import { teamListCall, organizationListCall, keyAliasesCall, keyListCall } from "../networking"; import { Team } from "./key_list"; import { Organization } from "../networking"; +export interface TeamFilterOptions { + keyAliases: string[]; + organizationIds: string[]; + userIds: Array<{ id: string; email: string }>; +} + +/** + * Fetches filter options (key aliases, org IDs, user IDs) scoped to a team's keys. + * Used by TeamVirtualKeysTable to show only relevant filter options. + */ +export const fetchTeamFilterOptions = async ( + accessToken: string | null, + teamId: string, +): Promise => { + if (!accessToken || !teamId) { + return { keyAliases: [], organizationIds: [], userIds: [] }; + } + + try { + const keyAliases = new Set(); + const organizationIds = new Set(); + const userMap = new Map(); // user_id -> user_email + + let page = 1; + let totalPages = 1; + + do { + const response = await keyListCall( + accessToken, + null, + teamId, + null, + null, + null, + page, + 100, + null, + null, + "user", + null, + ); + + const keys = response?.keys || []; + totalPages = response?.total_pages ?? 1; + + for (const key of keys) { + const alias = key?.key_alias; + if (alias && typeof alias === "string") { + keyAliases.add(alias.trim()); + } + const orgId = key?.organization_id; + if (orgId && typeof orgId === "string") { + organizationIds.add(orgId.trim()); + } + const userId = key?.user_id; + if (userId && typeof userId === "string") { + const email = key?.user?.user_email || userId; + userMap.set(userId, email); + } + } + + page++; + } while (page <= totalPages); + + return { + keyAliases: Array.from(keyAliases).sort(), + organizationIds: Array.from(organizationIds).sort(), + userIds: Array.from(userMap.entries()).map(([id, email]) => ({ id, email })), + }; + } catch (error) { + console.error("Error fetching team filter options:", error); + return { keyAliases: [], organizationIds: [], userIds: [] }; + } +}; + /** * Fetches all key aliases via the dedicated /key/aliases endpoint * @param accessToken The access token for API authentication diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index 75fe76902bd..317db7b37fd 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -110,6 +110,11 @@ vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ })); vi.mock("../key_team_helpers/filter_helpers", () => ({ + fetchTeamFilterOptions: vi.fn().mockResolvedValue({ + keyAliases: [], + organizationIds: [], + userIds: [], + }), fetchAllKeyAliases: vi.fn().mockResolvedValue([]), fetchAllOrganizations: vi.fn().mockResolvedValue([]), })); @@ -597,8 +602,22 @@ describe("TeamInfoView", () => { it("should display X Members in Virtual Keys tab when navigated to", async () => { const user = userEvent.setup(); vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + const fiveKeys = Array.from({ length: 5 }, (_, i) => ({ + token: `sk-${i}`, + token_id: `key-${i}`, + key_alias: `key_${i}`, + key_name: `sk-...${i}`, + user_id: `user-${i}`, + organization_id: null, + user: { user_id: `user-${i}`, user_email: `user${i}@test.com` }, + created_at: "2024-01-01T00:00:00Z", + team_id: "123", + spend: 0, + max_budget: 100, + models: ["gpt-4"], + })); mockUseKeys.mockReturnValue({ - data: { keys: [], total_count: 5, current_page: 1, total_pages: 1 }, + data: { keys: fiveKeys, total_count: 5, current_page: 1, total_pages: 1 }, isPending: false, isFetching: false, refetch: vi.fn(), @@ -619,6 +638,56 @@ describe("TeamInfoView", () => { }); }); + it("should show Filters and pagination controls in Virtual Keys tab", async () => { + const user = userEvent.setup(); + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + mockUseKeys.mockReturnValue({ + data: { + keys: [ + { + token: "sk-1", + token_id: "key-1", + key_alias: "key1", + key_name: "sk-...1", + user_id: "user-1", + organization_id: null, + user: { user_id: "user-1", user_email: "user1@test.com" }, + created_at: "2024-01-01T00:00:00Z", + team_id: "123", + spend: 0, + max_budget: 100, + models: ["gpt-4"], + }, + ], + total_count: 1, + current_page: 1, + total_pages: 1, + }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const virtualKeysTab = screen.getByRole("tab", { name: "Virtual Keys" }); + await user.click(virtualKeysTab); + + await waitFor(() => { + expect(screen.getByText("1 Member")).toBeInTheDocument(); + }); + expect(screen.getByRole("button", { name: "Filters" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument(); + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument(); + }); + it("should display object permissions when present", async () => { vi.mocked(networking.teamInfoCall).mockResolvedValue( createMockTeamData({ diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx index e4b7b00fb58..41df5611e07 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi, MockedFunction } from "vitest"; import { renderWithProviders } from "../../../tests/test-utils"; import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable"; import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { fetchTeamFilterOptions } from "../key_team_helpers/filter_helpers"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { KeyResponse } from "../key_team_helpers/key_list"; import { Organization } from "../networking"; @@ -17,8 +18,11 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ })); vi.mock("../key_team_helpers/filter_helpers", () => ({ - fetchAllKeyAliases: vi.fn().mockResolvedValue([]), - fetchAllOrganizations: vi.fn().mockResolvedValue([]), + fetchTeamFilterOptions: vi.fn().mockResolvedValue({ + keyAliases: [], + organizationIds: [], + userIds: [], + }), })); vi.mock("../key_team_helpers/fetch_available_models_team_key", () => ({ @@ -139,7 +143,7 @@ describe("TeamVirtualKeysTable", () => { }); }); - it("should call useKeys with expand user to fetch user email", async () => { + it("should call useKeys with page, pageSize, and expand user for server-side pagination", async () => { renderWithProviders(); await waitFor(() => { @@ -189,4 +193,164 @@ describe("TeamVirtualKeysTable", () => { }); expect(screen.getByText("Key ID")).toBeInTheDocument(); }); + + it("should display keys in table when data is loaded", async () => { + mockUseKeys.mockReturnValue({ + data: { + keys: [ + createMockKey({ key_alias: "alice_key_team1" }), + createMockKey({ token: "sk-2", token_id: "key-2", key_alias: "bob_key_team1" }), + ], + total_count: 2, + current_page: 1, + total_pages: 1, + } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("2 Members")).toBeInTheDocument(); + }); + expect(screen.getByText("alice_key_team1")).toBeInTheDocument(); + expect(screen.getByText("bob_key_team1")).toBeInTheDocument(); + }); + + it("should show Page X of Y when multiple pages exist", async () => { + mockUseKeys.mockReturnValue({ + data: { + keys: [createMockKey()], + total_count: 100, + current_page: 1, + total_pages: 3, + } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Page 1 of 3")).toBeInTheDocument(); + }); + expect(screen.getByText("100 Members")).toBeInTheDocument(); + }); + + it("should fetch page 2 when Next is clicked", async () => { + const user = userEvent.setup(); + mockUseKeys.mockImplementation((page: number) => ({ + data: { + keys: page === 1 ? [createMockKey()] : [createMockKey({ token: "sk-page2", key_alias: "page2_key" })], + total_count: 100, + current_page: page, + total_pages: 3, + } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any)); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Page 1 of 3")).toBeInTheDocument(); + }); + + const nextButton = screen.getByRole("button", { name: "Next" }); + await user.click(nextButton); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith( + 2, + 50, + expect.objectContaining({ teamID: "team-1" }) + ); + }); + }); + + it("should show Loading keys when isPending", async () => { + mockUseKeys.mockReturnValue({ + data: undefined, + isPending: true, + isFetching: true, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Loading keys...")).toBeInTheDocument(); + }); + }); + + it("should show No keys found when keys array is empty", async () => { + mockUseKeys.mockReturnValue({ + data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("0 Members")).toBeInTheDocument(); + }); + expect(screen.getByText("No keys found")).toBeInTheDocument(); + }); + + it("should fetch team-scoped filter options for Key Alias, Organization ID, and User ID", async () => { + const mockFetchTeamFilterOptions = vi.mocked(fetchTeamFilterOptions); + mockFetchTeamFilterOptions.mockResolvedValue({ + keyAliases: ["alice_key_team1", "charlie_key_team1"], + organizationIds: ["org-123"], + userIds: [ + { id: "user-1", email: "alice@example.com" }, + { id: "user-2", email: "charlie@example.com" }, + ], + }); + + // Use unique teamId to avoid cache hit from previous tests (refetchOnMount: false) + renderWithProviders( + + ); + + await waitFor(() => { + expect(mockFetchTeamFilterOptions).toHaveBeenCalledWith( + "test-token", + "team-filter-options-test" + ); + }); + }); + + it("should open Key Info View when key is clicked", async () => { + mockUseKeys.mockReturnValue({ + data: { + keys: [createMockKey({ token: "sk-click-me", key_alias: "clickable_key" })], + total_count: 1, + current_page: 1, + total_pages: 1, + } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("clickable_key")).toBeInTheDocument(); + }); + + const keyButton = screen.getByRole("button", { name: /sk-click-me|clickable_key/ }); + await userEvent.click(keyButton); + + await waitFor(() => { + expect(screen.getByText("Key Info View")).toBeInTheDocument(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index ce998d18677..ddbffa118b1 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -8,7 +8,6 @@ import { ColumnDef, flexRender, getCoreRowModel, - getPaginationRowModel, getSortedRowModel, PaginationState, SortingState, @@ -28,14 +27,14 @@ import { } from "@tremor/react"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Popover, Skeleton, Tooltip } from "antd"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; import FilterComponent, { FilterOption } from "../molecules/filter"; import { Organization } from "../networking"; import KeyInfoView from "../templates/key_info_view"; import { useQuery } from "@tanstack/react-query"; -import { fetchAllKeyAliases, fetchAllOrganizations } from "../key_team_helpers/filter_helpers"; +import { fetchTeamFilterOptions } from "../key_team_helpers/filter_helpers"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; interface TeamVirtualKeysTableProps { @@ -69,22 +68,24 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi const sortBy = sorting.length > 0 ? sorting[0].id : "created_at"; const sortOrder = sorting.length > 0 ? (sorting[0].desc ? "desc" : "asc") : "desc"; + const pageIndex = tablePagination.pageIndex; + const pageSize = tablePagination.pageSize; + const { data: keys, isPending: isLoading, isFetching, refetch, - } = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize, { + } = useKeys(pageIndex + 1, pageSize, { teamID: teamId, + organizationID: filters["Organization ID"]?.trim() || undefined, + selectedKeyAlias: filters["Key Alias"]?.trim() || undefined, + userID: filters["User ID"]?.trim() || undefined, sortBy: sortBy || undefined, sortOrder: sortOrder || undefined, - organizationID: filters["Organization ID"] || undefined, - selectedKeyAlias: filters["Key Alias"] || undefined, - userID: filters["User ID"] || undefined, expand: "user", }); - const totalCount = keys?.total_count || 0; const displayKeys = useMemo(() => { const kList = keys?.keys || []; const orgId = organization?.organization_id; @@ -94,6 +95,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi organization_id: k.organization_id || orgId, })); }, [keys?.keys, organization?.organization_id]); + + const totalCount = keys?.total_count ?? 0; + const pageCount = keys?.total_pages ?? 0; const [expandedAccordions, setExpandedAccordions] = useState>({}); const currentTeam: Team = useMemo( @@ -114,29 +118,27 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi [teamId, teamAlias, organization], ); - const allKeyAliasesQuery = useQuery({ - queryKey: ["allKeyAliases"], - queryFn: async () => fetchAllKeyAliases(accessToken), - enabled: !!accessToken, + const teamFilterOptionsQuery = useQuery({ + queryKey: ["teamFilterOptions", teamId], + queryFn: async () => fetchTeamFilterOptions(accessToken, teamId), + enabled: !!accessToken && !!teamId, }); - const allKeyAliases = allKeyAliasesQuery.data || []; + const teamFilterOptions = teamFilterOptionsQuery.data || { + keyAliases: [], + organizationIds: [], + userIds: [], + }; - const allOrganizationsQuery = useQuery({ - queryKey: ["allOrganizations"], - queryFn: async () => fetchAllOrganizations(accessToken), - enabled: !!accessToken, - }); - const allOrganizations = allOrganizationsQuery.data || []; - - useEffect(() => { - if (refetch) { - const handleStorageChange = () => refetch(); - window.addEventListener("storage", handleStorageChange); - return () => window.removeEventListener("storage", handleStorageChange); - } + const handleStorageChange = useCallback(() => { + refetch?.(); }, [refetch]); - const handleFilterChange = (newFilters: Record, skipDebounce = false) => { + useEffect(() => { + window.addEventListener("storage", handleStorageChange); + return () => window.removeEventListener("storage", handleStorageChange); + }, [handleStorageChange]); + + const handleFilterChange = useCallback((newFilters: Record, skipDebounce = false) => { setFilters((prev) => ({ ...prev, "Organization ID": newFilters["Organization ID"] ?? prev["Organization ID"], @@ -148,9 +150,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi if (!skipDebounce) { setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); } - }; + }, []); - const handleFilterReset = () => { + const handleFilterReset = useCallback(() => { setFilters({ "Organization ID": "", "Key Alias": "", @@ -159,7 +161,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi "Sort Order": "desc", }); setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); - }; + }, []); const filterOptions: FilterOption[] = useMemo( () => [ @@ -168,16 +170,13 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi label: "Organization ID", isSearchable: true, searchFn: async (searchText: string) => { - if (!allOrganizations.length) return []; - const filtered = allOrganizations.filter( - (org) => org.organization_id?.toLowerCase().includes(searchText.toLowerCase()) ?? false, - ); - return filtered - .filter((org) => org.organization_id != null) - .map((org) => ({ - label: `${org.organization_id || "Unknown"} (${org.organization_id})`, - value: org.organization_id as string, - })); + const { organizationIds } = teamFilterOptions; + if (!organizationIds.length) return []; + const lower = searchText.toLowerCase(); + const filtered = lower + ? organizationIds.filter((id) => id.toLowerCase().includes(lower)) + : organizationIds; + return filtered.map((id) => ({ label: id, value: id })); }, }, { @@ -185,15 +184,35 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi label: "Key Alias", isSearchable: true, searchFn: async (searchText: string) => { - const filtered = allKeyAliases.filter((alias) => - alias.toLowerCase().includes(searchText.toLowerCase()), - ); + const { keyAliases } = teamFilterOptions; + const lower = searchText.toLowerCase(); + const filtered = lower + ? keyAliases.filter((alias) => alias.toLowerCase().includes(lower)) + : keyAliases; return filtered.map((alias) => ({ label: alias, value: alias })); }, }, - { name: "User ID", label: "User ID", isSearchable: false }, + { + name: "User ID", + label: "User ID", + isSearchable: true, + searchFn: async (searchText: string) => { + const { userIds } = teamFilterOptions; + const lower = searchText.toLowerCase(); + const filtered = lower + ? userIds.filter( + (u) => + u.id.toLowerCase().includes(lower) || u.email.toLowerCase().includes(lower), + ) + : userIds; + return filtered.map((u) => ({ + label: u.email ? `${u.id} (${u.email})` : u.id, + value: u.id, + })); + }, + }, ], - [allOrganizations, allKeyAliases], + [teamFilterOptions], ); const columns: ColumnDef[] = useMemo( @@ -521,13 +540,8 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi [expandedAccordions], ); - const table = useReactTable({ - data: displayKeys, - columns, - columnResizeMode: "onChange", - columnResizeDirection: "ltr", - state: { sorting, pagination: tablePagination }, - onSortingChange: (updaterOrValue) => { + const handleSortingChange = useCallback( + (updaterOrValue: React.SetStateAction) => { const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; setSorting(newSorting); @@ -535,7 +549,6 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi const sortState = newSorting[0]; handleFilterChange( { - ...filters, "Sort By": sortState.id, "Sort Order": sortState.desc ? "desc" : "asc", }, @@ -543,17 +556,25 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi ); } }, + [sorting, handleFilterChange], + ); + + const table = useReactTable({ + data: displayKeys, + columns, + columnResizeMode: "onChange", + columnResizeDirection: "ltr", + state: { sorting, pagination: tablePagination }, + onSortingChange: handleSortingChange, onPaginationChange: setTablePagination, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), - getPaginationRowModel: getPaginationRowModel(), enableSorting: true, manualSorting: false, manualPagination: true, - pageCount: Math.ceil(totalCount / tablePagination.pageSize), + pageCount: pageCount, }); - const { pageIndex, pageSize } = table.getState().pagination; return (
{selectedKey ? ( From f44c36f9802bc46bbd4b646924aed8ddeea82f2a Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Mon, 23 Feb 2026 15:43:04 -0800 Subject: [PATCH 06/97] Fix Unbounded pagination --- .../src/components/key_team_helpers/filter_helpers.ts | 6 ++++-- .../src/components/team/TeamVirtualKeysTable.tsx | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts index 08eccc4ed2c..dd3766592bf 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts +++ b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts @@ -25,6 +25,8 @@ export const fetchTeamFilterOptions = async ( const organizationIds = new Set(); const userMap = new Map(); // user_id -> user_email + const MAX_PAGES = 1; // Cap at 20 keys on load to avoid heavy fetches + const PAGE_SIZE = 20; let page = 1; let totalPages = 1; @@ -37,7 +39,7 @@ export const fetchTeamFilterOptions = async ( null, null, page, - 100, + PAGE_SIZE, null, null, "user", @@ -64,7 +66,7 @@ export const fetchTeamFilterOptions = async ( } page++; - } while (page <= totalPages); + } while (page <= totalPages && page <= MAX_PAGES); return { keyAliases: Array.from(keyAliases).sort(), diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index ddbffa118b1..13d7885d672 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -122,6 +122,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi queryKey: ["teamFilterOptions", teamId], queryFn: async () => fetchTeamFilterOptions(accessToken, teamId), enabled: !!accessToken && !!teamId, + staleTime: 30000, // 30 seconds - align with useKeys }); const teamFilterOptions = teamFilterOptionsQuery.data || { keyAliases: [], From bc99e38e55348aaab5db5fcef3adb1f9ba44faa3 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Mon, 23 Feb 2026 15:44:51 -0800 Subject: [PATCH 07/97] Fix: accessToken not included in queryKey --- .../src/components/team/TeamVirtualKeysTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index 13d7885d672..74100069779 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -119,7 +119,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi ); const teamFilterOptionsQuery = useQuery({ - queryKey: ["teamFilterOptions", teamId], + queryKey: ["teamFilterOptions", teamId, accessToken], queryFn: async () => fetchTeamFilterOptions(accessToken, teamId), enabled: !!accessToken && !!teamId, staleTime: 30000, // 30 seconds - align with useKeys From a1f5450b4001555b04ce51044004580ced8e3cb1 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Mon, 23 Feb 2026 16:01:04 -0800 Subject: [PATCH 08/97] manualSorting fix. Filtering fetching fix --- .../components/key_team_helpers/filter_helpers.ts | 14 ++++++++------ .../src/components/team/TeamVirtualKeysTable.tsx | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts index dd3766592bf..0a5590d78ce 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts +++ b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts @@ -8,9 +8,13 @@ export interface TeamFilterOptions { userIds: Array<{ id: string; email: string }>; } +const FILTER_OPTIONS_PAGE_SIZE = 100; // API max per page +const MAX_PAGES = 50; // Cap at 5000 keys to avoid unbounded fetches + /** - * Fetches filter options (key aliases, org IDs, user IDs) scoped to a team's keys. - * Used by TeamVirtualKeysTable to show only relevant filter options. + * Fetches filter options (key aliases, org IDs, user IDs) from all team keys. + * Paginates through pages to build complete dropdowns. Capped at 50 pages + * (5000 keys) to limit load for very large teams. */ export const fetchTeamFilterOptions = async ( accessToken: string | null, @@ -23,10 +27,8 @@ export const fetchTeamFilterOptions = async ( try { const keyAliases = new Set(); const organizationIds = new Set(); - const userMap = new Map(); // user_id -> user_email + const userMap = new Map(); - const MAX_PAGES = 1; // Cap at 20 keys on load to avoid heavy fetches - const PAGE_SIZE = 20; let page = 1; let totalPages = 1; @@ -39,7 +41,7 @@ export const fetchTeamFilterOptions = async ( null, null, page, - PAGE_SIZE, + FILTER_OPTIONS_PAGE_SIZE, null, null, "user", diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index 74100069779..de3061a003d 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -571,7 +571,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), enableSorting: true, - manualSorting: false, + manualSorting: true, // Server sorts via useKeys. Avoid redundant client-side sort manualPagination: true, pageCount: pageCount, }); From 18bc0d4ec94f04bc3b284003f43a90f6d15a39e4 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Fri, 27 Feb 2026 13:37:23 -0800 Subject: [PATCH 09/97] reusable-credentials --- .../handle_add_model_submit.test.tsx | 24 +++++++ .../add_model/handle_add_model_submit.tsx | 3 + .../src/components/model_info_view.test.tsx | 63 +++++++++++++++++++ .../src/components/model_info_view.tsx | 57 ++++++++++++++++- 4 files changed, 146 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx index 5cf7a18634e..c261e505684 100644 --- a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx @@ -55,4 +55,28 @@ describe("prepareModelAddRequest", () => { const [deployment] = deployments!; expect(deployment.litellmParamsObj.custom_llm_provider).toBe("petals"); }); + + it("ignores litellm_credential_name inside LiteLLM Params JSON", async () => { + const formValues = { + model_mappings: [ + { + public_name: "Public Model", + litellm_model: "litellm/public", + }, + ], + model_name: "custom-model-name", + litellm_credential_name: "selected-credential", + litellm_extra_params: JSON.stringify({ + litellm_credential_name: "from-json", + timeout: 5, + }), + }; + + const deployments = await prepareModelAddRequest({ ...formValues }, "token", null); + + expect(deployments).toHaveLength(1); + const [deployment] = deployments!; + expect(deployment.litellmParamsObj.litellm_credential_name).toBe("selected-credential"); + expect(deployment.litellmParamsObj.timeout).toBe(5); + }); }); diff --git a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx index 1d8c980c5ae..5137a302dd5 100644 --- a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx +++ b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx @@ -91,6 +91,9 @@ export const prepareModelAddRequest = async (formValues: Record, ac if (value && value != undefined) { try { litellmExtraParams = JSON.parse(value); + if ("litellm_credential_name" in litellmExtraParams) { + delete litellmExtraParams.litellm_credential_name; + } } catch (error) { NotificationManager.fromBackend("Failed to parse LiteLLM Extra Params: " + error); throw new Error("Failed to parse litellm_extra_params: " + error); diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx index 7158c452d94..b2cbbda34aa 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -23,6 +23,7 @@ vi.mock("./molecules/notifications_manager", () => ({ vi.mock("./networking", () => ({ modelInfoV1Call: vi.fn(), credentialGetCall: vi.fn(), + credentialListCall: vi.fn(), getGuardrailsList: vi.fn(), tagListCall: vi.fn(), testConnectionRequest: vi.fn(), @@ -47,6 +48,7 @@ vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ const mockNotificationsManager = vi.mocked(NotificationsManager); const mockModelInfoV1Call = vi.mocked(networking.modelInfoV1Call); const mockCredentialGetCall = vi.mocked(networking.credentialGetCall); +const mockCredentialListCall = vi.mocked(networking.credentialListCall); const mockGetGuardrailsList = vi.mocked(networking.getGuardrailsList); const mockTagListCall = vi.mocked(networking.tagListCall); const mockTestConnectionRequest = vi.mocked(networking.testConnectionRequest); @@ -63,6 +65,7 @@ describe("ModelInfoView", () => { model: "gpt-4", api_base: "https://api.openai.com/v1", custom_llm_provider: "openai", + litellm_credential_name: "selected-credential", }, model_info: { id: "123", @@ -125,6 +128,15 @@ describe("ModelInfoView", () => { credential_values: {}, credential_info: {}, }); + mockCredentialListCall.mockResolvedValue({ + credentials: [ + { + credential_name: "selected-credential", + credential_values: {}, + credential_info: {}, + }, + ], + }); mockGetGuardrailsList.mockResolvedValue({ guardrails: [{ guardrail_name: "content_filter" }, { guardrail_name: "toxicity_filter" }], @@ -489,6 +501,57 @@ describe("ModelInfoView", () => { }); }); + it("should show existing credentials field in edit mode", async () => { + const user = userEvent.setup(); + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /edit settings/i })); + + await waitFor(() => { + expect(screen.getByText("Existing Credentials")).toBeInTheDocument(); + }); + }); + + it("should keep selector credential and ignore litellm_credential_name from LiteLLM Params json", async () => { + const user = userEvent.setup(); + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /edit settings/i })); + + const litellmParamsInput = screen + .getAllByRole("textbox") + .find( + (input) => + input.tagName === "TEXTAREA" && + (input as HTMLTextAreaElement).value.includes('"custom_llm_provider"'), + ); + expect(litellmParamsInput).toBeDefined(); + if (!litellmParamsInput) { + return; + } + expect((litellmParamsInput as HTMLTextAreaElement).value).not.toContain("litellm_credential_name"); + await user.clear(litellmParamsInput); + await user.paste(`{"litellm_credential_name":"from-json","timeout":42}`); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(mockModelPatchUpdateCall).toHaveBeenCalled(); + }); + + const updatePayload = mockModelPatchUpdateCall.mock.calls[0][1]; + expect(updatePayload.litellm_params.litellm_credential_name).toBe("selected-credential"); + expect(updatePayload.litellm_params.litellm_credential_name).not.toBe("from-json"); + }); + it("should display health check model field for wildcard models", async () => { const wildcardModelData = { ...defaultModelData, diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index e2fc8caa21c..0ba86e9f243 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -30,6 +30,7 @@ import { CredentialItem, credentialCreateCall, credentialGetCall, + credentialListCall, getGuardrailsList, modelDeleteCall, modelInfoV1Call, @@ -75,6 +76,7 @@ export default function ModelInfoView({ const [isAutoRouterModalOpen, setIsAutoRouterModalOpen] = useState(false); const [guardrailsList, setGuardrailsList] = useState([]); const [tagsList, setTagsList] = useState>({}); + const [credentialsList, setCredentialsList] = useState([]); // Fetch model data using hook const { data: rawModelDataResponse, isLoading: isLoadingModel } = useModelsInfo(1, 50, undefined, modelId); @@ -191,10 +193,21 @@ export default function ModelInfoView({ } }; + const fetchCredentials = async () => { + if (!accessToken) return; + try { + const response = await credentialListCall(accessToken); + setCredentialsList(response.credentials || []); + } catch (error) { + console.error("Failed to fetch credentials:", error); + } + }; + getExistingCredential(); getModelInfo(); fetchGuardrails(); fetchTags(); + fetchCredentials(); }, [accessToken, modelId]); const handleReuseCredential = async (values: any) => { @@ -220,6 +233,7 @@ export default function ModelInfoView({ let parsedExtraParams: Record = {}; try { parsedExtraParams = values.litellm_extra_params ? JSON.parse(values.litellm_extra_params) : {}; + delete parsedExtraParams.litellm_credential_name; } catch (e) { NotificationsManager.fromBackend("Invalid JSON in LiteLLM Params"); setIsSaving(false); @@ -242,6 +256,11 @@ export default function ModelInfoView({ output_cost_per_token: values.output_cost / 1_000_000, tags: values.tags, }; + if (values.litellm_credential_name) { + updatedLitellmParams.litellm_credential_name = values.litellm_credential_name; + } else { + delete updatedLitellmParams.litellm_credential_name; + } if (values.guardrails) { updatedLitellmParams.guardrails = values.guardrails; } @@ -608,7 +627,16 @@ export default function ModelInfoView({ : [], tags: Array.isArray(localModelData.litellm_params?.tags) ? localModelData.litellm_params.tags : [], health_check_model: isWildcardModel ? localModelData.model_info?.health_check_model : null, - litellm_extra_params: JSON.stringify(localModelData.litellm_params || {}, null, 2), + litellm_credential_name: localModelData.litellm_params?.litellm_credential_name || undefined, + litellm_extra_params: JSON.stringify( + Object.fromEntries( + Object.entries(localModelData.litellm_params || {}).filter( + ([key]) => key !== "litellm_credential_name", + ), + ), + null, + 2, + ), }} layout="vertical" onValuesChange={() => setIsDirty(true)} @@ -930,6 +958,33 @@ export default function ModelInfoView({
)} +
+ Existing Credentials + {isEditing ? ( + + setBlockScope("team")} + className="align-middle" + /> + Team + + +
+ +
+ + {blockScope === "team" ? "Team" : "Key"} + + {blockScope === "team" ? ( + setBlockTeamId(id || null)} + /> + ) : ( + onChange(toolName, v)} - onClick={(e) => e.stopPropagation()} - style={{ - minWidth: 110, - fontWeight: 500, - }} - popupMatchSelectWidth={false} - options={POLICY_OPTIONS.map((o) => ({ - value: o.value, - label: ( - - - {o.label} - - ), - }))} - /> - ); -}; - -export const ToolPolicies: React.FC = ({ accessToken }) => { +export const ToolPolicies: React.FC = ({ accessToken, onSelectTool }) => { const [tools, setTools] = useState([]); const [loading, setLoading] = useState(true); const [isFetching, setIsFetching] = useState(false); const [error, setError] = useState(null); - const [saving, setSaving] = useState(null); + const [savingInput, setSavingInput] = useState(null); + const [savingOutput, setSavingOutput] = useState(null); const [searchTerm, setSearchTerm] = useState(""); const [sortField, setSortField] = useState("created_at"); @@ -123,16 +96,29 @@ export const ToolPolicies: React.FC = ({ accessToken }) => { return () => clearInterval(id); }, [isLiveTail, load]); - const handlePolicyChange = async (toolName: string, newPolicy: string) => { + const handleInputPolicyChange = async (toolName: string, newPolicy: string) => { if (!accessToken) return; - setSaving(toolName); + setSavingInput(toolName); try { - await updateToolPolicy(accessToken, toolName, newPolicy); - setTools((prev) => prev.map((t) => (t.tool_name === toolName ? { ...t, call_policy: newPolicy } : t))); + await updateToolPolicy(accessToken, toolName, { input_policy: newPolicy }); + setTools((prev) => prev.map((t) => (t.tool_name === toolName ? { ...t, input_policy: newPolicy } : t))); } catch (e: any) { - alert(`Failed to update policy: ${e.message}`); + alert(`Failed to update input policy: ${e.message}`); } finally { - setSaving(null); + setSavingInput(null); + } + }; + + const handleOutputPolicyChange = async (toolName: string, newPolicy: string) => { + if (!accessToken) return; + setSavingOutput(toolName); + try { + await updateToolPolicy(accessToken, toolName, { output_policy: newPolicy }); + setTools((prev) => prev.map((t) => (t.tool_name === toolName ? { ...t, output_policy: newPolicy } : t))); + } catch (e: any) { + alert(`Failed to update output policy: ${e.message}`); + } finally { + setSavingOutput(null); } }; @@ -157,7 +143,6 @@ export const ToolPolicies: React.FC = ({ accessToken }) => { setCurrentPage(1); }; - // Build unique team/key options from loaded data const teamOptions = Array.from(new Set(tools.map((t) => t.team_id).filter(Boolean))).map((v) => ({ label: v as string, value: v as string, @@ -169,9 +154,14 @@ export const ToolPolicies: React.FC = ({ accessToken }) => { const filterOptions: FilterOption[] = [ { - name: "Policy", - label: "Policy", - options: POLICY_OPTIONS.map((o) => ({ label: o.label, value: o.value })), + name: "Input Policy", + label: "Input Policy", + options: INPUT_POLICY_OPTIONS.map((o) => ({ label: o.label, value: o.value })), + }, + { + name: "Output Policy", + label: "Output Policy", + options: OUTPUT_POLICY_OPTIONS.map((o) => ({ label: o.label, value: o.value })), }, { name: "Team Name", @@ -185,6 +175,39 @@ export const ToolPolicies: React.FC = ({ accessToken }) => { }, ]; + const { newToday, newYesterday, trendSubtitle, totalTools, blockedCount, activeTeamsCount, needsReviewTools } = + useMemo(() => { + const now = new Date(); + const todayKey = getUTCDateKey(now); + const yesterday = new Date(now); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + const yesterdayKey = getUTCDateKey(yesterday); + + const newToday = countToolsInUTCDay(tools, todayKey); + const newYesterday = countToolsInUTCDay(tools, yesterdayKey); + const trendSubtitle = getTrendSubtitle(newToday, newYesterday); + + const totalTools = tools.length; + const blockedCount = tools.filter((t) => t.input_policy === "blocked").length; + const activeTeamsCount = new Set(tools.map((t) => t.team_id).filter(Boolean)).size; + + const needsReviewTools = tools.filter( + (t) => + isCreatedInUTCDay(t.created_at, todayKey) && + t.input_policy === "untrusted" + ); + + return { + newToday, + newYesterday, + trendSubtitle, + totalTools, + blockedCount, + activeTeamsCount, + needsReviewTools, + }; + }, [tools]); + const SortHeader = ({ label, field }: { label: string; field: SortField }) => (
{label} @@ -203,10 +226,12 @@ export const ToolPolicies: React.FC = ({ accessToken }) => { (t.team_id ?? "").toLowerCase().includes(q) || (t.key_alias ?? "").toLowerCase().includes(q) || (t.key_hash ?? "").toLowerCase().includes(q) || - t.call_policy.toLowerCase().includes(q); + t.input_policy.toLowerCase().includes(q) || + t.output_policy.toLowerCase().includes(q); if (!matchesSearch) return false; } - if (activeFilters["Policy"] && t.call_policy !== activeFilters["Policy"]) return false; + if (activeFilters["Input Policy"] && t.input_policy !== activeFilters["Input Policy"]) return false; + if (activeFilters["Output Policy"] && t.output_policy !== activeFilters["Output Policy"]) return false; if (activeFilters["Team Name"] && t.team_id !== activeFilters["Team Name"]) return false; if (activeFilters["Key Name"] && t.key_alias !== activeFilters["Key Name"]) return false; return true; @@ -223,11 +248,74 @@ export const ToolPolicies: React.FC = ({ accessToken }) => { const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize)); const paginated = sorted.slice((currentPage - 1) * pageSize, currentPage * pageSize); + const scrollToToolRow = (toolId: string) => { + const idx = sorted.findIndex((t) => t.tool_id === toolId); + if (idx >= 0) { + const page = Math.floor(idx / pageSize) + 1; + if (page !== currentPage) setCurrentPage(page); + requestAnimationFrame(() => { + setTimeout(() => { + document.getElementById(`tool-row-${toolId}`)?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 100); + }); + } + }; + return ( -
+

Tool Policies

+ +
+ + + + } + /> + + 0 ? "text-red-600" : undefined} + /> + 0 ? activeTeamsCount : "—"} /> +
+ + {needsReviewTools.length > 0 && ( +
+

Needs Review

+

+ {needsReviewTools.length} new tool{needsReviewTools.length !== 1 ? "s" : ""} discovered that require + policy decisions. +

+
+ {needsReviewTools.map((t) => ( + + + {t.tool_name} + + + + ))} +
+
+ )} +
- {/* Toolbar */}
@@ -311,7 +399,6 @@ export const ToolPolicies: React.FC = ({ accessToken }) => {
- {/* Filter row */}
= ({ accessToken }) => {
- {/* Auto-refresh banner */} {isLiveTail && (
Auto-refreshing every 15 seconds @@ -336,7 +422,6 @@ export const ToolPolicies: React.FC = ({ accessToken }) => {
{error}
)} - {/* Table */} @@ -347,7 +432,10 @@ export const ToolPolicies: React.FC = ({ accessToken }) => { - + + + + @@ -359,45 +447,61 @@ export const ToolPolicies: React.FC = ({ accessToken }) => { - Origin + User Agent {loading ? ( - + Loading tools… ) : paginated.length === 0 ? ( - + No tools discovered yet. Make a chat completion that returns tool_calls to start auto-discovery. ) : ( paginated.map((tool) => ( - + - - - {tool.tool_name} - - + - - {(tool.call_count ?? 0).toLocaleString()} + + + + +
+ {(tool.call_count ?? 0).toLocaleString()} +
@@ -417,8 +521,8 @@ export const ToolPolicies: React.FC = ({ accessToken }) => { - - {tool.origin ?? "-"} + + {tool.user_agent ?? "-"}
@@ -427,7 +531,6 @@ export const ToolPolicies: React.FC = ({ accessToken }) => {
- {/* Bottom pagination (only when > 1 page) */} {totalPages > 1 && (
@@ -453,6 +556,7 @@ export const ToolPolicies: React.FC = ({ accessToken }) => {
)}
+
); }; diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.tsx new file mode 100644 index 00000000000..1317351931e --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.tsx @@ -0,0 +1,92 @@ +"use client"; + +import React from "react"; +import { Select } from "antd"; + +export const INPUT_POLICY_OPTIONS = [ + { value: "untrusted", label: "untrusted", color: "#92400e", bg: "#fef3c7", border: "#fcd34d" }, + { value: "trusted", label: "trusted", color: "#065f46", bg: "#d1fae5", border: "#6ee7b7" }, + { value: "blocked", label: "blocked", color: "#991b1b", bg: "#fee2e2", border: "#fca5a5" }, +] as const; + +export const OUTPUT_POLICY_OPTIONS = [ + { value: "untrusted", label: "untrusted", color: "#92400e", bg: "#fef3c7", border: "#fcd34d" }, + { value: "trusted", label: "trusted", color: "#065f46", bg: "#d1fae5", border: "#6ee7b7" }, +] as const; + +export const POLICY_OPTIONS = INPUT_POLICY_OPTIONS; + +export const policyStyle = (p: string) => + INPUT_POLICY_OPTIONS.find((o) => o.value === p) ?? INPUT_POLICY_OPTIONS[0]; + +export interface PolicySelectProps { + value: string; + toolName: string; + saving: boolean; + onChange: (toolName: string, policy: string) => void; + policyType?: "input" | "output"; + size?: "small" | "middle"; + minWidth?: number; + stopPropagation?: boolean; +} + +export const PolicySelect: React.FC = ({ + value, + toolName, + saving, + onChange, + policyType = "input", + size = "small", + minWidth = 110, + stopPropagation = true, +}) => { + const options = policyType === "output" ? OUTPUT_POLICY_OPTIONS : INPUT_POLICY_OPTIONS; + const style = policyStyle(value); + return ( +