From 3f3efea3016bb52d93601d32f326fd02e8ac4313 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Thu, 11 Sep 2025 15:12:21 -0700 Subject: [PATCH 01/42] test(test_gemini.py): add additional testing for additionalproperties case --- litellm/types/llms/vertex_ai.py | 1 - tests/llm_translation/test_gemini.py | 79 ++++++++++++++----- .../test_amazing_vertex_completion.py | 50 +++++++++--- 3 files changed, 96 insertions(+), 34 deletions(-) diff --git a/litellm/types/llms/vertex_ai.py b/litellm/types/llms/vertex_ai.py index 2687b79f72..f17a284ddf 100644 --- a/litellm/types/llms/vertex_ai.py +++ b/litellm/types/llms/vertex_ai.py @@ -113,7 +113,6 @@ class Schema(TypedDict, total=False): pattern: str example: Any anyOf: List["Schema"] - additionalProperties: Any class FunctionDeclaration(TypedDict, total=False): diff --git a/tests/llm_translation/test_gemini.py b/tests/llm_translation/test_gemini.py index 47e3aaa814..122429a87a 100644 --- a/tests/llm_translation/test_gemini.py +++ b/tests/llm_translation/test_gemini.py @@ -269,7 +269,11 @@ def test_gemini_image_generation(): assert len(response.choices[0].message.images) > 0 assert response.choices[0].message.images[0]["image_url"] is not None assert response.choices[0].message.images[0]["image_url"]["url"] is not None - assert response.choices[0].message.images[0]["image_url"]["url"].startswith("data:image/png;base64,") + assert ( + response.choices[0] + .message.images[0]["image_url"]["url"] + .startswith("data:image/png;base64,") + ) def test_gemini_thinking(): @@ -661,7 +665,8 @@ def test_system_message_with_no_user_message(): assert response is not None assert response.choices[0].message.content is not None - + + def get_current_weather(location, unit="fahrenheit"): """Get the current weather in a given location""" if "tokyo" in location.lower(): @@ -778,9 +783,9 @@ def test_gemini_reasoning_effort_minimal(): # Test with different Gemini models to verify model-specific mapping test_cases = [ - ("gemini/gemini-2.5-flash", 1), # Flash: minimum 1 token - ("gemini/gemini-2.5-pro", 128), # Pro: minimum 128 tokens - ("gemini/gemini-2.5-flash-lite", 512), # Flash-Lite: minimum 512 tokens + ("gemini/gemini-2.5-flash", 1), # Flash: minimum 1 token + ("gemini/gemini-2.5-pro", 128), # Pro: minimum 128 tokens + ("gemini/gemini-2.5-flash-lite", 512), # Flash-Lite: minimum 512 tokens ] for model, expected_min_budget in test_cases: @@ -793,24 +798,32 @@ def test_gemini_reasoning_effort_minimal(): "reasoning_effort": "minimal", }, ) - + # Verify that the thinking config is set correctly request_body = raw_request["raw_request_body"] - assert "generationConfig" in request_body, f"Model {model} should have generationConfig" - + assert ( + "generationConfig" in request_body + ), f"Model {model} should have generationConfig" + generation_config = request_body["generationConfig"] - assert "thinkingConfig" in generation_config, f"Model {model} should have thinkingConfig" - + assert ( + "thinkingConfig" in generation_config + ), f"Model {model} should have thinkingConfig" + thinking_config = generation_config["thinkingConfig"] - assert "thinkingBudget" in thinking_config, f"Model {model} should have thinkingBudget" - + assert ( + "thinkingBudget" in thinking_config + ), f"Model {model} should have thinkingBudget" + actual_budget = thinking_config["thinkingBudget"] - assert actual_budget == expected_min_budget, \ - f"Model {model} should map 'minimal' to {expected_min_budget} tokens, got {actual_budget}" - + assert ( + actual_budget == expected_min_budget + ), f"Model {model} should map 'minimal' to {expected_min_budget} tokens, got {actual_budget}" + # Verify that includeThoughts is True for minimal reasoning effort - assert thinking_config.get("includeThoughts", True), \ - f"Model {model} should have includeThoughts=True for minimal reasoning effort" + assert thinking_config.get( + "includeThoughts", True + ), f"Model {model} should have includeThoughts=True for minimal reasoning effort" # Test with unknown model (should use generic fallback) try: @@ -822,15 +835,41 @@ def test_gemini_reasoning_effort_minimal(): "reasoning_effort": "minimal", }, ) - + request_body = raw_request["raw_request_body"] generation_config = request_body["generationConfig"] thinking_config = generation_config["thinkingConfig"] # Should use generic fallback (128 tokens) - assert thinking_config["thinkingBudget"] == 128, \ - "Unknown model should use generic fallback of 128 tokens" + assert ( + thinking_config["thinkingBudget"] == 128 + ), "Unknown model should use generic fallback of 128 tokens" except Exception as e: # If return_raw_request doesn't work for unknown models, that's okay # The important part is that our known models work correctly print(f"Note: Unknown model test skipped due to: {e}") pass + + +def test_gemini_additional_properties_bug(): + # Simple tool with additionalProperties (simulating the TypedDict issue) + tools = [ + { + "type": "function", + "function": { + "name": "test_tool", + "description": "Test tool", + "parameters": { + "type": "object", + "properties": {"param1": {"type": "string"}}, + # This causes the error - any non-False value + "additionalProperties": True, # Could also be None, {}, etc. + }, + }, + } + ] + + messages = [{"role": "user", "content": "Test message"}] + + response = litellm.completion( + model="gemini/gemini-2.5-flash", messages=messages, tools=tools + ) diff --git a/tests/local_testing/test_amazing_vertex_completion.py b/tests/local_testing/test_amazing_vertex_completion.py index a27fe738c7..d20f54bdd3 100644 --- a/tests/local_testing/test_amazing_vertex_completion.py +++ b/tests/local_testing/test_amazing_vertex_completion.py @@ -397,7 +397,7 @@ async def test_async_vertexai_response(): | litellm.vertex_text_models | litellm.vertex_code_text_models ) - + test_models = random.sample(list(test_models), 1) test_models += list(litellm.vertex_language_models) # always test gemini-pro for model in test_models: @@ -504,7 +504,6 @@ async def test_async_vertexai_streaming_response(): pytest.fail(f"An exception occurred: {e}") - @pytest.mark.parametrize("load_pdf", [False]) # True, @pytest.mark.flaky(retries=3, delay=1) def test_completion_function_plus_pdf(load_pdf): @@ -547,6 +546,7 @@ def test_completion_function_plus_pdf(load_pdf): except Exception as e: pytest.fail("Got={}".format(str(e))) + def encode_image(image_path): import base64 @@ -910,7 +910,10 @@ async def test_partner_models_httpx(model, region, sync_mode): [ ("vertex_ai/meta/llama-4-scout-17b-16e-instruct-maas", "us-east5"), ("vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas", "us-south1"), - ("vertex_ai/mistral-large-2411", "us-central1"), # critical - we had this issue: https://github.com/BerriAI/litellm/issues/13888 + ( + "vertex_ai/mistral-large-2411", + "us-central1", + ), # critical - we had this issue: https://github.com/BerriAI/litellm/issues/13888 ("vertex_ai/openai/gpt-oss-20b-maas", "us-central1"), ], ) @@ -3827,7 +3830,7 @@ def test_vertex_ai_gemini_audio_ogg(): @pytest.mark.asyncio async def test_vertex_ai_deepseek(): """Test that deepseek models use the correct v1 API endpoint instead of v1beta1.""" - #load_vertex_ai_credentials() + # load_vertex_ai_credentials() litellm._turn_on_debug() from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler @@ -3840,21 +3843,17 @@ async def test_vertex_ai_deepseek(): { "message": { "role": "assistant", - "content": "Hello! How can I help you today?" + "content": "Hello! How can I help you today?", }, "index": 0, - "finish_reason": "stop" + "finish_reason": "stop", } ], - "usage": { - "prompt_tokens": 10, - "completion_tokens": 20, - "total_tokens": 30 - }, - "model": "deepseek-ai/deepseek-r1-0528-maas" + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, + "model": "deepseek-ai/deepseek-r1-0528-maas", } mock_response.status_code = 200 - + with patch.object(client, "post", return_value=mock_response) as mock_post: response = await acompletion( model="vertex_ai/deepseek-ai/deepseek-r1-0528-maas", @@ -3900,3 +3899,28 @@ def test_gemini_grounding_on_streaming(): vertex_ai_grounding_metadata_shows_up = True print(chunk) assert vertex_ai_grounding_metadata_shows_up + + +def test_gemini_additional_properties_bug(): + # Simple tool with additionalProperties (simulating the TypedDict issue) + tools = [ + { + "type": "function", + "function": { + "name": "test_tool", + "description": "Test tool", + "parameters": { + "type": "object", + "properties": {"param1": {"type": "string"}}, + # This causes the error - any non-False value + "additionalProperties": True, # Could also be None, {}, etc. + }, + }, + } + ] + + messages = [{"role": "user", "content": "Test message"}] + + response = litellm.completion( + model="gemini/gemini-2.5-flash", messages=messages, tools=tools + ) From 84a7329dba2838d6de6e9c1e6a4a7c03dfb9076d Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Thu, 11 Sep 2025 16:04:06 -0700 Subject: [PATCH 02/42] fix(secret_managers/get_azure_Ad_token_providers.py): infer credential type from env var don't default to ClientSecretCredential unless present in env var --- litellm/llms/azure/common_utils.py | 4 ++- litellm/proxy/_new_secret_config.yaml | 8 +++++ .../get_azure_ad_token_provider.py | 36 +++++++++++++++++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/litellm/llms/azure/common_utils.py b/litellm/llms/azure/common_utils.py index 09b1888e04..b36375e416 100644 --- a/litellm/llms/azure/common_utils.py +++ b/litellm/llms/azure/common_utils.py @@ -561,7 +561,9 @@ class BaseAzureLLM(BaseOpenAILLM): "Using Azure AD token provider based on Service Principal with Secret workflow for Azure Auth" ) try: - azure_ad_token_provider = get_azure_ad_token_provider(azure_scope=scope) + azure_ad_token_provider = get_azure_ad_token_provider( + azure_scope=scope, + ) except ValueError: verbose_logger.debug("Azure AD Token Provider could not be used.") if api_version is None: diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml index c785dd05c4..be2a830386 100644 --- a/litellm/proxy/_new_secret_config.yaml +++ b/litellm/proxy/_new_secret_config.yaml @@ -7,3 +7,11 @@ model_list: - model_name: wildcard_models/* litellm_params: model: openai/* + - model_name: gpt-4o + litellm_params: + model: azure/gpt-4o + api_base: https://cog-cuda-atg-sage-eastus2.openai.azure.com + api_version: 2025-04-01-preview + +litellm_settings: + enable_azure_ad_token_refresh: true \ No newline at end of file diff --git a/litellm/secret_managers/get_azure_ad_token_provider.py b/litellm/secret_managers/get_azure_ad_token_provider.py index e73c8f8d7a..184d959b96 100644 --- a/litellm/secret_managers/get_azure_ad_token_provider.py +++ b/litellm/secret_managers/get_azure_ad_token_provider.py @@ -1,11 +1,36 @@ import os from typing import Any, Callable, Optional, Union +from litellm._logging import verbose_logger from litellm.types.secret_managers.get_azure_ad_token_provider import ( AzureCredentialType, ) +def infer_credential_type_from_environment() -> AzureCredentialType: + if ( + os.environ.get("AZURE_CLIENT_ID") + and os.environ.get("AZURE_CLIENT_SECRET") + and os.environ.get("AZURE_TENANT_ID") + ): + return AzureCredentialType.ClientSecretCredential + elif os.environ.get("AZURE_CLIENT_ID"): + return AzureCredentialType.ManagedIdentityCredential + elif ( + os.environ.get("AZURE_CLIENT_ID") + and os.environ.get("AZURE_TENANT_ID") + and os.environ.get("AZURE_CERTIFICATE_PATH") + and os.environ.get("AZURE_CERTIFICATE_PASSWORD") + ): + return AzureCredentialType.CertificateCredential + elif os.environ.get("AZURE_CERTIFICATE_PASSWORD"): + return AzureCredentialType.CertificateCredential + elif os.environ.get("AZURE_CERTIFICATE_PATH"): + return AzureCredentialType.CertificateCredential + else: + return AzureCredentialType.DefaultAzureCredential + + def get_azure_ad_token_provider( azure_scope: Optional[str] = None, azure_credential: Optional[AzureCredentialType] = None, @@ -42,9 +67,14 @@ def get_azure_ad_token_provider( ) cred: str = ( - azure_credential.value if azure_credential else None - or os.environ.get("AZURE_CREDENTIAL", AzureCredentialType.ClientSecretCredential) - or AzureCredentialType.ClientSecretCredential + azure_credential.value + if azure_credential + else None + or os.environ.get("AZURE_CREDENTIAL") + or infer_credential_type_from_environment() + ) + verbose_logger.info( + f"For Azure AD Token Provider, choosing credential type: {cred}" ) credential: Optional[ Union[ From d0732f55b3954da5a6853447a99fb8ae5a38edbe Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Thu, 11 Sep 2025 16:07:32 -0700 Subject: [PATCH 03/42] test(test_get_azure_ad_token_provider.py): add unit test to ensure default azure credentials used in the right context --- .../test_get_azure_ad_token_provider.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_litellm/secret_managers/test_get_azure_ad_token_provider.py b/tests/test_litellm/secret_managers/test_get_azure_ad_token_provider.py index 85e55a5c30..f02f59cccc 100644 --- a/tests/test_litellm/secret_managers/test_get_azure_ad_token_provider.py +++ b/tests/test_litellm/secret_managers/test_get_azure_ad_token_provider.py @@ -214,3 +214,32 @@ class TestGetAzureAdTokenProvider: # Test that the returned callable works token = result() assert token == "mock-certificate-token" + + @patch.dict(os.environ, {}, clear=True) # Clear all environment variables + @patch("azure.identity.get_bearer_token_provider") + @patch("azure.identity.DefaultAzureCredential") + def test_get_azure_ad_token_provider_defaults_to_default_azure_credential( + self, mock_default_azure_credential, mock_get_bearer_token_provider + ): + """Test get_azure_ad_token_provider defaults to DefaultAzureCredential when no credentials are present.""" + # Mock the Azure identity credential instance + mock_credential_instance = MagicMock() + mock_default_azure_credential.return_value = mock_credential_instance + + # Mock the bearer token provider + mock_token_provider = MagicMock(return_value="mock-default-token") + mock_get_bearer_token_provider.return_value = mock_token_provider + + # Call the function + result = get_azure_ad_token_provider() + + # Assertions + assert callable(result) + mock_default_azure_credential.assert_called_once_with() + mock_get_bearer_token_provider.assert_called_once_with( + mock_credential_instance, "https://cognitiveservices.azure.com/.default" + ) + + # Test that the returned callable works + token = result() + assert token == "mock-default-token" From 54e71bd0775adeab4df7015e334dd7ac3cbc551b Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Thu, 11 Sep 2025 16:10:13 -0700 Subject: [PATCH 04/42] fix(common_utils.py): add helpful message --- litellm/llms/azure/common_utils.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/litellm/llms/azure/common_utils.py b/litellm/llms/azure/common_utils.py index b36375e416..7c744298fb 100644 --- a/litellm/llms/azure/common_utils.py +++ b/litellm/llms/azure/common_utils.py @@ -365,6 +365,11 @@ def get_azure_ad_token( azure_ad_token_provider = get_azure_ad_token_provider(azure_scope=scope) except ValueError: verbose_logger.debug("Azure AD Token Provider could not be used.") + except Exception as e: + verbose_logger.error( + f"Error calling Azure AD token provider: {str(e)}. Follow docs - https://docs.litellm.ai/docs/providers/azure/#azure-ad-token-refresh---defaultazurecredential" + ) + raise e ######################################################### # If litellm.enable_azure_ad_token_refresh is True and no other token provider is available, From 03804cb1d29b1fc930c64089e03f7a08e17156f7 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Sun, 5 Oct 2025 16:01:20 -0700 Subject: [PATCH 05/42] preliminary virtual-keys refactor simplifies handleCreate function for CreateKeyModal moved hooks, components, files where I want them moved virtual-key page to (console) for common layout added layout for refactored console file structure, sidebar wired teams to refactored components fixing type issue wired new top level hook useAuthorized Adds routing to unrefactored pages Update leftnav.tsx Update useAuthorized.ts useAuthorized in virtual-keys/page and deprecation notices prelim VirtualKeyViewPage, removed old comment Delete page.tsx --- .../src/app/(console)/components/Sidebar.tsx | 291 ++++++++ .../components/modals/CreateUserModal.tsx | 345 +++++++++ .../src/app/(console)/hooks/useAuthorized.ts | 23 + .../src/app/(console)/layout.tsx | 46 ++ .../virtual-keys/components/CreateKey.tsx | 108 +++ .../CreateKeyForm/KeyDetailsSection.tsx | 148 ++++ .../CreateKeyForm/OptionalSettingsSection.tsx | 458 ++++++++++++ .../CreateKeyForm/OwnershipSection.tsx | 130 ++++ .../CreateKeyModal/CreateKeyModal.tsx | 233 ++++++ .../components/CreateKeyModal/hooks/index.ts | 5 + .../hooks/useGuardrailsAndPrompts.ts | 24 + .../hooks/useMcpAccessGroups.ts | 21 + .../CreateKeyModal/hooks/useTeamModels.ts | 26 + .../CreateKeyModal/hooks/useUserModels.ts | 21 + .../CreateKeyModal/hooks/useUserSearch.ts | 41 ++ .../components/CreateKeyModal/networking.ts | 84 +++ .../components/CreateKeyModal/types.ts | 17 + .../components/CreateKeyModal/utils.ts | 187 +++++ .../virtual-keys/components/KeyInfoView.tsx | 697 ++++++++++++++++++ .../virtual-keys/components/SaveKeyModal.tsx | 58 ++ .../VirtualKeysTable/VirtualKeysTable.tsx | 666 +++++++++++++++++ .../VirtualKeysTable/hooks/useFilterLogic.ts | 189 +++++ .../(console)/virtual-keys/hooks/useTeams.tsx | 20 + .../app/(console)/virtual-keys/networking.ts | 17 + .../src/app/(console)/virtual-keys/page.tsx | 52 ++ ui/litellm-dashboard/src/app/page.tsx | 2 - .../src/components/leftnav.tsx | 50 +- .../organisms/create_key_button.tsx | 8 + .../components/templates/view_key_table.tsx | 7 + ui/litellm-dashboard/src/utils/roles.ts | 2 +- 30 files changed, 3941 insertions(+), 35 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(console)/components/Sidebar.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/components/modals/CreateUserModal.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/hooks/useAuthorized.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/layout.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKey.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/index.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/networking.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/types.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/utils.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/KeyInfoView.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/SaveKeyModal.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/hooks/useTeams.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/networking.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/page.tsx diff --git a/ui/litellm-dashboard/src/app/(console)/components/Sidebar.tsx b/ui/litellm-dashboard/src/app/(console)/components/Sidebar.tsx new file mode 100644 index 0000000000..ad60ba626a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/components/Sidebar.tsx @@ -0,0 +1,291 @@ +import React from "react"; +import Link from "next/link"; +import { usePathname, useSearchParams } from "next/navigation"; +import { Layout, Menu, ConfigProvider } from "antd"; +import { + KeyOutlined, + PlayCircleOutlined, + BlockOutlined, + BarChartOutlined, + TeamOutlined, + BankOutlined, + UserOutlined, + SettingOutlined, + ApiOutlined, + AppstoreOutlined, + DatabaseOutlined, + FileTextOutlined, + LineChartOutlined, + SafetyOutlined, + ExperimentOutlined, + ToolOutlined, + TagsOutlined, + BgColorsOutlined, +} from "@ant-design/icons"; +import { all_admin_roles, internalUserRoles, isAdminRole, rolesWithWriteAccess } from "@/utils/roles"; +import UsageIndicator from "@/components/usage_indicator"; + +const { Sider } = Layout; + +interface SidebarProps { + accessToken: string | null; + userRole: string; + /** Used to highlight a menu item when pathname doesn't match (e.g., on non-routed pages) */ + defaultSelectedKey?: string; + collapsed?: boolean; +} + +interface MenuItem { + key: string; + page: string; + label: string; + roles?: string[]; + children?: MenuItem[]; + icon?: React.ReactNode; +} + +const Sidebar: React.FC = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => { + const menuItems: MenuItem[] = [ + { key: "1", page: "api-keys", label: "Virtual Keys", icon: }, + { + key: "3", + page: "llm-playground", + label: "Test Key", + icon: , + roles: rolesWithWriteAccess, + }, + { + key: "2", + page: "models", + label: "Models + Endpoints", + icon: , + roles: rolesWithWriteAccess, + }, + { + key: "12", + page: "new_usage", + label: "Usage", + icon: , + roles: [...all_admin_roles, ...internalUserRoles], + }, + { key: "6", page: "teams", label: "Teams", icon: }, + { + key: "17", + page: "organizations", + label: "Organizations", + icon: , + roles: all_admin_roles, + }, + { + key: "5", + page: "users", + label: "Internal Users", + icon: , + roles: all_admin_roles, + }, + { key: "14", page: "api_ref", label: "API Reference", icon: }, + { key: "16", page: "model-hub-table", label: "Model Hub", icon: }, + { key: "15", page: "logs", label: "Logs", icon: }, + { + key: "11", + page: "guardrails", + label: "Guardrails", + icon: , + roles: all_admin_roles, + }, + { + key: "26", + page: "tools", + label: "Tools", + icon: , + children: [ + { key: "18", page: "mcp-servers", label: "MCP Servers", icon: }, + { + key: "21", + page: "vector-stores", + label: "Vector Stores", + icon: , + roles: all_admin_roles, + }, + ], + }, + { + key: "experimental", + page: "experimental", + label: "Experimental", + icon: , + children: [ + { + key: "9", + page: "caching", + label: "Caching", + icon: , + roles: all_admin_roles, + }, + { + key: "25", + page: "prompts", + label: "Prompts", + icon: , + roles: all_admin_roles, + }, + { + key: "10", + page: "budgets", + label: "Budgets", + icon: , + roles: all_admin_roles, + }, + { + key: "20", + page: "transform-request", + label: "API Playground", + icon: , + roles: [...all_admin_roles, ...internalUserRoles], + }, + { + key: "19", + page: "tag-management", + label: "Tag Management", + icon: , + roles: all_admin_roles, + }, + { key: "4", page: "usage", label: "Old Usage", icon: }, + ], + }, + { + key: "settings", + page: "settings", + label: "Settings", + icon: , + roles: all_admin_roles, + children: [ + { + key: "11", + page: "general-settings", + label: "Router Settings", + icon: , + roles: all_admin_roles, + }, + { + key: "8", + page: "settings", + label: "Logging & Alerts", + icon: , + roles: all_admin_roles, + }, + { + key: "13", + page: "admin-panel", + label: "Admin Settings", + icon: , + roles: all_admin_roles, + }, + { + key: "14", + page: "ui-theme", + label: "UI Theme", + icon: , + roles: all_admin_roles, + }, + ], + }, + ]; + + // Role filtering (preserves original visibility behavior) + const filteredMenuItems: MenuItem[] = menuItems + .filter((item) => !item.roles || item.roles.includes(userRole)) + .map((item) => ({ + ...item, + children: item.children?.filter((child) => !child.roles || child.roles.includes(userRole)), + })); + + // Highlight selection based on pathname or ?page= + const pathname = usePathname(); + const searchParams = useSearchParams(); + const pageParam = searchParams.get("page") || undefined; + + const findMenuItemKey = (page: string): string => { + const top = filteredMenuItems.find((i) => i.page === page); + if (top) return top.key; + for (const i of filteredMenuItems) { + const child = i.children?.find((c) => c.page === page); + if (child) return child.key; + } + return "1"; + }; + + const selectedMenuKey = + pathname === "/virtual-keys" + ? "1" + : pageParam + ? findMenuItemKey(pageParam) + : defaultSelectedKey + ? findMenuItemKey(defaultSelectedKey) + : "1"; + + // Root-only routing helper: always replace everything after the domain + const rootWithPage = (p: string) => ({ pathname: "/", query: { page: p } }); + + // Convert to AntD Menu items: + // - "Virtual Keys" still routes to /virtual-keys + // - All other items (and children) route to "/?page=" + const antdItems = filteredMenuItems.map((item) => { + const isVirtualKeys = item.key === "1"; + const label = isVirtualKeys ? ( + Virtual Keys + ) : ( + {item.label} + ); + + return { + key: item.key, + icon: item.icon, + label, + children: item.children?.map((child) => ({ + key: child.key, + icon: child.icon, + label: {child.label}, + })), + }; + }); + + return ( + + + + + + + {isAdminRole(userRole) && !collapsed && } + + + ); +}; + +export default Sidebar; diff --git a/ui/litellm-dashboard/src/app/(console)/components/modals/CreateUserModal.tsx b/ui/litellm-dashboard/src/app/(console)/components/modals/CreateUserModal.tsx new file mode 100644 index 0000000000..4caaceb662 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/components/modals/CreateUserModal.tsx @@ -0,0 +1,345 @@ +// TODO: refactor +import React, { useState, useEffect } from "react"; +import { Button, Modal, Form, Input, message, Select, InputNumber, Select as Select2 } from "antd"; +import { + Button as Button2, + Text, + TextInput, + SelectItem, + Accordion, + AccordionHeader, + AccordionBody, + Title, +} from "@tremor/react"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +const { Option } = Select; +import { Tooltip } from "antd"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { useQueryClient } from "@tanstack/react-query"; +import OnboardingModal, { InvitationLink } from "@/components/onboarding_link"; +import { + getProxyBaseUrl, + getProxyUISettings, + invitationCreateCall, + modelAvailableCall, + Team, + userCreateCall, +} from "@/components/networking"; +import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; +import BulkCreateUsersButton from "@/components/bulk_create_users_button"; +import { fetchTeams } from "@/app/(console)/virtual-keys/networking"; +import useTeams from "@/app/(console)/virtual-keys/hooks/useTeams"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +// Helper function to generate UUID compatible across all environments +const generateUUID = (): string => { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + // Fallback UUID generation for environments without crypto.randomUUID + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0; + const v = c == "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +}; + +interface CreateUserModalProps { + possibleUIRoles: null | Record>; + onUserCreated?: (userId: string) => void; + isEmbedded?: boolean; +} + +interface UISettings { + PROXY_BASE_URL: string | null; + PROXY_LOGOUT_URL: string | null; + DEFAULT_TEAM_DISABLED: boolean; + SSO_ENABLED: boolean; +} + +const CreateUserModal: React.FC = ({ possibleUIRoles, onUserCreated, isEmbedded = false }) => { + const { userId: userID, accessToken } = useAuthorized(); + const queryClient = useQueryClient(); + const [uiSettings, setUISettings] = useState(null); + const [form] = Form.useForm(); + const [isModalVisible, setIsModalVisible] = useState(false); + const [apiuser, setApiuser] = useState(false); + const [userModels, setUserModels] = useState([]); + const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false); + const [invitationLinkData, setInvitationLinkData] = useState(null); + const [baseUrl, setBaseUrl] = useState(null); + const teams = useTeams(); + + // get all models + useEffect(() => { + const fetchData = async () => { + try { + const userRole = "any"; // You may need to get the user role dynamically + const modelDataResponse = await modelAvailableCall(accessToken, userID, userRole); + // Assuming modelDataResponse.data contains an array of model objects with a 'model_name' property + const availableModels = []; + for (let i = 0; i < modelDataResponse.data.length; i++) { + const model = modelDataResponse.data[i]; + availableModels.push(model.id); + } + console.log("Model data response:", modelDataResponse.data); + console.log("Available models:", availableModels); + + // Assuming modelDataResponse.data contains an array of model names + setUserModels(availableModels); + + // get ui settings + const uiSettingsResponse = await getProxyUISettings(accessToken); + console.log("uiSettingsResponse:", uiSettingsResponse); + + setUISettings(uiSettingsResponse); + } catch (error) { + console.error("Error fetching model data:", error); + } + }; + + setBaseUrl(getProxyBaseUrl()); + + fetchData(); // Call the function to fetch model data when the component mounts + }, []); // Empty dependency array to run only once + + const handleOk = () => { + setIsModalVisible(false); + form.resetFields(); + }; + + const handleCancel = () => { + setIsModalVisible(false); + setApiuser(false); + form.resetFields(); + }; + + const handleCreate = async (formValues: { user_id: string; models?: string[]; user_role: string }) => { + try { + NotificationsManager.info("Making API Call"); + if (!isEmbedded) { + setIsModalVisible(true); + } + if ((!formValues.models || formValues.models.length === 0) && formValues.user_role !== "proxy_admin") { + console.log("formValues.user_role", formValues.user_role); + // If models is empty or undefined, set it to "no-default-models" + formValues.models = ["no-default-models"]; + } + console.log("formValues in create user:", formValues); + const response = await userCreateCall(accessToken, null, formValues); + await queryClient.invalidateQueries({ queryKey: ["userList"] }); + console.log("user create Response:", response); + setApiuser(true); + const user_id = response.data?.user_id || response.user_id; + + // Call the callback if provided (for embedded mode) + if (onUserCreated && isEmbedded) { + onUserCreated(user_id); + form.resetFields(); + return; // Skip the invitation flow when embedded + } + + // only do invite link flow if sso is not enabled + if (!uiSettings?.SSO_ENABLED) { + invitationCreateCall(accessToken, user_id).then((data) => { + data.has_user_setup_sso = false; + setInvitationLinkData(data); + setIsInvitationLinkModalVisible(true); + }); + } else { + // create an InvitationLink Object for this user for the SSO flow + // for SSO the invite link is the proxy base url since the User just needs to login + const invitationLink: InvitationLink = { + id: generateUUID(), // Generate a unique ID + user_id: user_id, + is_accepted: false, + accepted_at: null, + expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // Set expiry to 7 days from now + created_at: new Date(), + created_by: userID, // Assuming userID is the current user creating the invitation + updated_at: new Date(), + updated_by: userID, + has_user_setup_sso: true, + }; + setInvitationLinkData(invitationLink); + setIsInvitationLinkModalVisible(true); + } + + NotificationsManager.success("API user Created"); + form.resetFields(); + localStorage.removeItem("userData" + userID); + } catch (error: any) { + const errorMessage = error.response?.data?.detail || error?.message || "Error creating the user"; + NotificationsManager.fromBackend(errorMessage); + console.error("Error creating the user:", error); + } + }; + + // Modify the return statement to handle embedded mode + if (isEmbedded) { + return ( +
+ + + + + + {possibleUIRoles && + Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => ( + +
+ {ui_label}{" "} +

+ {description} +

+
+
+ ))} +
+
+ + + + + + + + +
+ +
+
+ ); + } + + // Original return for standalone mode + return ( +
+ setIsModalVisible(true)}> + + Invite User + + + + Create a User who can own keys +
+ + + + + Global Proxy Role{" "} + + + + + } + name="user_role" + > + + {possibleUIRoles && + Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => ( + +
+ {ui_label}{" "} +

+ {description} +

+
+
+ ))} +
+
+ + + + + + + + + + + Personal Key Creation + + + + Models{" "} + + + + + } + name="models" + help="Models user has access to, outside of team scope." + > + + + All Proxy Models + + {userModels.map((model) => ( + + {getModelDisplayName(model)} + + ))} + + + + +
+ +
+
+
+ {apiuser && ( + + )} +
+ ); +}; + +export default CreateUserModal; diff --git a/ui/litellm-dashboard/src/app/(console)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(console)/hooks/useAuthorized.ts new file mode 100644 index 0000000000..45f5ee92ea --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/hooks/useAuthorized.ts @@ -0,0 +1,23 @@ +import { Router } from "next/router"; +import { getCookie } from "@/utils/cookieUtils"; +import { useRouter } from "next/navigation"; +import { jwtDecode } from "jwt-decode"; + +const useAuthorized = () => { + const token = getCookie("token"); + const router = useRouter(); + + if (!token) { + router.replace("/sso/key/generate"); + } + + const decoded = jwtDecode(token as string) as { [key: string]: any }; + const accessToken = decoded.key; + const userId = decoded.user_id; + const userRole = decoded.user_role; + const premiumUser = decoded.premium_user; + + return { accessToken, userId, userRole, premiumUser }; +}; + +export default useAuthorized; diff --git a/ui/litellm-dashboard/src/app/(console)/layout.tsx b/ui/litellm-dashboard/src/app/(console)/layout.tsx new file mode 100644 index 0000000000..cbfb5bdcb5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/layout.tsx @@ -0,0 +1,46 @@ +"use client"; + +import React from "react"; +import Navbar from "@/components/navbar"; +import { ThemeProvider } from "@/contexts/ThemeContext"; +import Sidebar from "@/app/(console)/components/Sidebar"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +export default function Layout({ children }: { children: React.ReactNode }) { + const { accessToken, userRole } = useAuthorized(); + const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false); + + const toggleSidebar = () => setSidebarCollapsed((v) => !v); + + return ( + +
+ +
+
+ +
+
{children}
+
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKey.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKey.tsx new file mode 100644 index 0000000000..c8e0b1d8ce --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKey.tsx @@ -0,0 +1,108 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Button } from "@tremor/react"; +import { Modal, Form } from "antd"; +import { getPossibleUserRoles, Organization } from "@/components/networking"; +import { rolesWithWriteAccess } from "@/utils/roles"; +import { Team } from "@/components/key_team_helpers/key_list"; +import CreateKeyModal from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyModal"; +import CreateUserModal from "@/app/(console)/components/modals/CreateUserModal"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +interface CreateKeyProps { + team: Team | null; + userRole: string | null; + data: any[] | null; + addKey: (data: any) => void; +} + +interface User { + user_id: string; + user_email: string; + role?: string; +} + +interface UserOption { + label: string; + value: string; + user: User; +} +const CreateKey: React.FC = ({ team, userRole, data, addKey }) => { + const { accessToken } = useAuthorized(); + const [form] = Form.useForm(); + const [isModalVisible, setIsModalVisible] = useState(false); + const [isCreateUserModalVisible, setIsCreateUserModalVisible] = useState(false); + const [newlyCreatedUserId, setNewlyCreatedUserId] = useState(null); + const [possibleUIRoles, setPossibleUIRoles] = useState>>({}); + + useEffect(() => { + const fetchPossibleRoles = async () => { + try { + if (accessToken) { + // Check if roles are cached in session storage + const cachedRoles = sessionStorage.getItem("possibleUserRoles"); + if (cachedRoles) { + setPossibleUIRoles(JSON.parse(cachedRoles)); + } else { + const availableUserRoles = await getPossibleUserRoles(accessToken); + sessionStorage.setItem("possibleUserRoles", JSON.stringify(availableUserRoles)); + setPossibleUIRoles(availableUserRoles); + } + } + } catch (error) { + console.error("Error fetching possible user roles:", error); + } + }; + + fetchPossibleRoles(); + }, [accessToken]); + + // Add a callback function to handle user creation + const handleUserCreated = (userId: string) => { + setNewlyCreatedUserId(userId); + form.setFieldsValue({ user_id: userId }); + setIsCreateUserModalVisible(false); + }; + + const handleUserSelect = (_value: string, option: UserOption): void => { + const selectedUser = option.user; + form.setFieldsValue({ + user_id: selectedUser.user_id, + }); + }; + + return ( +
+ {userRole && rolesWithWriteAccess.includes(userRole) && ( + + )} + + + {isCreateUserModalVisible && ( + setIsCreateUserModalVisible(false)} + footer={null} + width={800} + > + + + )} +
+ ); +}; + +export default CreateKey; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx new file mode 100644 index 0000000000..54e9aaefed --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx @@ -0,0 +1,148 @@ +import { TextInput, Title } from "@tremor/react"; +import { Form, FormInstance, Select, Tooltip } from "antd"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; +import React from "react"; + +export interface KeyDetailsSectionProps { + form: FormInstance; + keyOwner: string; + modelsToPick: string[]; + keyType: string; + setKeyType: (keyType: string) => void; +} + +const { Option } = Select; + +const KeyDetailsSection = ({ form, keyOwner, keyType, modelsToPick, setKeyType }: KeyDetailsSectionProps) => { + return ( +
+ Key Details + + {keyOwner === "you" || keyOwner === "another_user" ? "Key Name" : "Service Account ID"}{" "} + + + + + } + name="key_alias" + rules={[ + { + required: true, + message: `Please input a ${keyOwner === "you" ? "key name" : "service account ID"}`, + }, + ]} + help="required" + > + + + + + Models{" "} + + + + + } + name="models" + rules={ + keyType === "management" || keyType === "read_only" + ? [] + : [{ required: true, message: "Please select a model" }] + } + help={ + keyType === "management" || keyType === "read_only" + ? "Models field is disabled for this key type" + : "required" + } + className="mt-4" + > + + + + + Key Type{" "} + + + + + } + name="key_type" + initialValue="default" + className="mt-4" + > + + +
+ ); +}; + +export default KeyDetailsSection; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx new file mode 100644 index 0000000000..c879cdf595 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx @@ -0,0 +1,458 @@ +import { Accordion, AccordionBody, AccordionHeader, Text, Title } from "@tremor/react"; +import { Form, FormInstance, Input, Select, Tooltip } from "antd"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import NumericalInput from "@/components/shared/numerical_input"; +import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown"; +import RateLimitTypeFormItem from "@/components/common_components/RateLimitTypeFormItem"; +import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector"; +import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector"; +import PremiumLoggingSettings from "@/components/common_components/PremiumLoggingSettings"; +import ModelAliasManager from "@/components/common_components/ModelAliasManager"; +import KeyLifecycleSettings from "@/components/common_components/KeyLifecycleSettings"; +import { proxyBaseUrl } from "@/components/networking"; +import SchemaFormFields from "@/components/common_components/check_openapi_schema"; +import { Team } from "@/components/key_team_helpers/key_list"; +import React from "react"; +import { DefaultOptionType } from "rc-select/lib/Select"; +import { ModelAliases } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types"; + +export interface OptionalSettingsSectionProps { + form: FormInstance; + team: Team | null; + premiumUser: boolean; + guardrails: string[]; + prompts: string[]; + accessToken: string; + predefinedTags: DefaultOptionType[]; + loggingSettings: any; + setLoggingSettings: (settings: any) => void; + disabledCallbacks: string[]; + setDisabledCallbacks: (disabledCallbacks: string[]) => void; + modelAliases: ModelAliases; + setModelAliases: (aliases: ModelAliases) => void; + autoRotationEnabled: boolean; + setAutoRotationEnabled: (enabled: boolean) => void; + rotationInterval: string; + setRotationInterval: (interval: string) => void; +} + +// TODO: break apart this component into smaller sections +const OptionalSettingsSection = ({ + form, + team, + premiumUser, + guardrails, + prompts, + accessToken, + predefinedTags, + loggingSettings, + setLoggingSettings, + disabledCallbacks, + setDisabledCallbacks, + modelAliases, + setModelAliases, + autoRotationEnabled, + setAutoRotationEnabled, + rotationInterval, + setRotationInterval, +}: OptionalSettingsSectionProps) => { + return ( +
+ + + Optional Settings + + + + Max Budget (USD){" "} + + + + + } + name="max_budget" + help={`Budget cannot exceed team max budget: $${team?.max_budget !== null && team?.max_budget !== undefined ? team?.max_budget : "unlimited"}`} + rules={[ + { + validator: async (_, value) => { + if (value && team && team.max_budget !== null && value > team.max_budget) { + throw new Error( + `Budget cannot exceed team max budget: $${formatNumberWithCommas(team.max_budget, 4)}`, + ); + } + }, + }, + ]} + > + + + + Reset Budget{" "} + + + + + } + name="budget_duration" + help={`Team Reset Budget: ${team?.budget_duration !== null && team?.budget_duration !== undefined ? team?.budget_duration : "None"}`} + > + form.setFieldValue("budget_duration", value)} /> + + + Tokens per minute Limit (TPM){" "} + + + + + } + name="tpm_limit" + help={`TPM cannot exceed team TPM limit: ${team?.tpm_limit !== null && team?.tpm_limit !== undefined ? team?.tpm_limit : "unlimited"}`} + rules={[ + { + validator: async (_, value) => { + if (value && team && team.tpm_limit !== null && value > team.tpm_limit) { + throw new Error(`TPM limit cannot exceed team TPM limit: ${team.tpm_limit}`); + } + }, + }, + ]} + > + + + + + Requests per minute Limit (RPM){" "} + + + + + } + name="rpm_limit" + help={`RPM cannot exceed team RPM limit: ${team?.rpm_limit !== null && team?.rpm_limit !== undefined ? team?.rpm_limit : "unlimited"}`} + rules={[ + { + validator: async (_, value) => { + if (value && team && team.rpm_limit !== null && value > team.rpm_limit) { + throw new Error(`RPM limit cannot exceed team RPM limit: ${team.rpm_limit}`); + } + }, + }, + ]} + > + + + + + Guardrails{" "} + + e.stopPropagation()} // Prevent accordion from collapsing when clicking link + > + + + + + } + name="guardrails" + className="mt-4" + help={ + premiumUser + ? "Select existing guardrails or enter new ones" + : "Premium feature - Upgrade to set guardrails by key" + } + > + ({ value: name, label: name }))} + /> + + + Allowed Vector Stores{" "} + + + + + } + name="allowed_vector_store_ids" + className="mt-4" + help="Select vector stores this key can access. Leave empty for access to all vector stores" + > + form.setFieldValue("allowed_vector_store_ids", values)} + value={form.getFieldValue("allowed_vector_store_ids")} + accessToken={accessToken} + placeholder="Select vector stores (optional)" + /> + + + + Allowed MCP Servers{" "} + + + + + } + name="allowed_mcp_servers_and_groups" + className="mt-4" + help="Select MCP servers or access groups this key can access. " + > + form.setFieldValue("allowed_mcp_servers_and_groups", val)} + value={form.getFieldValue("allowed_mcp_servers_and_groups")} + accessToken={accessToken} + placeholder="Select MCP servers or access groups (optional)" + /> + + + + Metadata{" "} + + + + + } + name="metadata" + className="mt-4" + > + + + + Tags{" "} + + + + + } + name="tags" + className="mt-4" + help={`Tags for tracking spend and/or doing tag-based routing.`} + > + handleUserSelect(value, option as UserOption)} + options={userOptions} + loading={userSearchLoading} + allowClear + style={{ width: "100%" }} + notFoundContent={userSearchLoading ? "Searching..." : "No users found"} + /> + setIsCreateUserModalVisible(true)} style={{ marginLeft: "8px" }}> + Create User + +
+
Search by email to find users
+ + + )} + + Team{" "} + + + + + } + name="team_id" + initialValue={team ? team.team_id : null} + className="mt-4" + rules={[ + { + required: keyOwner === "service_account", + message: "Please select a team for the service account", + }, + ]} + help={keyOwner === "service_account" ? "required" : ""} + > + { + const selectedTeam = teams?.find((t) => t.team_id === teamId) || null; + setSelectedCreateKeyTeam(selectedTeam); + }} + /> + + + ); +}; + +export default OwnershipSection; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx new file mode 100644 index 0000000000..9f4d7528a8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx @@ -0,0 +1,233 @@ +import { Button as Button2, Form, FormInstance, Modal } from "antd"; +import { Text } from "@tremor/react"; +import { keyCreateCall, keyCreateServiceAccountCall } from "@/components/networking"; +import React, { useEffect, useState } from "react"; +import { Team } from "@/components/key_team_helpers/key_list"; +import SaveKeyModal from "@/app/(console)/virtual-keys/components/SaveKeyModal"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { + useGuardrailsAndPrompts, + useMcpAccessGroups, + useTeamModels, + useUserModels, + useUserSearch, +} from "@/app/(console)/virtual-keys/components/CreateKeyModal/hooks"; +import OwnershipSection from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection"; +import KeyDetailsSection from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection"; +import OptionalSettingsSection from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection"; +import { ModelAliases } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types"; +import { getPredefinedTags, prepareFormValues } from "@/app/(console)/virtual-keys/components/CreateKeyModal/utils"; +import { fetchTeams } from "@/app/(console)/virtual-keys/networking"; +import useTeams from "@/app/(console)/virtual-keys/hooks/useTeams"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +export interface CreateKeyModalProps { + isModalVisible: boolean; + setIsModalVisible: (isModalVisible: boolean) => void; + form: FormInstance; + handleUserSelect: (_value: string, option: UserOption) => void; + setIsCreateUserModalVisible: (visible: boolean) => void; + team: Team | null; + data: any[] | null; + addKey: (data: any) => void; +} + +interface User { + user_id: string; + user_email: string; + role?: string; +} + +interface UserOption { + label: string; + value: string; + user: User; +} + +const CreateKeyModal = ({ + isModalVisible, + setIsModalVisible, + form, + handleUserSelect, + setIsCreateUserModalVisible, + team, + data, + addKey, +}: CreateKeyModalProps) => { + const { userId: userID, userRole, accessToken, premiumUser } = useAuthorized(); + const [apiKey, setApiKey] = useState(null); + const [keyOwner, setKeyOwner] = useState("you"); + const [keyType, setKeyType] = useState("default"); + + const [selectedCreateKeyTeam, setSelectedCreateKeyTeam] = useState(team); + + const [softBudget, setSoftBudget] = useState(null); + const [loggingSettings, setLoggingSettings] = useState([]); + const [disabledCallbacks, setDisabledCallbacks] = useState([]); + const [autoRotationEnabled, setAutoRotationEnabled] = useState(false); + const [rotationInterval, setRotationInterval] = useState("30d"); + const [modelAliases, setModelAliases] = useState({}); + const predefinedTags = getPredefinedTags(data); + + const { options: userOptions, loading: userSearchLoading, onSearch: handleUserSearch } = useUserSearch(); + const { guardrails, prompts } = useGuardrailsAndPrompts(); + const teams = useTeams(); + const mcpAccessGroups = useMcpAccessGroups(); + const userModels = useUserModels(); + const modelsToPick = useTeamModels(selectedCreateKeyTeam); + + const isTeamSelectionRequired = modelsToPick.includes("no-default-models"); + const isFormDisabled = isTeamSelectionRequired && !selectedCreateKeyTeam; + + const handleCancel = () => { + setIsModalVisible(false); + setApiKey(null); + setSelectedCreateKeyTeam(null); + form.resetFields(); + setLoggingSettings([]); + setDisabledCallbacks([]); + setKeyType("default"); + setModelAliases({}); + setAutoRotationEnabled(false); + setRotationInterval("30d"); + }; + + const handleOk = () => { + setIsModalVisible(false); + form.resetFields(); + setLoggingSettings([]); + setDisabledCallbacks([]); + setKeyType("default"); + setModelAliases({}); + setAutoRotationEnabled(false); + setRotationInterval("30d"); + }; + + const handleCreate = async (formValues: Record) => { + try { + const newKeyAlias = formValues?.key_alias ?? ""; + const newKeyTeamId = formValues?.team_id ?? null; + + const existingKeyAliases = data?.filter((k) => k.team_id === newKeyTeamId).map((k) => k.key_alias) ?? []; + + if (existingKeyAliases.includes(newKeyAlias)) { + throw new Error( + `Key alias ${newKeyAlias} already exists for team with ID ${newKeyTeamId}, please provide another key alias`, + ); + } + + NotificationsManager.info("Making API Call"); + setIsModalVisible(true); + + const prepared = prepareFormValues(formValues, { + keyOwner, + userID, + loggingSettings, + disabledCallbacks, + autoRotationEnabled, + rotationInterval, + modelAliases, + }); + + let response; + if (keyOwner === "service_account") { + response = await keyCreateServiceAccountCall(accessToken, prepared); + } else { + response = await keyCreateCall(accessToken, userID, prepared); + } + + // TODO: change logic to trigger API call in parent component + addKey(response); + + setApiKey(response["key"]); + setSoftBudget(response["soft_budget"]); + NotificationsManager.success("API Key Created"); + form.resetFields(); + localStorage.removeItem("userData" + userID); + } catch (error) { + console.log("error in create key:", error); + NotificationsManager.fromBackend(`Error creating the key: ${error}`); + } + }; + + useEffect(() => { + form.setFieldValue("models", []); + }, [selectedCreateKeyTeam, accessToken, userID, userRole, form]); + + return ( +
+ +
+ + + {/* Show message when team selection is required */} + {isFormDisabled && ( +
+ + Please select a team to continue configuring your API key. If you do not see any teams, please contact + your Proxy Admin to either provide you with access to models or to add you to a team. + +
+ )} + + {/* Section 2: Key Details */} + {!isFormDisabled && ( + + )} + + {/* Section 3: Optional Settings */} + {!isFormDisabled && ( + + )} + +
+ + Create Key + +
+ +
+ {apiKey && ( + + )} +
+ ); +}; + +export default CreateKeyModal; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/index.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/index.ts new file mode 100644 index 0000000000..ca3c4a663b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/index.ts @@ -0,0 +1,5 @@ +export * from "./useGuardrailsAndPrompts"; +export * from "./useMcpAccessGroups"; +export * from "./useUserModels"; +export * from "./useTeamModels"; +export * from "./useUserSearch"; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts new file mode 100644 index 0000000000..d092219f70 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; +import { fetchGuardrails, fetchPrompts } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +export const useGuardrailsAndPrompts = () => { + const [guardrails, setGuardrails] = useState([]); + const [prompts, setPrompts] = useState([]); + const { accessToken } = useAuthorized(); + + useEffect(() => { + if (!accessToken) { + setGuardrails([]); + setPrompts([]); + return; + } + (async () => { + const [g, p] = await Promise.all([fetchGuardrails(accessToken), fetchPrompts(accessToken)]); + setGuardrails(g || []); + setPrompts(p || []); + })(); + }, [accessToken]); + + return { guardrails, prompts }; +}; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts new file mode 100644 index 0000000000..8313f6e8d6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; +import { getMCPAccessGroups } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +export const useMcpAccessGroups = () => { + const [mcpAccessGroups, setMcpAccessGroups] = useState([]); + const { accessToken } = useAuthorized(); + + useEffect(() => { + if (!accessToken) { + setMcpAccessGroups([]); + return; + } + (async () => { + const groups = await getMCPAccessGroups(accessToken); + setMcpAccessGroups(groups || []); + })(); + }, [accessToken]); + + return mcpAccessGroups; +}; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts new file mode 100644 index 0000000000..c28ef0ecdb --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts @@ -0,0 +1,26 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Team } from "@/components/key_team_helpers/key_list"; +import { fetchTeamModels } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +export const useTeamModels = (selectedTeam: Team | null) => { + const { userId: userID, userRole, accessToken } = useAuthorized(); + const [modelsToPick, setModelsToPick] = useState([]); + + // turn array into a stable dep so it only re-runs when contents change + const selectedTeamModelsKey = useMemo(() => (selectedTeam?.models ?? []).join("|"), [selectedTeam?.models]); + + useEffect(() => { + if (!userID || !userRole || !accessToken) { + setModelsToPick([]); + return; + } + (async () => { + const fetched = await fetchTeamModels(userID, userRole, accessToken, selectedTeam?.team_id ?? null); + const union = Array.from(new Set([...(selectedTeam?.models ?? []), ...(fetched || [])])); + setModelsToPick(union); + })(); + }, [userID, userRole, accessToken, selectedTeam?.team_id, selectedTeamModelsKey, selectedTeam?.models]); + + return modelsToPick; +}; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts new file mode 100644 index 0000000000..ddba6879cc --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; +import { getUserModelNames } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +export const useUserModels = () => { + const { userId: userID, userRole, accessToken } = useAuthorized(); + const [userModels, setUserModels] = useState([]); + + useEffect(() => { + if (!userID || !userRole || !accessToken) { + setUserModels([]); + return; + } + (async () => { + const modelNames = await getUserModelNames(userID, userRole, accessToken); + setUserModels(modelNames || []); + })(); + }, [userID, userRole, accessToken]); + + return userModels; +}; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts new file mode 100644 index 0000000000..782beccf48 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts @@ -0,0 +1,41 @@ +import { useEffect, useMemo, useState } from "react"; +import { debounce } from "lodash"; +import { searchUserOptionsByEmail } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +type User = { user_id: string; user_email: string; role?: string }; +export interface UserOption { + label: string; + value: string; + user: User; +} + +export const useUserSearch = (delay = 300) => { + const { accessToken } = useAuthorized(); + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + + const doSearch = async (raw: string) => { + const q = raw?.trim(); + if (!q || !accessToken) { + setOptions([]); + setLoading(false); + return; + } + setLoading(true); + try { + const res = await searchUserOptionsByEmail(accessToken, q); + setOptions(res || []); + } finally { + setLoading(false); + } + }; + + const onSearch = useMemo(() => debounce(doSearch, delay), [accessToken, delay]); + + useEffect(() => { + return () => onSearch.cancel(); + }, [onSearch]); + + return { options, loading, onSearch }; +}; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/networking.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/networking.ts new file mode 100644 index 0000000000..6cb4e2f7f0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/networking.ts @@ -0,0 +1,84 @@ +import { + fetchMCPAccessGroups, + getGuardrailsList, + getPromptsList, + modelAvailableCall, + User, + userFilterUICall, +} from "@/components/networking"; +import { ModelAvailableResponse, UserOption } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types"; + +export const fetchGuardrails = async (accessToken: string) => { + try { + const response = await getGuardrailsList(accessToken); + return response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name); + } catch (error) { + console.error("Failed to fetch guardrails:", error); + return []; + } +}; + +export const fetchPrompts = async (accessToken: string) => { + try { + const response = await getPromptsList(accessToken); + return response.prompts.map((prompt) => prompt.prompt_id); + } catch (error) { + console.error("Failed to fetch prompts:", error); + return []; + } +}; + +export const searchUserOptionsByEmail = async (accessToken: string, emailQuery: string): Promise => { + const params = new URLSearchParams(); + params.append("user_email", emailQuery); + + const response = await userFilterUICall(accessToken, params); + const users: User[] = response; + + return users.map((user) => ({ + label: `${user.user_email} (${user.user_id})`, + value: user.user_id, + user, + })); +}; + +export const getUserModelNames = async (userID: string, userRole: string, accessToken: string): Promise => { + const res: ModelAvailableResponse = await modelAvailableCall(accessToken, userID, userRole); + return (res?.data ?? []).map((m) => m.id); +}; + +export const fetchTeamModels = async ( + userID: string, + userRole: string, + accessToken: string, + teamID: string | null, +): Promise => { + try { + if (userID === null || userRole === null) { + return []; + } + + if (accessToken !== null) { + const model_available = await modelAvailableCall(accessToken, userID, userRole, true, teamID, true); + let available_model_names = model_available["data"].map((element: { id: string }) => element.id); + console.log("available_model_names:", available_model_names); + return available_model_names; + } + return []; + } catch (error) { + console.error("Error fetching user models:", error); + return []; + } +}; + +export const getMCPAccessGroups = async (accessToken: string): Promise => { + try { + if (accessToken == null) { + return []; + } + return await fetchMCPAccessGroups(accessToken); + } catch (error) { + console.error("Failed to fetch MCP access groups:", error); + return []; + } +}; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/types.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/types.ts new file mode 100644 index 0000000000..4d48df82d4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/types.ts @@ -0,0 +1,17 @@ +export interface User { + user_id: string; + user_email: string; + role?: string; +} + +export interface UserOption { + label: string; + value: string; + user: User; +} + +export type Model = { id: string }; + +export type ModelAvailableResponse = { data: Model[] }; + +export type ModelAliases = { [key: string]: string }; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/utils.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/utils.ts new file mode 100644 index 0000000000..066770cf46 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/utils.ts @@ -0,0 +1,187 @@ +import { mapDisplayToInternalNames } from "@/components/callback_info_helpers"; +import { ModelAliases } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types"; + +export const getPredefinedTags = (data: any[] | null) => { + let allTags = []; + + console.log("data:", JSON.stringify(data)); + + if (data) { + for (let key of data) { + if (key["metadata"] && key["metadata"]["tags"]) { + allTags.push(...key["metadata"]["tags"]); + } + } + } + + const uniqueTags = Array.from(new Set(allTags)).map((tag) => ({ + value: tag, + label: tag, + })); + + console.log("uniqueTags:", uniqueTags); + return uniqueTags; +}; + +type AnyObj = Record; + +type PrepareOptions = { + keyOwner: string; + userID: string; + loggingSettings: any[]; + disabledCallbacks: string[]; + autoRotationEnabled: boolean; + rotationInterval: string; + modelAliases: ModelAliases; +}; + +export function safeParseMetadata(metadataStr: string | undefined): AnyObj { + try { + return JSON.parse(metadataStr || "{}"); + } catch (error) { + console.error("Error parsing metadata:", error); + return {}; + } +} + +export function withOwnerAssignments( + values: AnyObj, + { keyOwner, userID }: Pick, +): AnyObj { + // If owned by "you", set user_id + const base = keyOwner === "you" ? { ...values, user_id: userID } : { ...values }; + return base; +} + +export function enrichMetadata( + metadata: AnyObj, + values: AnyObj, + { + keyOwner, + loggingSettings, + disabledCallbacks, + }: Pick, +): AnyObj { + let next = { ...metadata }; + + // If it's a service account, add the service_account_id to the metadata + if (keyOwner === "service_account") { + next = { ...next, service_account_id: values.key_alias }; + } + + // Add logging settings to the metadata + if (Array.isArray(loggingSettings) && loggingSettings.length > 0) { + next = { ...next, logging: loggingSettings.filter((config: any) => config?.callback_name) }; + } + + // Add disabled callbacks to the metadata + if (Array.isArray(disabledCallbacks) && disabledCallbacks.length > 0) { + const mappedDisabledCallbacks = mapDisplayToInternalNames(disabledCallbacks); + next = { ...next, litellm_disabled_callbacks: mappedDisabledCallbacks }; + } + + return next; +} + +export function withAutoRotation( + values: AnyObj, + { autoRotationEnabled, rotationInterval }: Pick, +): AnyObj { + if (!autoRotationEnabled) return { ...values }; + return { ...values, auto_rotate: true, rotation_interval: rotationInterval }; +} + +export function withDurationNoop(values: AnyObj): AnyObj { + // Preserve the original no-op logic: if duration exists, set it to itself + if (values.duration) { + return { ...values, duration: values.duration }; + } + return { ...values }; +} + +export function mergeObjectPermission(values: AnyObj, patch: AnyObj): AnyObj { + const object_permission = { ...(values.object_permission || {}), ...patch }; + return { ...values, object_permission }; +} + +export function withVectorStores(values: AnyObj): AnyObj { + const { allowed_vector_store_ids, ...rest } = values; + if (Array.isArray(allowed_vector_store_ids) && allowed_vector_store_ids.length > 0) { + const patched = mergeObjectPermission(rest, { vector_stores: allowed_vector_store_ids }); + return patched; // original field removed by destructuring + } + return { ...values }; +} + +export function withMcpServersAndGroups(values: AnyObj): AnyObj { + const { allowed_mcp_servers_and_groups, ...rest } = values; + const servers = allowed_mcp_servers_and_groups?.servers; + const accessGroups = allowed_mcp_servers_and_groups?.accessGroups; + + const hasServers = Array.isArray(servers) && servers.length > 0; + const hasAccessGroups = Array.isArray(accessGroups) && accessGroups.length > 0; + + if (!hasServers && !hasAccessGroups) return { ...values }; + + let patched = { ...rest }; + if (hasServers) patched = mergeObjectPermission(patched, { mcp_servers: servers }); + if (hasAccessGroups) patched = mergeObjectPermission(patched, { mcp_access_groups: accessGroups }); + + // original field removed by destructuring + return patched; +} + +export function withMcpAccessGroups(values: AnyObj): AnyObj { + const { allowed_mcp_access_groups, ...rest } = values; + if (Array.isArray(allowed_mcp_access_groups) && allowed_mcp_access_groups.length > 0) { + const patched = mergeObjectPermission(rest, { mcp_access_groups: allowed_mcp_access_groups }); + return patched; // original field removed by destructuring + } + return { ...values }; +} + +function withAliases(values: AnyObj, modelAliases: ModelAliases): AnyObj { + if (modelAliases && Object.keys(modelAliases).length > 0) { + return { ...values, aliases: JSON.stringify(modelAliases) }; + } + return { ...values }; +} + +function withMetadataString(values: AnyObj, metadata: AnyObj): AnyObj { + return { ...values, metadata: JSON.stringify(metadata) }; +} + +export function prepareFormValues(initialValues: AnyObj, opts: PrepareOptions): AnyObj { + // Step 1: assign user id if needed + let values = withOwnerAssignments(initialValues, { keyOwner: opts.keyOwner, userID: opts.userID }); + + // Step 2: metadata parse & enrichment + const parsedMetadata = safeParseMetadata(values.metadata); + const enrichedMetadata = enrichMetadata(parsedMetadata, values, { + keyOwner: opts.keyOwner, + loggingSettings: opts.loggingSettings, + disabledCallbacks: opts.disabledCallbacks, + }); + + // Step 3: auto-rotation + values = withAutoRotation(values, { + autoRotationEnabled: opts.autoRotationEnabled, + rotationInterval: opts.rotationInterval, + }); + + // Step 4: duration (no-op preservation) + values = withDurationNoop(values); + + // Step 5: commit metadata string + values = withMetadataString(values, enrichedMetadata); + + // Step 6: object_permission transformations + values = withVectorStores(values); + values = withMcpServersAndGroups(values); + values = withMcpAccessGroups(values); + + // Step 7: aliases + values = withAliases(values, opts.modelAliases); + + return values; +} diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/KeyInfoView.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/KeyInfoView.tsx new file mode 100644 index 0000000000..1c7185f1dc --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/KeyInfoView.tsx @@ -0,0 +1,697 @@ +import React, { useEffect, useState } from "react"; +import { + Card, + Text, + Button, + Grid, + Tab, + TabList, + TabGroup, + TabPanel, + TabPanels, + Title, + Badge, +} from "@tremor/react"; +import { ArrowLeftIcon, TrashIcon, RefreshIcon } from "@heroicons/react/outline"; +import { Form, Tooltip, Button as AntdButton } from "antd"; +import { copyToClipboard as utilCopyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils"; +import { CopyIcon, CheckIcon } from "lucide-react"; +import { KeyResponse } from "@/components/key_team_helpers/key_list" +import { mapDisplayToInternalNames, mapInternalToDisplayNames } from "@/components/callback_info_helpers" +import NotificationManager from "@/components/molecules/notifications_manager" +import { keyDeleteCall, keyUpdateCall } from "@/components/networking" +import { parseErrorMessage } from "@/components/shared/errorUtils" +import { rolesWithWriteAccess } from "@/utils/roles" +import { RegenerateKeyModal } from "@/components/organisms/regenerate_key_modal" +import ObjectPermissionsView from "@/components/object_permissions_view" +import LoggingSettingsView from "@/components/logging_settings_view" +import { extractLoggingSettings, formatMetadataForDisplay } from "@/components/key_info_utils" +import AutoRotationView from "@/components/common_components/AutoRotationView" +import { KeyEditView } from "@/components/templates/key_edit_view" + + +interface KeyInfoViewProps { + keyId: string; + onClose: () => void; + keyData: KeyResponse | undefined; + onKeyDataUpdate?: (data: Partial) => void; + onDelete?: () => void; + accessToken: string | null; + userID: string | null; + userRole: string | null; + teams: any[] | null; + premiumUser: boolean; + setAccessToken?: (token: string) => void; + backButtonText?: string; +} + +const KeyInfoView = ({ + keyId, + onClose, + keyData, + accessToken, + userID, + userRole, + teams, + onKeyDataUpdate, + onDelete, + premiumUser, + setAccessToken, + backButtonText = "Back to Keys", + }: KeyInfoViewProps) => { + const [isEditing, setIsEditing] = useState(false); + const [form] = Form.useForm(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [deleteConfirmInput, setDeleteConfirmInput] = useState(""); + const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false); + const [copiedStates, setCopiedStates] = useState>({}); + + // Add local state to maintain key data and track regeneration + const [currentKeyData, setCurrentKeyData] = useState(keyData); + const [lastRegeneratedAt, setLastRegeneratedAt] = useState(null); + const [isRecentlyRegenerated, setIsRecentlyRegenerated] = useState(false); + + // Update local state when keyData prop changes (but don't reset to undefined) + useEffect(() => { + if (keyData) { + setCurrentKeyData(keyData); + } + }, [keyData]); + + // Reset recent regeneration indicator after 5 seconds + useEffect(() => { + if (isRecentlyRegenerated) { + const timer = setTimeout(() => { + setIsRecentlyRegenerated(false); + }, 5000); + return () => clearTimeout(timer); + } + }, [isRecentlyRegenerated]); + + // Use currentKeyData instead of keyData throughout the component + if (!currentKeyData) { + return ( +
+ + Key not found +
+ ); + } + + const handleKeyUpdate = async (formValues: Record) => { + try { + if (!accessToken) return; + + const currentKey = formValues.token; + formValues.key = currentKey; + + // Guard premium features + if (!premiumUser) { + delete formValues.guardrails; + delete formValues.prompts; + } + + // Handle object_permission updates + if (formValues.vector_stores !== undefined) { + formValues.object_permission = { + ...currentKeyData.object_permission, + vector_stores: formValues.vector_stores || [], + }; + // Remove vector_stores from the top level as it should be in object_permission + delete formValues.vector_stores; + } + + if (formValues.mcp_servers_and_groups !== undefined) { + const { servers, accessGroups } = formValues.mcp_servers_and_groups || { servers: [], accessGroups: [] }; + formValues.object_permission = { + ...currentKeyData.object_permission, + mcp_servers: servers || [], + mcp_access_groups: accessGroups || [], + }; + // Remove mcp_servers_and_groups from the top level as it should be in object_permission + delete formValues.mcp_servers_and_groups; + } + + // Convert metadata back to an object if it exists and is a string + if (formValues.metadata && typeof formValues.metadata === "string") { + try { + const parsedMetadata = JSON.parse(formValues.metadata); + formValues.metadata = { + ...parsedMetadata, + ...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}), + ...(formValues.logging_settings ? { logging: formValues.logging_settings } : {}), + ...(formValues.disabled_callbacks?.length > 0 + ? { + litellm_disabled_callbacks: mapDisplayToInternalNames(formValues.disabled_callbacks), + } + : {}), + }; + } catch (error) { + console.error("Error parsing metadata JSON:", error); + NotificationManager.error("Invalid metadata JSON"); + return; + } + } else { + formValues.metadata = { + ...(formValues.metadata || {}), + ...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}), + ...(formValues.logging_settings ? { logging: formValues.logging_settings } : {}), + ...(formValues.disabled_callbacks?.length > 0 + ? { + litellm_disabled_callbacks: mapDisplayToInternalNames(formValues.disabled_callbacks), + } + : {}), + }; + } + + delete formValues.logging_settings; + + // Convert budget_duration to API format + if (formValues.budget_duration) { + const durationMap: Record = { + daily: "24h", + weekly: "7d", + monthly: "30d", + }; + formValues.budget_duration = durationMap[formValues.budget_duration]; + } + + const newKeyValues = await keyUpdateCall(accessToken, formValues); + + // Update local state + setCurrentKeyData((prevData) => (prevData ? { ...prevData, ...newKeyValues } : undefined)); + + if (onKeyDataUpdate) { + onKeyDataUpdate(newKeyValues); + } + NotificationManager.success("Key updated successfully"); + setIsEditing(false); + // Refresh key data here if needed + } catch (error) { + NotificationManager.fromBackend(parseErrorMessage(error)); + console.error("Error updating key:", error); + } + }; + + const handleDelete = async () => { + try { + if (!accessToken) return; + await keyDeleteCall(accessToken as string, currentKeyData.token || currentKeyData.token_id); + NotificationManager.success("Key deleted successfully"); + if (onDelete) { + onDelete(); + } + onClose(); + } catch (error) { + console.error("Error deleting the key:", error); + NotificationManager.fromBackend(error); + } + // Reset the confirmation input + setDeleteConfirmInput(""); + }; + + const copyToClipboard = async (text: string, key: string) => { + const success = await utilCopyToClipboard(text); + if (success) { + setCopiedStates((prev) => ({ ...prev, [key]: true })); + setTimeout(() => { + setCopiedStates((prev) => ({ ...prev, [key]: false })); + }, 2000); + } + }; + + const handleRegenerateKeyUpdate = (updatedKeyData: Partial) => { + // Update local state immediately with ALL the new data + setCurrentKeyData((prevData) => { + if (!prevData) return undefined; + const newData = { + ...prevData, + ...updatedKeyData, // This should include the new token (key-id) + // Update the created_at to show when it was regenerated + created_at: new Date().toLocaleString(), + }; + return newData; + }); + + // Track regeneration timestamp + setLastRegeneratedAt(new Date()); + setIsRecentlyRegenerated(true); + + if (onKeyDataUpdate) { + onKeyDataUpdate({ + ...updatedKeyData, + created_at: new Date().toLocaleString(), + }); + } + }; + + // Update the formatTimestamp function to use the desired date format + const formatTimestamp = (timestamp: string | Date) => { + const date = new Date(timestamp); + const dateStr = date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + const timeStr = date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + return `${dateStr} at ${timeStr}`; + }; + + return ( +
+
+
+ + {currentKeyData.key_alias || "API Key"} + +
+
+ Key ID + {currentKeyData.token_id || currentKeyData.token} +
+ : } + onClick={() => copyToClipboard(currentKeyData.token_id || currentKeyData.token, "key-id")} + className={`ml-2 transition-all duration-200${ + copiedStates["key-id"] + ? "text-green-600 bg-green-50 border-green-200" + : "text-gray-500 hover:text-gray-700 hover:bg-gray-100" + }`} + /> +
+ + {/* Add timestamp and regeneration indicator */} +
+ + {currentKeyData.updated_at && currentKeyData.updated_at !== currentKeyData.created_at + ? `Updated: ${formatTimestamp(currentKeyData.updated_at)}` + : `Created: ${formatTimestamp(currentKeyData.created_at)}`} + + + {isRecentlyRegenerated && ( + + Recently Regenerated + + )} + + {lastRegeneratedAt && ( + + Regenerated + + )} +
+
+ {userRole && rolesWithWriteAccess.includes(userRole) && ( +
+ + + + + + +
+ )} +
+ + {/* Add RegenerateKeyModal */} + setIsRegenerateModalOpen(false)} + accessToken={accessToken} + premiumUser={premiumUser} + setAccessToken={setAccessToken} + onKeyUpdate={handleRegenerateKeyUpdate} + /> + + {/* Delete Confirmation Modal */} + {isDeleteModalOpen && + (() => { + const keyName = currentKeyData?.key_alias || currentKeyData?.token_id || "API Key"; + const isValid = deleteConfirmInput === keyName; + return ( +
+
+
+
+

Delete Key

+ +
+
+
+
+ + + +
+
+

+ Warning: You are about to delete this API key. +

+

+ This action is irreversible and will immediately revoke access for any applications using this + key. +

+
+
+

Are you sure you want to delete this API key?

+
+ + setDeleteConfirmInput(e.target.value)} + placeholder="Enter key name exactly" + className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-base" + autoFocus + /> +
+
+
+
+ + +
+
+
+ ); + })()} + + + + Overview + Settings + + + + {/* Overview Panel */} + + + + Spend +
+ ${formatNumberWithCommas(currentKeyData.spend, 4)} + + of{" "} + {currentKeyData.max_budget !== null + ? `$${formatNumberWithCommas(currentKeyData.max_budget)}` + : "Unlimited"} + +
+
+ + + Rate Limits +
+ TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"} + RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"} +
+
+ + + Models +
+ {currentKeyData.models && currentKeyData.models.length > 0 ? ( + currentKeyData.models.map((model, index) => ( + + {model} + + )) + ) : ( + No models specified + )} +
+
+ + + + + + + + +
+
+ + {/* Settings Panel */} + + +
+ Key Settings + {!isEditing && userRole && rolesWithWriteAccess.includes(userRole) && ( + + )} +
+ + {isEditing ? ( + setIsEditing(false)} + onSubmit={handleKeyUpdate} + teams={teams} + accessToken={accessToken} + userID={userID} + userRole={userRole} + premiumUser={premiumUser} + /> + ) : ( +
+
+ Key ID + {currentKeyData.token_id || currentKeyData.token} +
+ +
+ Key Alias + {currentKeyData.key_alias || "Not Set"} +
+ +
+ Secret Key + {currentKeyData.key_name} +
+ +
+ Team ID + {currentKeyData.team_id || "Not Set"} +
+ +
+ Organization + {currentKeyData.organization_id || "Not Set"} +
+ +
+ Created + {formatTimestamp(currentKeyData.created_at)} +
+ + {lastRegeneratedAt && ( +
+ Last Regenerated +
+ {formatTimestamp(lastRegeneratedAt)} + + Recent + +
+
+ )} + +
+ Expires + {currentKeyData.expires ? formatTimestamp(currentKeyData.expires) : "Never"} +
+ + + +
+ Spend + ${formatNumberWithCommas(currentKeyData.spend, 4)} USD +
+ +
+ Budget + + {currentKeyData.max_budget !== null + ? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}` + : "Unlimited"} + +
+ +
+ Prompts + + {Array.isArray(currentKeyData.metadata?.prompts) && currentKeyData.metadata.prompts.length > 0 + ? currentKeyData.metadata.prompts.map((prompt, index) => ( + + {prompt} + + )) + : "No prompts specified"} + +
+ +
+ Models +
+ {currentKeyData.models && currentKeyData.models.length > 0 ? ( + currentKeyData.models.map((model, index) => ( + + {model} + + )) + ) : ( + No models specified + )} +
+
+ +
+ Rate Limits + TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"} + RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"} + + Max Parallel Requests:{" "} + {currentKeyData.max_parallel_requests !== null + ? currentKeyData.max_parallel_requests + : "Unlimited"} + + + Model TPM Limits:{" "} + {currentKeyData.metadata?.model_tpm_limit + ? JSON.stringify(currentKeyData.metadata.model_tpm_limit) + : "Unlimited"} + + + Model RPM Limits:{" "} + {currentKeyData.metadata?.model_rpm_limit + ? JSON.stringify(currentKeyData.metadata.model_rpm_limit) + : "Unlimited"} + +
+ +
+ Metadata +
+                      {formatMetadataForDisplay(currentKeyData.metadata)}
+                    
+
+ + + + +
+ )} +
+
+
+
+
+ ); +} + +export default KeyInfoView; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/SaveKeyModal.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/SaveKeyModal.tsx new file mode 100644 index 0000000000..bd0d425c46 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/SaveKeyModal.tsx @@ -0,0 +1,58 @@ +import { Button, Col, Grid, Text, Title } from "@tremor/react"; +import { CopyToClipboard } from "react-copy-to-clipboard"; +import { Modal } from "antd"; +import React from "react"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +export interface SaveKeyModalProps { + apiKey: string; + isModalVisible: boolean; + handleOk: () => void; + handleCancel: () => void; +} + +const SaveKeyModal = ({ apiKey, isModalVisible, handleOk, handleCancel }: SaveKeyModalProps) => { + const handleCopy = () => { + NotificationsManager.success("API Key copied to clipboard"); + }; + + return ( + + + Save your Key + +

+ Please save this secret key somewhere safe and accessible. For security reasons,{" "} + you will not be able to view it again through your LiteLLM account. If you lose this secret key, you + will need to generate a new one. +

+ + + {apiKey != null ? ( +
+ API Key: +
+
{apiKey}
+
+ + + + +
+ ) : ( + Key being created, this might take 30s + )} + +
+
+ ); +}; + +export default SaveKeyModal; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx new file mode 100644 index 0000000000..5c32a736ae --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx @@ -0,0 +1,666 @@ +// TODO: refactor +"use client"; + +import React, { useEffect, useState } from "react"; +import { ColumnDef } from "@tanstack/react-table"; +import { Button } from "@tremor/react"; +import { Tooltip } from "antd"; +import { updateExistingKeys } from "@/utils/dataUtils"; +import { flexRender, getCoreRowModel, getSortedRowModel, SortingState, useReactTable } from "@tanstack/react-table"; +import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell, Icon } from "@tremor/react"; +import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; +import { Badge, Text } from "@tremor/react"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { KeyResponse, Team } from "@/components/key_team_helpers/key_list"; +import { Organization, userListCall } from "@/components/networking"; +import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; +import FilterComponent, { FilterOption } from "@/components/molecules/filter"; +import KeyInfoView from "@/components/templates/key_info_view"; +import { useFilterLogic } from "@/components/key_team_helpers/filter_logic"; +import useTeams from "@/app/(console)/virtual-keys/hooks/useTeams"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +interface AllKeysTableProps { + keys: KeyResponse[]; + setKeys: (keys: KeyResponse[] | ((prev: KeyResponse[]) => KeyResponse[])) => void; + isLoading?: boolean; + pagination: { + currentPage: number; + totalPages: number; + totalCount: number; + }; + onPageChange: (page: number) => void; + pageSize?: number; + organizations: Organization[] | null; + refresh?: () => void; + onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void; + currentSort?: { + sortBy: string; + sortOrder: "asc" | "desc"; + }; + setAccessToken?: (token: string) => void; +} + +interface UserResponse { + user_id: string; + user_email: string; + user_role: string; +} + +const VirtualKeysTable = ({ + keys, + setKeys, + isLoading = false, + pagination, + onPageChange, + pageSize = 50, + organizations, + refresh, + onSortChange, + currentSort, + setAccessToken, +}: AllKeysTableProps) => { + const { userId: userID, userRole, accessToken, premiumUser } = useAuthorized(); + const teams = useTeams(); + const [selectedKeyId, setSelectedKeyId] = useState(null); + const [userList, setUserList] = useState([]); + const [sorting, setSorting] = React.useState(() => { + if (currentSort) { + return [ + { + id: currentSort.sortBy, + desc: currentSort.sortOrder === "desc", + }, + ]; + } + return [ + { + id: "created_at", + desc: true, + }, + ]; + }); + const [expandedAccordions, setExpandedAccordions] = useState>({}); + + // Use the filter logic hook + + const { filters, filteredKeys, allKeyAliases, allTeams, allOrganizations, handleFilterChange, handleFilterReset } = + useFilterLogic({ + keys, + teams, + organizations, + accessToken, + }); + + useEffect(() => { + if (accessToken) { + const user_IDs = keys.map((key) => key.user_id).filter((id) => id !== null); + const fetchUserList = async () => { + const userListData = await userListCall(accessToken, user_IDs, 1, 100); + setUserList(userListData.users); + }; + fetchUserList(); + } + }, [accessToken, keys]); + + // Add a useEffect to call refresh when a key is created + useEffect(() => { + if (refresh) { + const handleStorageChange = () => { + refresh(); + }; + + // Listen for storage events that might indicate a key was created + window.addEventListener("storage", handleStorageChange); + + return () => { + window.removeEventListener("storage", handleStorageChange); + }; + } + }, [refresh]); + + const columns: ColumnDef[] = [ + { + id: "expander", + header: () => null, + cell: ({ row }) => + row.getCanExpand() ? ( + + ) : null, + }, + { + id: "token", + accessorKey: "token", + header: "Key ID", + cell: (info) => ( +
+ + + +
+ ), + }, + { + id: "key_alias", + accessorKey: "key_alias", + header: "Key Alias", + cell: (info) => { + const value = info.getValue() as string; + return ( + {value ? (value.length > 20 ? `${value.slice(0, 20)}...` : value) : "-"} + ); + }, + }, + { + id: "key_name", + accessorKey: "key_name", + header: "Secret Key", + cell: (info) => {info.getValue() as string}, + }, + { + id: "team_alias", + accessorKey: "team_id", + header: "Team Alias", + cell: ({ row, getValue }) => { + const teamId = getValue() as string; + const team = teams?.find((t) => t.team_id === teamId); + return team?.team_alias || "Unknown"; + }, + }, + { + id: "team_id", + accessorKey: "team_id", + header: "Team ID", + cell: (info) => ( + + {info.getValue() ? `${(info.getValue() as string).slice(0, 7)}...` : "-"} + + ), + }, + { + id: "organization_id", + accessorKey: "organization_id", + header: "Organization ID", + cell: (info) => (info.getValue() ? info.renderValue() : "-"), + }, + { + id: "user_email", + accessorKey: "user_id", + header: "User Email", + cell: (info) => { + const userId = info.getValue() as string; + const user = userList.find((u) => u.user_id === userId); + return user?.user_email ? ( + + {user?.user_email.slice(0, 20)}... + + ) : ( + "-" + ); + }, + }, + { + id: "user_id", + accessorKey: "user_id", + header: "User ID", + cell: (info) => { + const userId = info.getValue() as string | null; + if (userId && userId.length > 15) { + return ( + + {userId.slice(0, 7)}... + + ); + } + return userId ? userId : "-"; + }, + }, + { + id: "created_at", + accessorKey: "created_at", + header: "Created At", + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleDateString() : "-"; + }, + }, + { + id: "created_by", + accessorKey: "created_by", + header: "Created By", + cell: (info) => { + const value = info.getValue() as string | null; + if (value && value.length > 15) { + return ( + + {value.slice(0, 7)}... + + ); + } + return value; + }, + }, + { + id: "updated_at", + accessorKey: "updated_at", + header: "Updated At", + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleDateString() : "Never"; + }, + }, + { + id: "expires", + accessorKey: "expires", + header: "Expires", + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleDateString() : "Never"; + }, + }, + { + id: "spend", + accessorKey: "spend", + header: "Spend (USD)", + cell: (info) => formatNumberWithCommas(info.getValue() as number, 4), + }, + { + id: "max_budget", + accessorKey: "max_budget", + header: "Budget (USD)", + 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", + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleString() : "Never"; + }, + }, + { + id: "models", + accessorKey: "models", + header: "Models", + 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", + 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"}
+
+ ); + }, + }, + ]; + + const filterOptions: FilterOption[] = [ + { + name: "Team ID", + label: "Team ID", + isSearchable: true, + searchFn: async (searchText: string) => { + if (!allTeams || allTeams.length === 0) return []; + + const filteredTeams = allTeams.filter( + (team) => + team.team_id.toLowerCase().includes(searchText.toLowerCase()) || + (team.team_alias && team.team_alias.toLowerCase().includes(searchText.toLowerCase())), + ); + + return filteredTeams.map((team) => ({ + label: `${team.team_alias || team.team_id} (${team.team_id})`, + value: team.team_id, + })); + }, + }, + { + name: "Organization ID", + label: "Organization ID", + isSearchable: true, + searchFn: async (searchText: string) => { + if (!allOrganizations || allOrganizations.length === 0) return []; + + const filteredOrgs = allOrganizations.filter( + (org) => org.organization_id?.toLowerCase().includes(searchText.toLowerCase()) ?? false, + ); + + return filteredOrgs + .filter((org) => org.organization_id !== null && org.organization_id !== undefined) + .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) => { + const filteredKeyAliases = allKeyAliases.filter((key) => { + return key.toLowerCase().includes(searchText.toLowerCase()); + }); + + return filteredKeyAliases.map((key) => { + return { + label: key, + value: key, + }; + }); + }, + }, + { + name: "User ID", + label: "User ID", + isSearchable: false, + }, + { + name: "Key Hash", + label: "Key Hash", + isSearchable: false, + }, + ]; + + console.log(`keys: ${JSON.stringify(keys)}`); + + const table = useReactTable({ + data: filteredKeys, + columns: columns.filter((col) => col.id !== "expander"), + state: { + sorting, + }, + onSortingChange: (updaterOrValue) => { + const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; + console.log(`newSorting: ${JSON.stringify(newSorting)}`); + setSorting(newSorting); + if (newSorting && newSorting.length > 0) { + const sortState = newSorting[0]; + const sortBy = sortState.id; + const sortOrder = sortState.desc ? "desc" : "asc"; + console.log(`sortBy: ${sortBy}, sortOrder: ${sortOrder}`); + handleFilterChange({ + ...filters, + "Sort By": sortBy, + "Sort Order": sortOrder, + }); + onSortChange?.(sortBy, sortOrder); + } + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + enableSorting: true, + manualSorting: false, + }); + + // Update local sorting state when currentSort prop changes + React.useEffect(() => { + if (currentSort) { + setSorting([ + { + id: currentSort.sortBy, + desc: currentSort.sortOrder === "desc", + }, + ]); + } + }, [currentSort]); + + return ( +
+ {selectedKeyId ? ( + setSelectedKeyId(null)} + keyData={filteredKeys.find((k) => k.token === selectedKeyId)} + onKeyDataUpdate={(updatedKeyData) => { + setKeys((keys) => + keys.map((key) => { + if (key.token === updatedKeyData.token) { + return updateExistingKeys(key, updatedKeyData); + } + return key; + }), + ); + if (refresh) refresh(); // Minimal fix: refresh the full key list after an update + }} + onDelete={() => { + setKeys((keys) => keys.filter((key) => key.token !== selectedKeyId)); + if (refresh) refresh(); // Minimal fix: refresh the full key list after a delete + }} + accessToken={accessToken} + userID={userID} + userRole={userRole} + teams={allTeams} + premiumUser={premiumUser} + setAccessToken={setAccessToken} + /> + ) : ( +
+
+ +
+ +
+ + Showing{" "} + {isLoading + ? "..." + : `${(pagination.currentPage - 1) * pageSize + 1} - ${Math.min(pagination.currentPage * pageSize, pagination.totalCount)}`}{" "} + of {isLoading ? "..." : pagination.totalCount} results + + +
+ + Page {isLoading ? "..." : pagination.currentPage} of {isLoading ? "..." : pagination.totalPages} + + + + + +
+
+
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + +
+
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+ {header.id !== "actions" && ( +
+ {header.column.getIsSorted() ? ( + { + asc: , + desc: , + }[header.column.getIsSorted() as string] + ) : ( + + )} +
+ )} +
+
+ ))} +
+ ))} +
+ + {isLoading ? ( + + +
+

🚅 Loading keys...

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

No keys found

+
+
+
+ )} +
+
+
+
+
+
+ )} +
+ ); +}; + +export default VirtualKeysTable; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts new file mode 100644 index 0000000000..ac174a554e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts @@ -0,0 +1,189 @@ +import { useCallback, useEffect, useState, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { debounce } from "lodash"; +import { KeyResponse, Team } from "@/components/key_team_helpers/key_list"; +import { keyListCall, Organization } from "@/components/networking"; +import { defaultPageSize } from "@/components/constants"; +import { fetchAllKeyAliases, fetchAllOrganizations, fetchAllTeams } from "@/components/key_team_helpers/filter_helpers"; + +export interface FilterState { + "Team ID": string; + "Organization ID": string; + "Key Alias": string; + [key: string]: string; + "User ID": string; + "Sort By": string; + "Sort Order": string; +} + +export function useFilterLogic({ + keys, + teams, + organizations, + accessToken, +}: { + keys: KeyResponse[]; + teams: Team[] | null; + organizations: Organization[] | null; + accessToken: string | null; +}) { + const defaultFilters: FilterState = { + "Team ID": "", + "Organization ID": "", + "Key Alias": "", + "User ID": "", + "Sort By": "created_at", + "Sort Order": "desc", + }; + const [filters, setFilters] = useState(defaultFilters); + const [allTeams, setAllTeams] = useState(teams || []); + const [allOrganizations, setAllOrganizations] = useState(organizations || []); + const [filteredKeys, setFilteredKeys] = useState(keys); + const lastSearchTimestamp = useRef(0); + const debouncedSearch = useCallback( + debounce(async (filters: FilterState) => { + if (!accessToken) { + return; + } + + const currentTimestamp = Date.now(); + lastSearchTimestamp.current = currentTimestamp; + + try { + // Make the API call using userListCall with all filter parameters + const data = await keyListCall( + accessToken, + filters["Organization ID"] || null, + filters["Team ID"] || null, + filters["Key Alias"] || null, + filters["User ID"] || null, + filters["Key Hash"] || null, + 1, // Reset to first page when searching + defaultPageSize, + filters["Sort By"] || null, + filters["Sort Order"] || null, + ); + + // Only update state if this is the most recent search + if (currentTimestamp === lastSearchTimestamp.current) { + if (data) { + setFilteredKeys(data.keys); + console.log("called from debouncedSearch filters:", JSON.stringify(filters)); + console.log("called from debouncedSearch data:", JSON.stringify(data)); + } + } + } catch (error) { + console.error("Error searching users:", error); + } + }, 300), + [accessToken], + ); + // Apply filters to keys whenever keys or filters change + useEffect(() => { + if (!keys) { + setFilteredKeys([]); + return; + } + + let result = [...keys]; + + // Apply Team ID filter + if (filters["Team ID"]) { + result = result.filter((key) => key.team_id === filters["Team ID"]); + } + + // Apply Organization ID filter + if (filters["Organization ID"]) { + result = result.filter((key) => key.organization_id === filters["Organization ID"]); + } + + setFilteredKeys(result); + }, [keys, filters]); + + // Fetch all data for filters when component mounts + useEffect(() => { + const loadAllFilterData = async () => { + // Load all teams - no organization filter needed here + const teamsData = await fetchAllTeams(accessToken); + if (teamsData.length > 0) { + setAllTeams(teamsData); + } + + // Load all organizations + const orgsData = await fetchAllOrganizations(accessToken); + if (orgsData.length > 0) { + setAllOrganizations(orgsData); + } + }; + + if (accessToken) { + loadAllFilterData(); + } + }, [accessToken]); + + const queryAllKeysQuery = useQuery({ + queryKey: ["allKeys"], + queryFn: async () => { + if (!accessToken) throw new Error("Access token required"); + return await fetchAllKeyAliases(accessToken); + }, + enabled: !!accessToken, + }); + const allKeyAliases = queryAllKeysQuery.data || []; + + // Update teams and organizations when props change + useEffect(() => { + if (teams && teams.length > 0) { + setAllTeams((prevTeams) => { + // Only update if we don't already have a larger set of teams + return prevTeams.length < teams.length ? teams : prevTeams; + }); + } + }, [teams]); + + useEffect(() => { + if (organizations && organizations.length > 0) { + setAllOrganizations((prevOrgs) => { + // Only update if we don't already have a larger set of organizations + return prevOrgs.length < organizations.length ? organizations : prevOrgs; + }); + } + }, [organizations]); + + const handleFilterChange = (newFilters: Record) => { + // Update filters state + setFilters({ + "Team ID": newFilters["Team ID"] || "", + "Organization ID": newFilters["Organization ID"] || "", + "Key Alias": newFilters["Key Alias"] || "", + "User ID": newFilters["User ID"] || "", + "Sort By": newFilters["Sort By"] || "created_at", + "Sort Order": newFilters["Sort Order"] || "desc", + }); + + // Fetch keys based on new filters + const updatedFilters = { + ...filters, + ...newFilters, + }; + debouncedSearch(updatedFilters); + }; + + const handleFilterReset = () => { + // Reset filters state + setFilters(defaultFilters); + + // Reset selections + debouncedSearch(defaultFilters); + }; + + return { + filters, + filteredKeys, + allKeyAliases, + allTeams, + allOrganizations, + handleFilterChange, + handleFilterReset, + }; +} diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/hooks/useTeams.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/hooks/useTeams.tsx new file mode 100644 index 0000000000..d1208737ec --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/hooks/useTeams.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; +import { fetchTeams } from "@/app/(console)/virtual-keys/networking"; +import { Team } from "@/components/key_team_helpers/key_list"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +const useTeams = () => { + const [teams, setTeams] = useState([]); + const { accessToken, userId: userID, userRole } = useAuthorized(); + + useEffect(() => { + (async () => { + const fetched = await fetchTeams(accessToken, userID, userRole, null); + setTeams(fetched); + })(); + }, [accessToken, userID, userRole]); + + return teams; +}; + +export default useTeams; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/networking.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/networking.ts new file mode 100644 index 0000000000..7fb09d61a5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/networking.ts @@ -0,0 +1,17 @@ +import { Organization, teamListCall } from "@/components/networking"; + +export const fetchTeams = async ( + accessToken: string, + userID: string | null, + userRole: string | null, + currentOrg: Organization | null, +) => { + let givenTeams; + if (userRole != "Admin" && userRole != "Admin Viewer") { + givenTeams = await teamListCall(accessToken, currentOrg?.organization_id || null, userID); + } else { + givenTeams = await teamListCall(accessToken, currentOrg?.organization_id || null); + } + + return givenTeams; +}; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/page.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/page.tsx new file mode 100644 index 0000000000..9554905088 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/page.tsx @@ -0,0 +1,52 @@ +"use client"; + +import VirtualKeysTable from "@/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable"; +import { getCookie } from "@/utils/cookieUtils"; +import { jwtDecode } from "jwt-decode"; +import { useState } from "react"; +import useKeyList from "@/components/key_team_helpers/key_list"; +import { useRouter } from "next/navigation"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Col, Grid } from "@tremor/react"; +import CreateKey from "@/app/(console)/virtual-keys/components/CreateKey"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized" + +const VirtualKeysPage = () => { + const {accessToken, userRole} = useAuthorized(); + const [createClicked, setCreateClicked] = useState(false); + + const queryClient = new QueryClient(); + + const { keys, isLoading, error, pagination, refresh, setKeys } = useKeyList({ + selectedKeyAlias: null, + currentOrg: null, + accessToken: accessToken || "", + createClicked, + }); + + const addKey = (data: any) => { + setKeys((prevData) => (prevData ? [...prevData, data] : [data])); + setCreateClicked(() => !createClicked); + }; + + return ( + +
+ + + + {}} + organizations={null} + /> + + +
+
+ ); +}; + +export default VirtualKeysPage; diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 579e90e530..472b3f9a37 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -234,7 +234,6 @@ export default function CreateKeyPage() { teams={teams} keys={keys} setUserRole={setUserRole} - userEmail={userEmail} setUserEmail={setUserEmail} setTeams={setTeams} setKeys={setKeys} @@ -275,7 +274,6 @@ export default function CreateKeyPage() { teams={teams} keys={keys} setUserRole={setUserRole} - userEmail={userEmail} setUserEmail={setUserEmail} setTeams={setTeams} setKeys={setKeys} diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index e47691d3bb..d9326cb887 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -1,8 +1,4 @@ import { Layout, Menu } from "antd"; -import Link from "next/link"; -import { List } from "postcss/lib/list"; -import { Text, Button } from "@tremor/react"; -import { useState } from "react"; import { KeyOutlined, PlayCircleOutlined, @@ -16,29 +12,17 @@ import { AppstoreOutlined, DatabaseOutlined, FileTextOutlined, - LineOutlined, LineChartOutlined, SafetyOutlined, ExperimentOutlined, - ThunderboltOutlined, - LockOutlined, ToolOutlined, TagsOutlined, BgColorsOutlined, - MenuFoldOutlined, - MenuUnfoldOutlined, } from "@ant-design/icons"; -import { - old_admin_roles, - v2_admin_role_names, - all_admin_roles, - rolesAllowedToSeeUsage, - rolesWithWriteAccess, - internalUserRoles, - isAdminRole, -} from "../utils/roles"; +import { all_admin_roles, rolesWithWriteAccess, internalUserRoles, isAdminRole } from "../utils/roles"; import UsageIndicator from "./usage_indicator"; import { ConfigProvider } from "antd"; +import { useRouter } from "next/navigation"; const { Sider } = Layout; // Define the props type @@ -61,6 +45,7 @@ interface MenuItem { } const Sidebar: React.FC = ({ accessToken, setPage, userRole, defaultSelectedKey, collapsed = false }) => { + const router = useRouter(); // Note: If a menu item does not have a role, it is visible to all roles. const menuItems: MenuItem[] = [ { @@ -252,6 +237,19 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau return true; }); + const navigateToPage = (page: string) => { + if (page === "api-keys") { + // Go to clean route and DO NOT call setPage to avoid any parent sync that re-adds ?page=api-keys + router.replace("/virtual-keys"); + return; + } + const newSearchParams = new URLSearchParams(window.location.search); + newSearchParams.set("page", page); + // Use absolute root to replace everything after the domain + router.replace(`/?${newSearchParams.toString()}`); + setPage(page); + }; + return ( = ({ accessToken, setPage, userRole, defau key: child.key, icon: child.icon, label: child.label, - onClick: () => { - const newSearchParams = new URLSearchParams(window.location.search); - newSearchParams.set("page", child.page); - window.history.pushState(null, "", `?${newSearchParams.toString()}`); - setPage(child.page); - }, + onClick: () => navigateToPage(child.page), })), - onClick: !item.children - ? () => { - const newSearchParams = new URLSearchParams(window.location.search); - newSearchParams.set("page", item.page); - window.history.pushState(null, "", `?${newSearchParams.toString()}`); - setPage(item.page); - } - : undefined, + onClick: !item.children ? () => navigateToPage(item.page) : undefined, }))} /> diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index c725466b98..20c1f5b3a3 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -131,6 +131,14 @@ export const fetchUserModels = async ( } }; + +/** + * ───────────────────────────────────────────────────────────────────────── + * @deprecated + * This component is being DEPRECATED in favor of src/app/(console)/virtual-keys/components/CreateKey.tsx + * Please contribute to the new refactor. + * ───────────────────────────────────────────────────────────────────────── + */ const CreateKey: React.FC = ({ userID, team, diff --git a/ui/litellm-dashboard/src/components/templates/view_key_table.tsx b/ui/litellm-dashboard/src/components/templates/view_key_table.tsx index bad8bdb983..f3ba12d56d 100644 --- a/ui/litellm-dashboard/src/components/templates/view_key_table.tsx +++ b/ui/litellm-dashboard/src/components/templates/view_key_table.tsx @@ -135,6 +135,13 @@ interface CombinedLimits { [key: string]: CombinedLimit; // Index signature allowing string keys } +/** + * ───────────────────────────────────────────────────────────────────────── + * @deprecated + * This component is being DEPRECATED in favor of src/app/(console)/virtual-keys/components/VirtualKeysTable/ + * Please contribute to the new refactor. + * ───────────────────────────────────────────────────────────────────────── + */ const ViewKeyTable: React.FC = ({ userID, userRole, diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts index aafeb010be..f542da105f 100644 --- a/ui/litellm-dashboard/src/utils/roles.ts +++ b/ui/litellm-dashboard/src/utils/roles.ts @@ -5,7 +5,7 @@ export const all_admin_roles = [...old_admin_roles, ...v2_admin_role_names]; export const internalUserRoles = ["Internal User", "Internal Viewer"]; export const rolesAllowedToSeeUsage = ["Admin", "Admin Viewer", "Internal User", "Internal Viewer"]; -export const rolesWithWriteAccess = ["Internal User", "Admin"]; +export const rolesWithWriteAccess = ["Internal User", "Admin", "proxy_admin"]; // Helper function to check if a role is in all_admin_roles export const isAdminRole = (role: string): boolean => { From 3f53d6b7ad32b7b55f9782f80e336bba55cd9a4c Mon Sep 17 00:00:00 2001 From: = Date: Mon, 6 Oct 2025 09:45:58 -0700 Subject: [PATCH 06/42] Moved KeyInfoView to refactored folder, added deprecation notice --- .../components/VirtualKeysTable/VirtualKeysTable.tsx | 2 +- .../src/components/templates/key_info_view.tsx | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx index 5c32a736ae..cdbd7cee43 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx @@ -15,10 +15,10 @@ import { KeyResponse, Team } from "@/components/key_team_helpers/key_list"; import { Organization, userListCall } from "@/components/networking"; import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; import FilterComponent, { FilterOption } from "@/components/molecules/filter"; -import KeyInfoView from "@/components/templates/key_info_view"; import { useFilterLogic } from "@/components/key_team_helpers/filter_logic"; import useTeams from "@/app/(console)/virtual-keys/hooks/useTeams"; import useAuthorized from "@/app/(console)/hooks/useAuthorized"; +import KeyInfoView from "@/app/(console)/virtual-keys/components/KeyInfoView"; interface AllKeysTableProps { keys: KeyResponse[]; diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index a5ec177425..0080924fb6 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -47,6 +47,13 @@ interface KeyInfoViewProps { backButtonText?: string; } +/** + * ───────────────────────────────────────────────────────────────────────── + * @deprecated + * This component is being DEPRECATED in favor of src/app/(console)/virtual-keys/components/KeyInfoView.tsx + * Please contribute to the new refactor. + * ───────────────────────────────────────────────────────────────────────── + */ export default function KeyInfoView({ keyId, onClose, From 53ee3e2634f956c059bbf3b5449a061a46ba6666 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 6 Oct 2025 10:28:48 -0700 Subject: [PATCH 07/42] refactoredUI feature flag --- .../components/{Sidebar.tsx => Sidebar2.tsx} | 4 +- .../src/app/(console)/layout.tsx | 4 +- ui/litellm-dashboard/src/app/page.tsx | 24 +++++++--- .../src/components/leftnav.tsx | 46 +++++++++++-------- .../src/components/navbar.tsx | 21 ++++++++- .../src/hooks/useFeatureFlags.ts | 9 ++++ 6 files changed, 78 insertions(+), 30 deletions(-) rename ui/litellm-dashboard/src/app/(console)/components/{Sidebar.tsx => Sidebar2.tsx} (98%) create mode 100644 ui/litellm-dashboard/src/hooks/useFeatureFlags.ts diff --git a/ui/litellm-dashboard/src/app/(console)/components/Sidebar.tsx b/ui/litellm-dashboard/src/app/(console)/components/Sidebar2.tsx similarity index 98% rename from ui/litellm-dashboard/src/app/(console)/components/Sidebar.tsx rename to ui/litellm-dashboard/src/app/(console)/components/Sidebar2.tsx index ad60ba626a..b2477243a0 100644 --- a/ui/litellm-dashboard/src/app/(console)/components/Sidebar.tsx +++ b/ui/litellm-dashboard/src/app/(console)/components/Sidebar2.tsx @@ -44,7 +44,7 @@ interface MenuItem { icon?: React.ReactNode; } -const Sidebar: React.FC = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => { +const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => { const menuItems: MenuItem[] = [ { key: "1", page: "api-keys", label: "Virtual Keys", icon: }, { @@ -288,4 +288,4 @@ const Sidebar: React.FC = ({ accessToken, userRole, defaultSelecte ); }; -export default Sidebar; +export default Sidebar2; diff --git a/ui/litellm-dashboard/src/app/(console)/layout.tsx b/ui/litellm-dashboard/src/app/(console)/layout.tsx index cbfb5bdcb5..6ad84208f5 100644 --- a/ui/litellm-dashboard/src/app/(console)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(console)/layout.tsx @@ -3,7 +3,7 @@ import React from "react"; import Navbar from "@/components/navbar"; import { ThemeProvider } from "@/contexts/ThemeContext"; -import Sidebar from "@/app/(console)/components/Sidebar"; +import Sidebar2 from "@/app/(console)/components/Sidebar2"; import useAuthorized from "@/app/(console)/hooks/useAuthorized"; export default function Layout({ children }: { children: React.ReactNode }) { @@ -31,7 +31,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { />
- row.startsWith(name + "=")); @@ -116,6 +118,8 @@ export default function CreateKeyPage() { const [authLoading, setAuthLoading] = useState(true); const [userID, setUserID] = useState(null); + const { refactoredUIFlag, setRefactoredUIFlag } = useFeatureFlags(); + const invitation_id = searchParams.get("invitation_id"); // Get page from URL, default to 'api-keys' if not present @@ -254,16 +258,22 @@ export default function CreateKeyPage() { isPublicPage={false} sidebarCollapsed={sidebarCollapsed} onToggleSidebar={toggleSidebar} + refactoredUIFlag={refactoredUIFlag} + setRefactoredUIFlag={setRefactoredUIFlag} />
- + {refactoredUIFlag ? ( + + ) : ( + + )}
{page == "api-keys" ? ( diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index d9326cb887..34916cddb9 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -12,17 +12,29 @@ import { AppstoreOutlined, DatabaseOutlined, FileTextOutlined, + LineOutlined, LineChartOutlined, SafetyOutlined, ExperimentOutlined, + ThunderboltOutlined, + LockOutlined, ToolOutlined, TagsOutlined, BgColorsOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, } from "@ant-design/icons"; -import { all_admin_roles, rolesWithWriteAccess, internalUserRoles, isAdminRole } from "../utils/roles"; +import { + old_admin_roles, + v2_admin_role_names, + all_admin_roles, + rolesAllowedToSeeUsage, + rolesWithWriteAccess, + internalUserRoles, + isAdminRole, +} from "../utils/roles"; import UsageIndicator from "./usage_indicator"; import { ConfigProvider } from "antd"; -import { useRouter } from "next/navigation"; const { Sider } = Layout; // Define the props type @@ -45,7 +57,6 @@ interface MenuItem { } const Sidebar: React.FC = ({ accessToken, setPage, userRole, defaultSelectedKey, collapsed = false }) => { - const router = useRouter(); // Note: If a menu item does not have a role, it is visible to all roles. const menuItems: MenuItem[] = [ { @@ -237,19 +248,6 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau return true; }); - const navigateToPage = (page: string) => { - if (page === "api-keys") { - // Go to clean route and DO NOT call setPage to avoid any parent sync that re-adds ?page=api-keys - router.replace("/virtual-keys"); - return; - } - const newSearchParams = new URLSearchParams(window.location.search); - newSearchParams.set("page", page); - // Use absolute root to replace everything after the domain - router.replace(`/?${newSearchParams.toString()}`); - setPage(page); - }; - return ( = ({ accessToken, setPage, userRole, defau key: child.key, icon: child.icon, label: child.label, - onClick: () => navigateToPage(child.page), + onClick: () => { + const newSearchParams = new URLSearchParams(window.location.search); + newSearchParams.set("page", child.page); + window.history.pushState(null, "", `?${newSearchParams.toString()}`); + setPage(child.page); + }, })), - onClick: !item.children ? () => navigateToPage(item.page) : undefined, + onClick: !item.children + ? () => { + const newSearchParams = new URLSearchParams(window.location.search); + newSearchParams.set("page", item.page); + window.history.pushState(null, "", `?${newSearchParams.toString()}`); + setPage(item.page); + } + : undefined, }))} /> diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 0764288057..e4a9c21c44 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import React, { useState, useEffect } from "react"; import type { MenuProps } from "antd"; -import { Dropdown, Tooltip } from "antd"; +import { Dropdown, Tooltip, Switch } from "antd"; import { getProxyBaseUrl, Organization } from "@/components/networking"; import { defaultOrg } from "@/components/common_components/default_org"; import { @@ -19,6 +19,7 @@ import { clearTokenCookies } from "@/utils/cookieUtils"; import { fetchProxySettings } from "@/utils/proxyUtils"; import { useTheme } from "@/contexts/ThemeContext"; import { clearMCPAuthTokens } from "./mcp_tools/mcp_auth_storage"; +import useFeatureFlags from "@/hooks/useFeatureFlags"; interface NavbarProps { userID: string | null; @@ -31,6 +32,8 @@ interface NavbarProps { isPublicPage: boolean; sidebarCollapsed?: boolean; onToggleSidebar?: () => void; + refactoredUIFlag: boolean; + setRefactoredUIFlag: React.Dispatch>; } const Navbar: React.FC = ({ @@ -44,6 +47,8 @@ const Navbar: React.FC = ({ isPublicPage = false, sidebarCollapsed = false, onToggleSidebar, + refactoredUIFlag, + setRefactoredUIFlag, }) => { const baseUrl = getProxyBaseUrl(); const [logoutUrl, setLogoutUrl] = useState(""); @@ -79,6 +84,8 @@ const Navbar: React.FC = ({ const userItems: MenuProps["items"] = [ { key: "user-info", + // Prevent dropdown from closing when interacting with the toggle + onClick: (info) => info.domEvent?.stopPropagation(), label: (
@@ -115,6 +122,18 @@ const Navbar: React.FC = ({ {userEmail || "Unknown"}
+ + {/* NEW: Feature flag label + toggle below the email field */} +
+ Refactored UI + setRefactoredUIFlag(checked)} + aria-label="Toggle refactored UI feature flag" + /> +
), diff --git a/ui/litellm-dashboard/src/hooks/useFeatureFlags.ts b/ui/litellm-dashboard/src/hooks/useFeatureFlags.ts new file mode 100644 index 0000000000..b82f89e874 --- /dev/null +++ b/ui/litellm-dashboard/src/hooks/useFeatureFlags.ts @@ -0,0 +1,9 @@ +import { useState } from "react"; + +const useFeatureFlags = () => { + const [refactoredUIFlag, setRefactoredUIFlag] = useState(false); + + return { refactoredUIFlag, setRefactoredUIFlag }; +}; + +export default useFeatureFlags; From 715c4999a290a528a54ac3ccf6b01d08ae6811e9 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Mon, 6 Oct 2025 12:57:16 -0700 Subject: [PATCH 08/42] global feature flag state for app --- .../(console)/components/SidebarProvider.tsx | 34 +++++++++++++++++++ .../src/app/(console)/layout.tsx | 1 + ui/litellm-dashboard/src/app/layout.tsx | 5 ++- ui/litellm-dashboard/src/app/page.tsx | 21 +++--------- .../src/components/navbar.tsx | 7 ++-- .../src/hooks/useFeatureFlags.ts | 9 ----- .../src/hooks/useFeatureFlags.tsx | 26 ++++++++++++++ 7 files changed, 72 insertions(+), 31 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(console)/components/SidebarProvider.tsx delete mode 100644 ui/litellm-dashboard/src/hooks/useFeatureFlags.ts create mode 100644 ui/litellm-dashboard/src/hooks/useFeatureFlags.tsx diff --git a/ui/litellm-dashboard/src/app/(console)/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/(console)/components/SidebarProvider.tsx new file mode 100644 index 0000000000..a5e150da86 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/components/SidebarProvider.tsx @@ -0,0 +1,34 @@ +import Sidebar2 from "@/app/(console)/components/Sidebar2"; +import Sidebar from "@/components/leftnav"; +import React from "react"; +import useFeatureFlags from "@/hooks/useFeatureFlags"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +interface SidebarProviderProps { + defaultSelectedKey: string; + sidebarCollapsed: boolean; + setPage: (page: string) => void; +} + +const SidebarProvider = ({defaultSelectedKey, sidebarCollapsed, setPage}: SidebarProviderProps) => { + + const {accessToken, userRole} = useAuthorized(); + const { refactoredUIFlag, setRefactoredUIFlag } = useFeatureFlags(); + + return ( + refactoredUIFlag ? ( + + ) : ( + + ) + ); + +} + +export default SidebarProvider; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/app/(console)/layout.tsx b/ui/litellm-dashboard/src/app/(console)/layout.tsx index 6ad84208f5..595eeedc59 100644 --- a/ui/litellm-dashboard/src/app/(console)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(console)/layout.tsx @@ -5,6 +5,7 @@ import Navbar from "@/components/navbar"; import { ThemeProvider } from "@/contexts/ThemeContext"; import Sidebar2 from "@/app/(console)/components/Sidebar2"; import useAuthorized from "@/app/(console)/hooks/useAuthorized"; +import useFeatureFlags, { FeatureFlagsProvider } from "@/hooks/useFeatureFlags"; export default function Layout({ children }: { children: React.ReactNode }) { const { accessToken, userRole } = useAuthorized(); diff --git a/ui/litellm-dashboard/src/app/layout.tsx b/ui/litellm-dashboard/src/app/layout.tsx index 95c485fe2f..8a37d0fa1b 100644 --- a/ui/litellm-dashboard/src/app/layout.tsx +++ b/ui/litellm-dashboard/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; +import { FeatureFlagsProvider } from "@/hooks/useFeatureFlags"; const inter = Inter({ subsets: ["latin"] }); @@ -17,7 +18,9 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} + ); } diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 2e49b422b4..eff997a3aa 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -39,8 +39,9 @@ import VectorStoreManagement from "@/components/vector_store_management"; import UIThemeSettings from "@/components/ui_theme_settings"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { cx } from "@/lib/cva.config"; -import useFeatureFlags from "@/hooks/useFeatureFlags"; +import useFeatureFlags, { FeatureFlagsProvider } from "@/hooks/useFeatureFlags"; import Sidebar2 from "@/app/(console)/components/Sidebar2"; +import SidebarProvider from "@/app/(console)/components/SidebarProvider"; function getCookie(name: string) { const cookieValue = document.cookie.split("; ").find((row) => row.startsWith(name + "=")); @@ -118,8 +119,6 @@ export default function CreateKeyPage() { const [authLoading, setAuthLoading] = useState(true); const [userID, setUserID] = useState(null); - const { refactoredUIFlag, setRefactoredUIFlag } = useFeatureFlags(); - const invitation_id = searchParams.get("invitation_id"); // Get page from URL, default to 'api-keys' if not present @@ -234,6 +233,7 @@ export default function CreateKeyPage() {
- {refactoredUIFlag ? ( - - ) : ( - - )} +
{page == "api-keys" ? ( void; - refactoredUIFlag: boolean; - setRefactoredUIFlag: React.Dispatch>; } const Navbar: React.FC = ({ @@ -46,13 +44,12 @@ const Navbar: React.FC = ({ accessToken, isPublicPage = false, sidebarCollapsed = false, - onToggleSidebar, - refactoredUIFlag, - setRefactoredUIFlag, + onToggleSidebar }) => { const baseUrl = getProxyBaseUrl(); const [logoutUrl, setLogoutUrl] = useState(""); const { logoUrl } = useTheme(); + const { refactoredUIFlag, setRefactoredUIFlag } = useFeatureFlags(); // Simple logo URL: use custom logo if available, otherwise default const imageUrl = logoUrl || `${baseUrl}/get_image`; diff --git a/ui/litellm-dashboard/src/hooks/useFeatureFlags.ts b/ui/litellm-dashboard/src/hooks/useFeatureFlags.ts deleted file mode 100644 index b82f89e874..0000000000 --- a/ui/litellm-dashboard/src/hooks/useFeatureFlags.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useState } from "react"; - -const useFeatureFlags = () => { - const [refactoredUIFlag, setRefactoredUIFlag] = useState(false); - - return { refactoredUIFlag, setRefactoredUIFlag }; -}; - -export default useFeatureFlags; diff --git a/ui/litellm-dashboard/src/hooks/useFeatureFlags.tsx b/ui/litellm-dashboard/src/hooks/useFeatureFlags.tsx new file mode 100644 index 0000000000..d50fdf46a6 --- /dev/null +++ b/ui/litellm-dashboard/src/hooks/useFeatureFlags.tsx @@ -0,0 +1,26 @@ +'use client'; +import React, {createContext, useContext, useState} from 'react'; + +type Flags = { + refactoredUIFlag: boolean; + setRefactoredUIFlag: (v: boolean) => void; +}; + +const FeatureFlagsCtx = createContext(null); + +export const FeatureFlagsProvider = ({ children }: { children: React.ReactNode }) => { + const [refactoredUIFlag, setRefactoredUIFlag] = useState(false); + return ( + + {children} + +); +} + +const useFeatureFlags = () => { + const ctx = useContext(FeatureFlagsCtx); + if (!ctx) throw new Error('useFeatureFlags must be used within FeatureFlagsProvider'); + return ctx; +} + +export default useFeatureFlags; From f4d9e4869ad7c29647aed699de6e77e51584b4e8 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Mon, 6 Oct 2025 13:33:38 -0700 Subject: [PATCH 09/42] renamed (console), added use clients --- .../components/Sidebar2.tsx | 4 +++- .../components/SidebarProvider.tsx | 6 +++-- .../components/modals/CreateUserModal.tsx | 8 ++++--- .../hooks/useAuthorized.ts | 3 ++- .../app/{(console) => dashboard}/layout.tsx | 5 ++--- .../virtual-keys/components/CreateKey.tsx | 6 ++--- .../CreateKeyForm/KeyDetailsSection.tsx | 2 ++ .../CreateKeyForm/OptionalSettingsSection.tsx | 4 +++- .../CreateKeyForm/OwnershipSection.tsx | 4 +++- .../CreateKeyModal/CreateKeyModal.tsx | 22 ++++++++++--------- .../components/CreateKeyModal/hooks/index.ts | 0 .../hooks/useGuardrailsAndPrompts.ts | 4 ++-- .../hooks/useMcpAccessGroups.ts | 4 ++-- .../CreateKeyModal/hooks/useTeamModels.ts | 4 ++-- .../CreateKeyModal/hooks/useUserModels.ts | 4 ++-- .../CreateKeyModal/hooks/useUserSearch.ts | 4 ++-- .../components/CreateKeyModal/networking.ts | 2 +- .../components/CreateKeyModal/types.ts | 0 .../components/CreateKeyModal/utils.ts | 2 +- .../virtual-keys/components/KeyInfoView.tsx | 2 ++ .../virtual-keys/components/SaveKeyModal.tsx | 2 ++ .../VirtualKeysTable/VirtualKeysTable.tsx | 8 +++---- .../VirtualKeysTable/hooks/useFilterLogic.ts | 0 .../virtual-keys/hooks/useTeams.tsx | 4 ++-- .../virtual-keys/networking.ts | 0 .../virtual-keys/page.tsx | 9 +++----- ui/litellm-dashboard/src/app/page.tsx | 4 ++-- .../organisms/create_key_button.tsx | 2 +- .../components/templates/key_info_view.tsx | 2 +- .../components/templates/view_key_table.tsx | 2 +- 30 files changed, 69 insertions(+), 54 deletions(-) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/components/Sidebar2.tsx (99%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/components/SidebarProvider.tsx (86%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/components/modals/CreateUserModal.tsx (98%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/hooks/useAuthorized.ts (94%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/layout.tsx (86%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/CreateKey.tsx (94%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx (99%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx (99%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx (98%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx (91%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/CreateKeyModal/hooks/index.ts (100%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts (83%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts (80%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts (89%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts (81%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts (89%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/CreateKeyModal/networking.ts (95%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/CreateKeyModal/types.ts (100%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/CreateKeyModal/utils.ts (98%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/KeyInfoView.tsx (99%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/SaveKeyModal.tsx (99%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx (99%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts (100%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/hooks/useTeams.tsx (79%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/networking.ts (100%) rename ui/litellm-dashboard/src/app/{(console) => dashboard}/virtual-keys/page.tsx (81%) diff --git a/ui/litellm-dashboard/src/app/(console)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/dashboard/components/Sidebar2.tsx similarity index 99% rename from ui/litellm-dashboard/src/app/(console)/components/Sidebar2.tsx rename to ui/litellm-dashboard/src/app/dashboard/components/Sidebar2.tsx index b2477243a0..9de9f2f136 100644 --- a/ui/litellm-dashboard/src/app/(console)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/dashboard/components/Sidebar2.tsx @@ -1,3 +1,5 @@ +"use client"; + import React from "react"; import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; @@ -216,7 +218,7 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect }; const selectedMenuKey = - pathname === "/virtual-keys" + pathname === "/dashboard/virtual-keys" ? "1" : pageParam ? findMenuItemKey(pageParam) diff --git a/ui/litellm-dashboard/src/app/(console)/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/dashboard/components/SidebarProvider.tsx similarity index 86% rename from ui/litellm-dashboard/src/app/(console)/components/SidebarProvider.tsx rename to ui/litellm-dashboard/src/app/dashboard/components/SidebarProvider.tsx index a5e150da86..8380493798 100644 --- a/ui/litellm-dashboard/src/app/(console)/components/SidebarProvider.tsx +++ b/ui/litellm-dashboard/src/app/dashboard/components/SidebarProvider.tsx @@ -1,8 +1,10 @@ -import Sidebar2 from "@/app/(console)/components/Sidebar2"; +"use client"; + +import Sidebar2 from "@/app/dashboard/components/Sidebar2"; import Sidebar from "@/components/leftnav"; import React from "react"; import useFeatureFlags from "@/hooks/useFeatureFlags"; -import useAuthorized from "@/app/(console)/hooks/useAuthorized"; +import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; interface SidebarProviderProps { defaultSelectedKey: string; diff --git a/ui/litellm-dashboard/src/app/(console)/components/modals/CreateUserModal.tsx b/ui/litellm-dashboard/src/app/dashboard/components/modals/CreateUserModal.tsx similarity index 98% rename from ui/litellm-dashboard/src/app/(console)/components/modals/CreateUserModal.tsx rename to ui/litellm-dashboard/src/app/dashboard/components/modals/CreateUserModal.tsx index 4caaceb662..8b16074b25 100644 --- a/ui/litellm-dashboard/src/app/(console)/components/modals/CreateUserModal.tsx +++ b/ui/litellm-dashboard/src/app/dashboard/components/modals/CreateUserModal.tsx @@ -1,3 +1,5 @@ +"use client"; + // TODO: refactor import React, { useState, useEffect } from "react"; import { Button, Modal, Form, Input, message, Select, InputNumber, Select as Select2 } from "antd"; @@ -27,9 +29,9 @@ import { } from "@/components/networking"; import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; import BulkCreateUsersButton from "@/components/bulk_create_users_button"; -import { fetchTeams } from "@/app/(console)/virtual-keys/networking"; -import useTeams from "@/app/(console)/virtual-keys/hooks/useTeams"; -import useAuthorized from "@/app/(console)/hooks/useAuthorized"; +import { fetchTeams } from "@/app/dashboard/virtual-keys/networking"; +import useTeams from "@/app/dashboard/virtual-keys/hooks/useTeams"; +import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; // Helper function to generate UUID compatible across all environments const generateUUID = (): string => { diff --git a/ui/litellm-dashboard/src/app/(console)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/dashboard/hooks/useAuthorized.ts similarity index 94% rename from ui/litellm-dashboard/src/app/(console)/hooks/useAuthorized.ts rename to ui/litellm-dashboard/src/app/dashboard/hooks/useAuthorized.ts index 45f5ee92ea..9d436d0566 100644 --- a/ui/litellm-dashboard/src/app/(console)/hooks/useAuthorized.ts +++ b/ui/litellm-dashboard/src/app/dashboard/hooks/useAuthorized.ts @@ -1,4 +1,5 @@ -import { Router } from "next/router"; +"use client"; + import { getCookie } from "@/utils/cookieUtils"; import { useRouter } from "next/navigation"; import { jwtDecode } from "jwt-decode"; diff --git a/ui/litellm-dashboard/src/app/(console)/layout.tsx b/ui/litellm-dashboard/src/app/dashboard/layout.tsx similarity index 86% rename from ui/litellm-dashboard/src/app/(console)/layout.tsx rename to ui/litellm-dashboard/src/app/dashboard/layout.tsx index 595eeedc59..851ecec665 100644 --- a/ui/litellm-dashboard/src/app/(console)/layout.tsx +++ b/ui/litellm-dashboard/src/app/dashboard/layout.tsx @@ -3,9 +3,8 @@ import React from "react"; import Navbar from "@/components/navbar"; import { ThemeProvider } from "@/contexts/ThemeContext"; -import Sidebar2 from "@/app/(console)/components/Sidebar2"; -import useAuthorized from "@/app/(console)/hooks/useAuthorized"; -import useFeatureFlags, { FeatureFlagsProvider } from "@/hooks/useFeatureFlags"; +import Sidebar2 from "@/app/dashboard/components/Sidebar2"; +import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; export default function Layout({ children }: { children: React.ReactNode }) { const { accessToken, userRole } = useAuthorized(); diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKey.tsx b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKey.tsx similarity index 94% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKey.tsx rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKey.tsx index c8e0b1d8ce..15dd713917 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKey.tsx +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKey.tsx @@ -6,9 +6,9 @@ import { Modal, Form } from "antd"; import { getPossibleUserRoles, Organization } from "@/components/networking"; import { rolesWithWriteAccess } from "@/utils/roles"; import { Team } from "@/components/key_team_helpers/key_list"; -import CreateKeyModal from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyModal"; -import CreateUserModal from "@/app/(console)/components/modals/CreateUserModal"; -import useAuthorized from "@/app/(console)/hooks/useAuthorized"; +import CreateKeyModal from "@/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyModal"; +import CreateUserModal from "@/app/dashboard/components/modals/CreateUserModal"; +import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; interface CreateKeyProps { team: Team | null; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx similarity index 99% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx index 54e9aaefed..33a8ae06cf 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx @@ -1,3 +1,5 @@ +"use client"; + import { TextInput, Title } from "@tremor/react"; import { Form, FormInstance, Select, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx similarity index 99% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx index c879cdf595..65ebd2d571 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Accordion, AccordionBody, AccordionHeader, Text, Title } from "@tremor/react"; import { Form, FormInstance, Input, Select, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; @@ -15,7 +17,7 @@ import SchemaFormFields from "@/components/common_components/check_openapi_schem import { Team } from "@/components/key_team_helpers/key_list"; import React from "react"; import { DefaultOptionType } from "rc-select/lib/Select"; -import { ModelAliases } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types"; +import { ModelAliases } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/types"; export interface OptionalSettingsSectionProps { form: FormInstance; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx similarity index 98% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx index fdfb3893f6..00c2e9a1e2 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx @@ -1,10 +1,12 @@ +"use client"; + import { Button as Button2, Form, Radio, Select, Tooltip } from "antd"; import { Title } from "@tremor/react"; import { InfoCircleOutlined } from "@ant-design/icons"; import TeamDropdown from "@/components/common_components/team_dropdown"; import React from "react"; import { Team } from "@/components/key_team_helpers/key_list"; -import { UserOption } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types"; +import { UserOption } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/types"; interface OwnershipSectionProps { team: Team | null; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx similarity index 91% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx index 9f4d7528a8..2bee7f72b1 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx @@ -1,9 +1,11 @@ +"use client"; + import { Button as Button2, Form, FormInstance, Modal } from "antd"; import { Text } from "@tremor/react"; import { keyCreateCall, keyCreateServiceAccountCall } from "@/components/networking"; import React, { useEffect, useState } from "react"; import { Team } from "@/components/key_team_helpers/key_list"; -import SaveKeyModal from "@/app/(console)/virtual-keys/components/SaveKeyModal"; +import SaveKeyModal from "@/app/dashboard/virtual-keys/components/SaveKeyModal"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { useGuardrailsAndPrompts, @@ -11,15 +13,15 @@ import { useTeamModels, useUserModels, useUserSearch, -} from "@/app/(console)/virtual-keys/components/CreateKeyModal/hooks"; -import OwnershipSection from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection"; -import KeyDetailsSection from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection"; -import OptionalSettingsSection from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection"; -import { ModelAliases } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types"; -import { getPredefinedTags, prepareFormValues } from "@/app/(console)/virtual-keys/components/CreateKeyModal/utils"; -import { fetchTeams } from "@/app/(console)/virtual-keys/networking"; -import useTeams from "@/app/(console)/virtual-keys/hooks/useTeams"; -import useAuthorized from "@/app/(console)/hooks/useAuthorized"; +} from "@/app/dashboard/virtual-keys/components/CreateKeyModal/hooks"; +import OwnershipSection from "@/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection"; +import KeyDetailsSection from "@/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection"; +import OptionalSettingsSection from "@/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection"; +import { ModelAliases } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/types"; +import { getPredefinedTags, prepareFormValues } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/utils"; +import { fetchTeams } from "@/app/dashboard/virtual-keys/networking"; +import useTeams from "@/app/dashboard/virtual-keys/hooks/useTeams"; +import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; export interface CreateKeyModalProps { isModalVisible: boolean; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/index.ts b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/index.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/index.ts rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/index.ts diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts similarity index 83% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts index d092219f70..2445240e1e 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { fetchGuardrails, fetchPrompts } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking"; -import useAuthorized from "@/app/(console)/hooks/useAuthorized"; +import { fetchGuardrails, fetchPrompts } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; export const useGuardrailsAndPrompts = () => { const [guardrails, setGuardrails] = useState([]); diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts similarity index 80% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts index 8313f6e8d6..5007040000 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { getMCPAccessGroups } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking"; -import useAuthorized from "@/app/(console)/hooks/useAuthorized"; +import { getMCPAccessGroups } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; export const useMcpAccessGroups = () => { const [mcpAccessGroups, setMcpAccessGroups] = useState([]); diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts similarity index 89% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts index c28ef0ecdb..979337f5aa 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import type { Team } from "@/components/key_team_helpers/key_list"; -import { fetchTeamModels } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking"; -import useAuthorized from "@/app/(console)/hooks/useAuthorized"; +import { fetchTeamModels } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; export const useTeamModels = (selectedTeam: Team | null) => { const { userId: userID, userRole, accessToken } = useAuthorized(); diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts similarity index 81% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts index ddba6879cc..badcaf93d9 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { getUserModelNames } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking"; -import useAuthorized from "@/app/(console)/hooks/useAuthorized"; +import { getUserModelNames } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; export const useUserModels = () => { const { userId: userID, userRole, accessToken } = useAuthorized(); diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts similarity index 89% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts index 782beccf48..a3840bdc62 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { debounce } from "lodash"; -import { searchUserOptionsByEmail } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking"; -import useAuthorized from "@/app/(console)/hooks/useAuthorized"; +import { searchUserOptionsByEmail } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; type User = { user_id: string; user_email: string; role?: string }; export interface UserOption { diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/networking.ts b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/networking.ts similarity index 95% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/networking.ts rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/networking.ts index 6cb4e2f7f0..2277800dad 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/networking.ts +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/networking.ts @@ -6,7 +6,7 @@ import { User, userFilterUICall, } from "@/components/networking"; -import { ModelAvailableResponse, UserOption } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types"; +import { ModelAvailableResponse, UserOption } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/types"; export const fetchGuardrails = async (accessToken: string) => { try { diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/types.ts b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/types.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/types.ts rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/types.ts diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/utils.ts b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/utils.ts similarity index 98% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/utils.ts rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/utils.ts index 066770cf46..73cd433e78 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/utils.ts +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/utils.ts @@ -1,5 +1,5 @@ import { mapDisplayToInternalNames } from "@/components/callback_info_helpers"; -import { ModelAliases } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types"; +import { ModelAliases } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/types"; export const getPredefinedTags = (data: any[] | null) => { let allTags = []; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/KeyInfoView.tsx b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/KeyInfoView.tsx similarity index 99% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/KeyInfoView.tsx rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/KeyInfoView.tsx index 1c7185f1dc..9f9a47fbe1 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/KeyInfoView.tsx +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/KeyInfoView.tsx @@ -1,3 +1,5 @@ +"use client"; + import React, { useEffect, useState } from "react"; import { Card, diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/SaveKeyModal.tsx b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/SaveKeyModal.tsx similarity index 99% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/SaveKeyModal.tsx rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/SaveKeyModal.tsx index bd0d425c46..fed678aadc 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/SaveKeyModal.tsx +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/SaveKeyModal.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Button, Col, Grid, Text, Title } from "@tremor/react"; import { CopyToClipboard } from "react-copy-to-clipboard"; import { Modal } from "antd"; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx similarity index 99% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx index cdbd7cee43..b7727a299b 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx @@ -1,5 +1,5 @@ -// TODO: refactor "use client"; +// TODO: refactor import React, { useEffect, useState } from "react"; import { ColumnDef } from "@tanstack/react-table"; @@ -16,9 +16,9 @@ import { Organization, userListCall } from "@/components/networking"; import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; import FilterComponent, { FilterOption } from "@/components/molecules/filter"; import { useFilterLogic } from "@/components/key_team_helpers/filter_logic"; -import useTeams from "@/app/(console)/virtual-keys/hooks/useTeams"; -import useAuthorized from "@/app/(console)/hooks/useAuthorized"; -import KeyInfoView from "@/app/(console)/virtual-keys/components/KeyInfoView"; +import useTeams from "@/app/dashboard/virtual-keys/hooks/useTeams"; +import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; +import KeyInfoView from "@/app/dashboard/virtual-keys/components/KeyInfoView"; interface AllKeysTableProps { keys: KeyResponse[]; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/hooks/useTeams.tsx b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/hooks/useTeams.tsx similarity index 79% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/hooks/useTeams.tsx rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/hooks/useTeams.tsx index d1208737ec..92e5896e76 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/hooks/useTeams.tsx +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/hooks/useTeams.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; -import { fetchTeams } from "@/app/(console)/virtual-keys/networking"; +import { fetchTeams } from "@/app/dashboard/virtual-keys/networking"; import { Team } from "@/components/key_team_helpers/key_list"; -import useAuthorized from "@/app/(console)/hooks/useAuthorized"; +import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; const useTeams = () => { const [teams, setTeams] = useState([]); diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/networking.ts b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/networking.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/networking.ts rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/networking.ts diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/page.tsx b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/page.tsx similarity index 81% rename from ui/litellm-dashboard/src/app/(console)/virtual-keys/page.tsx rename to ui/litellm-dashboard/src/app/dashboard/virtual-keys/page.tsx index 9554905088..7dfc959867 100644 --- a/ui/litellm-dashboard/src/app/(console)/virtual-keys/page.tsx +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/page.tsx @@ -1,15 +1,12 @@ "use client"; -import VirtualKeysTable from "@/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable"; -import { getCookie } from "@/utils/cookieUtils"; -import { jwtDecode } from "jwt-decode"; +import VirtualKeysTable from "@/app/dashboard/virtual-keys/components/VirtualKeysTable/VirtualKeysTable"; import { useState } from "react"; import useKeyList from "@/components/key_team_helpers/key_list"; -import { useRouter } from "next/navigation"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Col, Grid } from "@tremor/react"; -import CreateKey from "@/app/(console)/virtual-keys/components/CreateKey"; -import useAuthorized from "@/app/(console)/hooks/useAuthorized" +import CreateKey from "@/app/dashboard/virtual-keys/components/CreateKey"; +import useAuthorized from "@/app/dashboard/hooks/useAuthorized" const VirtualKeysPage = () => { const {accessToken, userRole} = useAuthorized(); diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index eff997a3aa..3815df002b 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -40,8 +40,8 @@ import UIThemeSettings from "@/components/ui_theme_settings"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { cx } from "@/lib/cva.config"; import useFeatureFlags, { FeatureFlagsProvider } from "@/hooks/useFeatureFlags"; -import Sidebar2 from "@/app/(console)/components/Sidebar2"; -import SidebarProvider from "@/app/(console)/components/SidebarProvider"; +import Sidebar2 from "@/app/dashboard/components/Sidebar2"; +import SidebarProvider from "@/app/dashboard/components/SidebarProvider"; function getCookie(name: string) { const cookieValue = document.cookie.split("; ").find((row) => row.startsWith(name + "=")); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 20c1f5b3a3..d4035e83f0 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -135,7 +135,7 @@ export const fetchUserModels = async ( /** * ───────────────────────────────────────────────────────────────────────── * @deprecated - * This component is being DEPRECATED in favor of src/app/(console)/virtual-keys/components/CreateKey.tsx + * This component is being DEPRECATED in favor of src/app/dashboard/virtual-keys/components/CreateKey.tsx * Please contribute to the new refactor. * ───────────────────────────────────────────────────────────────────────── */ diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 0080924fb6..ba04e2644a 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -50,7 +50,7 @@ interface KeyInfoViewProps { /** * ───────────────────────────────────────────────────────────────────────── * @deprecated - * This component is being DEPRECATED in favor of src/app/(console)/virtual-keys/components/KeyInfoView.tsx + * This component is being DEPRECATED in favor of src/app/dashboard/virtual-keys/components/KeyInfoView.tsx * Please contribute to the new refactor. * ───────────────────────────────────────────────────────────────────────── */ diff --git a/ui/litellm-dashboard/src/components/templates/view_key_table.tsx b/ui/litellm-dashboard/src/components/templates/view_key_table.tsx index f3ba12d56d..3fad7c0a35 100644 --- a/ui/litellm-dashboard/src/components/templates/view_key_table.tsx +++ b/ui/litellm-dashboard/src/components/templates/view_key_table.tsx @@ -138,7 +138,7 @@ interface CombinedLimits { /** * ───────────────────────────────────────────────────────────────────────── * @deprecated - * This component is being DEPRECATED in favor of src/app/(console)/virtual-keys/components/VirtualKeysTable/ + * This component is being DEPRECATED in favor of src/app/dashboard/virtual-keys/components/VirtualKeysTable/ * Please contribute to the new refactor. * ───────────────────────────────────────────────────────────────────────── */ From cee408c0c06403b68837cd8cfc34d36c6a3ebc69 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Mon, 6 Oct 2025 14:03:53 -0700 Subject: [PATCH 10/42] diagnosing next build issue --- .../src/app/dashboard/hooks/useAuthorized.ts | 39 +++++++++++++------ ui/litellm-dashboard/src/app/layout.tsx | 9 +++-- ui/litellm-dashboard/src/utils/cookieUtils.ts | 1 + 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/ui/litellm-dashboard/src/app/dashboard/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/dashboard/hooks/useAuthorized.ts index 9d436d0566..74c0476f19 100644 --- a/ui/litellm-dashboard/src/app/dashboard/hooks/useAuthorized.ts +++ b/ui/litellm-dashboard/src/app/dashboard/hooks/useAuthorized.ts @@ -1,24 +1,41 @@ "use client"; -import { getCookie } from "@/utils/cookieUtils"; +import { useEffect, useMemo } from "react"; +import { getCookie, clearTokenCookies } from "@/utils/cookieUtils"; import { useRouter } from "next/navigation"; import { jwtDecode } from "jwt-decode"; const useAuthorized = () => { - const token = getCookie("token"); const router = useRouter(); - if (!token) { - router.replace("/sso/key/generate"); - } + const token = typeof document !== "undefined" ? getCookie("token") : null; - const decoded = jwtDecode(token as string) as { [key: string]: any }; - const accessToken = decoded.key; - const userId = decoded.user_id; - const userRole = decoded.user_role; - const premiumUser = decoded.premium_user; + // Redirect after mount if missing/invalid token + useEffect(() => { + if (!token) { + router.replace("/sso/key/generate"); + } + }, [token, router]); - return { accessToken, userId, userRole, premiumUser }; + // Decode safely + const decoded = useMemo(() => { + if (!token) return null; + try { + return jwtDecode(token) as Record; + } catch { + // Bad token in cookie — clear and bounce + clearTokenCookies(); + router.replace("/sso/key/generate"); + return null; + } + }, [token, router]); + + return { + accessToken: decoded?.key ?? null, + userId: decoded?.user_id ?? null, + userRole: decoded?.user_role ?? null, + premiumUser: decoded?.premium_user ?? null, + }; }; export default useAuthorized; diff --git a/ui/litellm-dashboard/src/app/layout.tsx b/ui/litellm-dashboard/src/app/layout.tsx index 8a37d0fa1b..9b6c027a56 100644 --- a/ui/litellm-dashboard/src/app/layout.tsx +++ b/ui/litellm-dashboard/src/app/layout.tsx @@ -18,9 +18,12 @@ export default function RootLayout({ }>) { return ( - - {children} - + + + + {children} + + ); } diff --git a/ui/litellm-dashboard/src/utils/cookieUtils.ts b/ui/litellm-dashboard/src/utils/cookieUtils.ts index 2cc9ef61eb..e477a9f77e 100644 --- a/ui/litellm-dashboard/src/utils/cookieUtils.ts +++ b/ui/litellm-dashboard/src/utils/cookieUtils.ts @@ -37,6 +37,7 @@ export function clearTokenCookies() { * @returns The cookie value or null if not found */ export function getCookie(name: string) { + if (typeof document === "undefined") return null; const cookieValue = document.cookie.split("; ").find((row) => row.startsWith(name + "=")); return cookieValue ? cookieValue.split("=")[1] : null; } From 00f61b214c04c5c78380f74898cdd50cbf2ccdd4 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Mon, 6 Oct 2025 14:17:02 -0700 Subject: [PATCH 11/42] more window guards --- .../VirtualKeysTable/VirtualKeysTable.tsx | 23 ++++++++++--------- ui/litellm-dashboard/src/utils/cookieUtils.ts | 4 ++++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx index b7727a299b..cbf10f9771 100644 --- a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx @@ -105,18 +105,19 @@ const VirtualKeysTable = ({ // Add a useEffect to call refresh when a key is created useEffect(() => { - if (refresh) { - const handleStorageChange = () => { - refresh(); - }; - - // Listen for storage events that might indicate a key was created - window.addEventListener("storage", handleStorageChange); - - return () => { - window.removeEventListener("storage", handleStorageChange); - }; + if (!refresh || typeof window === "undefined") { + return; } + const handleStorageChange = () => { + refresh(); + }; + + // Listen for storage events that might indicate a key was created + window.addEventListener("storage", handleStorageChange); + + return () => { + window.removeEventListener("storage", handleStorageChange); + }; }, [refresh]); const columns: ColumnDef[] = [ diff --git a/ui/litellm-dashboard/src/utils/cookieUtils.ts b/ui/litellm-dashboard/src/utils/cookieUtils.ts index e477a9f77e..23682fd6e8 100644 --- a/ui/litellm-dashboard/src/utils/cookieUtils.ts +++ b/ui/litellm-dashboard/src/utils/cookieUtils.ts @@ -6,6 +6,10 @@ * Clears the token cookie from both root and /ui paths */ export function clearTokenCookies() { + if (typeof window === "undefined" || typeof document === "undefined") { + return; + } + // Get the current domain const domain = window.location.hostname; From b1f3553ae4b7b0140540eb910c540d6e77f1a863 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Mon, 6 Oct 2025 14:28:12 -0700 Subject: [PATCH 12/42] Update networking.tsx --- .../src/components/networking.tsx | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 5f27c277d4..2ce673ee54 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -34,18 +34,34 @@ if (isLocal != true) { console.log = function () {}; } +const getWindowLocation = () => { + if (typeof window === "undefined") { + return null; + } + return window.location; +}; + const updateProxyBaseUrl = (serverRootPath: string, receivedProxyBaseUrl: string | null = null) => { /** * Special function for updating the proxy base url. Should only be called by getUiConfig. */ - const defaultProxyBaseUrl = isLocal ? "http://localhost:4000" : window.location.origin; - let initialProxyBaseUrl = receivedProxyBaseUrl || defaultProxyBaseUrl; + const browserLocation = getWindowLocation(); + const resolvedDefaultProxyBaseUrl = isLocal ? "http://localhost:4000" : browserLocation?.origin ?? null; + let initialProxyBaseUrl = receivedProxyBaseUrl || resolvedDefaultProxyBaseUrl; console.log("proxyBaseUrl:", proxyBaseUrl); console.log("serverRootPath:", serverRootPath); + + if (!initialProxyBaseUrl) { + proxyBaseUrl = proxyBaseUrl ?? null; + console.log("Updated proxyBaseUrl:", proxyBaseUrl); + return; + } + if (serverRootPath.length > 0 && !initialProxyBaseUrl.endsWith(serverRootPath) && serverRootPath != "/") { initialProxyBaseUrl += serverRootPath; - proxyBaseUrl = initialProxyBaseUrl; } + + proxyBaseUrl = initialProxyBaseUrl; console.log("Updated proxyBaseUrl:", proxyBaseUrl); }; @@ -54,7 +70,11 @@ const updateServerRootPath = (receivedServerRootPath: string) => { }; export const getProxyBaseUrl = (): string => { - return proxyBaseUrl ? proxyBaseUrl : window.location.origin; + if (proxyBaseUrl) { + return proxyBaseUrl; + } + const browserLocation = getWindowLocation(); + return browserLocation?.origin ?? ""; }; const HTTP_REQUEST = { @@ -159,7 +179,10 @@ const handleError = async (errorData: string) => { NotificationsManager.info("UI Session Expired. Logging out."); lastErrorTime = currentTime; clearTokenCookies(); - window.location.href = window.location.pathname; + const browserLocation = getWindowLocation(); + if (browserLocation) { + window.location.href = browserLocation.pathname; + } } lastErrorTime = currentTime; } else { From 6529ed4d620210efccc51912e413cb1ac4333041 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Mon, 6 Oct 2025 19:21:34 -0700 Subject: [PATCH 13/42] hidden dashboard routing, dev and build env files --- ui/litellm-dashboard/.env.development | 2 + ui/litellm-dashboard/.env.production | 2 + .../components/Sidebar2.tsx | 33 ++++++-- .../components/SidebarProvider.tsx | 32 +++++++ .../components/modals/CreateUserModal.tsx | 6 +- .../hooks/useAuthorized.ts | 0 .../app/{dashboard => (dashboard)}/layout.tsx | 4 +- .../virtual-keys/components/CreateKey.tsx | 6 +- .../CreateKeyForm/KeyDetailsSection.tsx | 0 .../CreateKeyForm/OptionalSettingsSection.tsx | 2 +- .../CreateKeyForm/OwnershipSection.tsx | 2 +- .../CreateKeyModal/CreateKeyModal.tsx | 20 ++--- .../components/CreateKeyModal/hooks/index.ts | 0 .../hooks/useGuardrailsAndPrompts.ts | 4 +- .../hooks/useMcpAccessGroups.ts | 4 +- .../CreateKeyModal/hooks/useTeamModels.ts | 4 +- .../CreateKeyModal/hooks/useUserModels.ts | 4 +- .../CreateKeyModal/hooks/useUserSearch.ts | 4 +- .../components/CreateKeyModal/networking.ts | 2 +- .../components/CreateKeyModal/types.ts | 0 .../components/CreateKeyModal/utils.ts | 2 +- .../virtual-keys/components/KeyInfoView.tsx | 0 .../virtual-keys/components/SaveKeyModal.tsx | 0 .../VirtualKeysTable/VirtualKeysTable.tsx | 6 +- .../VirtualKeysTable/hooks/useFilterLogic.ts | 0 .../virtual-keys/hooks/useTeams.tsx | 4 +- .../virtual-keys/networking.ts | 0 .../virtual-keys/page.tsx | 8 +- .../dashboard/components/SidebarProvider.tsx | 36 -------- ui/litellm-dashboard/src/app/page.tsx | 8 +- .../src/components/leftnav.tsx | 37 +++++---- .../organisms/create_key_button.tsx | 3 +- .../components/templates/key_info_view.tsx | 2 +- .../components/templates/view_key_table.tsx | 2 +- .../src/hooks/useFeatureFlags.tsx | 83 ++++++++++++++++--- 35 files changed, 208 insertions(+), 114 deletions(-) create mode 100644 ui/litellm-dashboard/.env.development create mode 100644 ui/litellm-dashboard/.env.production rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/components/Sidebar2.tsx (87%) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/components/modals/CreateUserModal.tsx (98%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/hooks/useAuthorized.ts (100%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/layout.tsx (91%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/CreateKey.tsx (92%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx (100%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx (99%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx (97%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx (89%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/CreateKeyModal/hooks/index.ts (100%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts (76%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts (73%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts (85%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts (75%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts (84%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/CreateKeyModal/networking.ts (95%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/CreateKeyModal/types.ts (100%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/CreateKeyModal/utils.ts (98%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/KeyInfoView.tsx (100%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/SaveKeyModal.tsx (100%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx (99%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts (100%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/hooks/useTeams.tsx (77%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/networking.ts (100%) rename ui/litellm-dashboard/src/app/{dashboard => (dashboard)}/virtual-keys/page.tsx (81%) delete mode 100644 ui/litellm-dashboard/src/app/dashboard/components/SidebarProvider.tsx diff --git a/ui/litellm-dashboard/.env.development b/ui/litellm-dashboard/.env.development new file mode 100644 index 0000000000..b408125408 --- /dev/null +++ b/ui/litellm-dashboard/.env.development @@ -0,0 +1,2 @@ +NODE_ENV=development +NEXT_PUBLIC_BASE_URL="" \ No newline at end of file diff --git a/ui/litellm-dashboard/.env.production b/ui/litellm-dashboard/.env.production new file mode 100644 index 0000000000..1df897e7d2 --- /dev/null +++ b/ui/litellm-dashboard/.env.production @@ -0,0 +1,2 @@ +NODE_ENV=production +NEXT_PUBLIC_BASE_URL="ui/" \ No newline at end of file diff --git a/ui/litellm-dashboard/src/app/dashboard/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx similarity index 87% rename from ui/litellm-dashboard/src/app/dashboard/components/Sidebar2.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index 9de9f2f136..209cf2e67f 100644 --- a/ui/litellm-dashboard/src/app/dashboard/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -46,6 +46,21 @@ interface MenuItem { icon?: React.ReactNode; } +/** ---------- Base URL helpers ---------- */ +/** Normalizes NEXT_PUBLIC_BASE_URL into either "" or "/something" (no trailing slash). */ +const getBasePath = () => { + const raw = process.env.NEXT_PUBLIC_BASE_URL ?? ""; + const trimmed = raw.replace(/^\/+|\/+$/g, ""); // strip leading/trailing slashes + return trimmed ? `/${trimmed}` : ""; +}; + +/** Joins base path with a relative path like "virtual-keys" or "/virtual-keys" -> "/base/virtual-keys" */ +const withBase = (relativePath: string) => { + const base = getBasePath(); // "" or "/ui" (no trailing slash) + const rel = relativePath.replace(/^\/+/, ""); // drop any leading slash + return `${base}/${rel}`.replace(/\/{2,}/g, "/"); // collapse accidental doubles +}; + const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => { const menuItems: MenuItem[] = [ { key: "1", page: "api-keys", label: "Virtual Keys", icon: }, @@ -217,8 +232,11 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect return "1"; }; + // Match Virtual Keys path with base prefix (e.g., "/virtual-keys" or "/ui/virtual-keys") + const virtualKeysPath = withBase("virtual-keys"); + const selectedMenuKey = - pathname === "/dashboard/virtual-keys" + pathname === virtualKeysPath ? "1" : pageParam ? findMenuItemKey(pageParam) @@ -226,16 +244,19 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect ? findMenuItemKey(defaultSelectedKey) : "1"; - // Root-only routing helper: always replace everything after the domain - const rootWithPage = (p: string) => ({ pathname: "/", query: { page: p } }); + // Root-only routing helper: always replace everything after the domain, honoring base path + const rootWithPage = (p: string) => ({ + pathname: getBasePath() || "/", + query: { page: p }, + }); // Convert to AntD Menu items: - // - "Virtual Keys" still routes to /virtual-keys - // - All other items (and children) route to "/?page=" + // - "Virtual Keys" routes to "//virtual-keys" + // - All other items (and children) route to "//?page=" const antdItems = filteredMenuItems.map((item) => { const isVirtualKeys = item.key === "1"; const label = isVirtualKeys ? ( - Virtual Keys + Virtual Keys ) : ( {item.label} ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx new file mode 100644 index 0000000000..822a30e640 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx @@ -0,0 +1,32 @@ +"use client"; + +import Sidebar2 from "@/app/(dashboard)/components/Sidebar2"; +import Sidebar from "@/components/leftnav"; +import React from "react"; +import useFeatureFlags from "@/hooks/useFeatureFlags"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +interface SidebarProviderProps { + defaultSelectedKey: string; + sidebarCollapsed: boolean; + setPage: (page: string) => void; +} + +const SidebarProvider = ({ defaultSelectedKey, sidebarCollapsed, setPage }: SidebarProviderProps) => { + const { accessToken, userRole } = useAuthorized(); + const { refactoredUIFlag, setRefactoredUIFlag } = useFeatureFlags(); + + return refactoredUIFlag ? ( + + ) : ( + + ); +}; + +export default SidebarProvider; diff --git a/ui/litellm-dashboard/src/app/dashboard/components/modals/CreateUserModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/modals/CreateUserModal.tsx similarity index 98% rename from ui/litellm-dashboard/src/app/dashboard/components/modals/CreateUserModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/components/modals/CreateUserModal.tsx index 8b16074b25..fce7f69192 100644 --- a/ui/litellm-dashboard/src/app/dashboard/components/modals/CreateUserModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/modals/CreateUserModal.tsx @@ -29,9 +29,9 @@ import { } from "@/components/networking"; import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; import BulkCreateUsersButton from "@/components/bulk_create_users_button"; -import { fetchTeams } from "@/app/dashboard/virtual-keys/networking"; -import useTeams from "@/app/dashboard/virtual-keys/hooks/useTeams"; -import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; +import { fetchTeams } from "@/app/(dashboard)/virtual-keys/networking"; +import useTeams from "@/app/(dashboard)/virtual-keys/hooks/useTeams"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; // Helper function to generate UUID compatible across all environments const generateUUID = (): string => { diff --git a/ui/litellm-dashboard/src/app/dashboard/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts similarity index 100% rename from ui/litellm-dashboard/src/app/dashboard/hooks/useAuthorized.ts rename to ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts diff --git a/ui/litellm-dashboard/src/app/dashboard/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx similarity index 91% rename from ui/litellm-dashboard/src/app/dashboard/layout.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 851ecec665..ad8a82703f 100644 --- a/ui/litellm-dashboard/src/app/dashboard/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -3,8 +3,8 @@ import React from "react"; import Navbar from "@/components/navbar"; import { ThemeProvider } from "@/contexts/ThemeContext"; -import Sidebar2 from "@/app/dashboard/components/Sidebar2"; -import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; +import Sidebar2 from "@/app/(dashboard)/components/Sidebar2"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function Layout({ children }: { children: React.ReactNode }) { const { accessToken, userRole } = useAuthorized(); diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKey.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKey.tsx similarity index 92% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKey.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKey.tsx index 15dd713917..82be28e495 100644 --- a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKey.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKey.tsx @@ -6,9 +6,9 @@ import { Modal, Form } from "antd"; import { getPossibleUserRoles, Organization } from "@/components/networking"; import { rolesWithWriteAccess } from "@/utils/roles"; import { Team } from "@/components/key_team_helpers/key_list"; -import CreateKeyModal from "@/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyModal"; -import CreateUserModal from "@/app/dashboard/components/modals/CreateUserModal"; -import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; +import CreateKeyModal from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyModal"; +import CreateUserModal from "@/app/(dashboard)/components/modals/CreateUserModal"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; interface CreateKeyProps { team: Team | null; diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx similarity index 99% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx index 65ebd2d571..b88f891944 100644 --- a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx @@ -17,7 +17,7 @@ import SchemaFormFields from "@/components/common_components/check_openapi_schem import { Team } from "@/components/key_team_helpers/key_list"; import React from "react"; import { DefaultOptionType } from "rc-select/lib/Select"; -import { ModelAliases } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/types"; +import { ModelAliases } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/types"; export interface OptionalSettingsSectionProps { form: FormInstance; diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx similarity index 97% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx index 00c2e9a1e2..f08b4ea367 100644 --- a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx @@ -6,7 +6,7 @@ import { InfoCircleOutlined } from "@ant-design/icons"; import TeamDropdown from "@/components/common_components/team_dropdown"; import React from "react"; import { Team } from "@/components/key_team_helpers/key_list"; -import { UserOption } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/types"; +import { UserOption } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/types"; interface OwnershipSectionProps { team: Team | null; diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx similarity index 89% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx index 2bee7f72b1..cbe3db4e8f 100644 --- a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx @@ -5,7 +5,7 @@ import { Text } from "@tremor/react"; import { keyCreateCall, keyCreateServiceAccountCall } from "@/components/networking"; import React, { useEffect, useState } from "react"; import { Team } from "@/components/key_team_helpers/key_list"; -import SaveKeyModal from "@/app/dashboard/virtual-keys/components/SaveKeyModal"; +import SaveKeyModal from "@/app/(dashboard)/virtual-keys/components/SaveKeyModal"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { useGuardrailsAndPrompts, @@ -13,15 +13,15 @@ import { useTeamModels, useUserModels, useUserSearch, -} from "@/app/dashboard/virtual-keys/components/CreateKeyModal/hooks"; -import OwnershipSection from "@/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection"; -import KeyDetailsSection from "@/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection"; -import OptionalSettingsSection from "@/app/dashboard/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection"; -import { ModelAliases } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/types"; -import { getPredefinedTags, prepareFormValues } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/utils"; -import { fetchTeams } from "@/app/dashboard/virtual-keys/networking"; -import useTeams from "@/app/dashboard/virtual-keys/hooks/useTeams"; -import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; +} from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks"; +import OwnershipSection from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection"; +import KeyDetailsSection from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection"; +import OptionalSettingsSection from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection"; +import { ModelAliases } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/types"; +import { getPredefinedTags, prepareFormValues } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/utils"; +import { fetchTeams } from "@/app/(dashboard)/virtual-keys/networking"; +import useTeams from "@/app/(dashboard)/virtual-keys/hooks/useTeams"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export interface CreateKeyModalProps { isModalVisible: boolean; diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/index.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/index.ts similarity index 100% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/index.ts rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/index.ts diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts similarity index 76% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts index 2445240e1e..f4718e8ba0 100644 --- a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { fetchGuardrails, fetchPrompts } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/networking"; -import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; +import { fetchGuardrails, fetchPrompts } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export const useGuardrailsAndPrompts = () => { const [guardrails, setGuardrails] = useState([]); diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts similarity index 73% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts index 5007040000..998b392f77 100644 --- a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { getMCPAccessGroups } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/networking"; -import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; +import { getMCPAccessGroups } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export const useMcpAccessGroups = () => { const [mcpAccessGroups, setMcpAccessGroups] = useState([]); diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts similarity index 85% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts index 979337f5aa..dd99de0615 100644 --- a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import type { Team } from "@/components/key_team_helpers/key_list"; -import { fetchTeamModels } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/networking"; -import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; +import { fetchTeamModels } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export const useTeamModels = (selectedTeam: Team | null) => { const { userId: userID, userRole, accessToken } = useAuthorized(); diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts similarity index 75% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts index badcaf93d9..66b5520f06 100644 --- a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { getUserModelNames } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/networking"; -import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; +import { getUserModelNames } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export const useUserModels = () => { const { userId: userID, userRole, accessToken } = useAuthorized(); diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts similarity index 84% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts index a3840bdc62..d266a51667 100644 --- a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { debounce } from "lodash"; -import { searchUserOptionsByEmail } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/networking"; -import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; +import { searchUserOptionsByEmail } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; type User = { user_id: string; user_email: string; role?: string }; export interface UserOption { diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/networking.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking.ts similarity index 95% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/networking.ts rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking.ts index 2277800dad..377f6d3113 100644 --- a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/networking.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking.ts @@ -6,7 +6,7 @@ import { User, userFilterUICall, } from "@/components/networking"; -import { ModelAvailableResponse, UserOption } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/types"; +import { ModelAvailableResponse, UserOption } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/types"; export const fetchGuardrails = async (accessToken: string) => { try { diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/types.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/types.ts similarity index 100% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/types.ts rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/types.ts diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/utils.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/utils.ts similarity index 98% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/utils.ts rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/utils.ts index 73cd433e78..1fb40b8789 100644 --- a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/CreateKeyModal/utils.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/utils.ts @@ -1,5 +1,5 @@ import { mapDisplayToInternalNames } from "@/components/callback_info_helpers"; -import { ModelAliases } from "@/app/dashboard/virtual-keys/components/CreateKeyModal/types"; +import { ModelAliases } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/types"; export const getPredefinedTags = (data: any[] | null) => { let allTags = []; diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/KeyInfoView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/KeyInfoView.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/KeyInfoView.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/KeyInfoView.tsx diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/SaveKeyModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/SaveKeyModal.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/SaveKeyModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/SaveKeyModal.tsx diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx similarity index 99% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx index cbf10f9771..015d8c43f9 100644 --- a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx @@ -16,9 +16,9 @@ import { Organization, userListCall } from "@/components/networking"; import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; import FilterComponent, { FilterOption } from "@/components/molecules/filter"; import { useFilterLogic } from "@/components/key_team_helpers/filter_logic"; -import useTeams from "@/app/dashboard/virtual-keys/hooks/useTeams"; -import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; -import KeyInfoView from "@/app/dashboard/virtual-keys/components/KeyInfoView"; +import useTeams from "@/app/(dashboard)/virtual-keys/hooks/useTeams"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import KeyInfoView from "@/app/(dashboard)/virtual-keys/components/KeyInfoView"; interface AllKeysTableProps { keys: KeyResponse[]; diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts similarity index 100% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/hooks/useTeams.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/hooks/useTeams.tsx similarity index 77% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/hooks/useTeams.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/hooks/useTeams.tsx index 92e5896e76..5f04f8a28a 100644 --- a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/hooks/useTeams.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/hooks/useTeams.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; -import { fetchTeams } from "@/app/dashboard/virtual-keys/networking"; +import { fetchTeams } from "@/app/(dashboard)/virtual-keys/networking"; import { Team } from "@/components/key_team_helpers/key_list"; -import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; const useTeams = () => { const [teams, setTeams] = useState([]); diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/networking.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/networking.ts similarity index 100% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/networking.ts rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/networking.ts diff --git a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/page.tsx similarity index 81% rename from ui/litellm-dashboard/src/app/dashboard/virtual-keys/page.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/page.tsx index 7dfc959867..611d92f1a3 100644 --- a/ui/litellm-dashboard/src/app/dashboard/virtual-keys/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/page.tsx @@ -1,15 +1,15 @@ "use client"; -import VirtualKeysTable from "@/app/dashboard/virtual-keys/components/VirtualKeysTable/VirtualKeysTable"; +import VirtualKeysTable from "@/app/(dashboard)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable"; import { useState } from "react"; import useKeyList from "@/components/key_team_helpers/key_list"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Col, Grid } from "@tremor/react"; -import CreateKey from "@/app/dashboard/virtual-keys/components/CreateKey"; -import useAuthorized from "@/app/dashboard/hooks/useAuthorized" +import CreateKey from "@/app/(dashboard)/virtual-keys/components/CreateKey"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; const VirtualKeysPage = () => { - const {accessToken, userRole} = useAuthorized(); + const { accessToken, userRole } = useAuthorized(); const [createClicked, setCreateClicked] = useState(false); const queryClient = new QueryClient(); diff --git a/ui/litellm-dashboard/src/app/dashboard/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/dashboard/components/SidebarProvider.tsx deleted file mode 100644 index 8380493798..0000000000 --- a/ui/litellm-dashboard/src/app/dashboard/components/SidebarProvider.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import Sidebar2 from "@/app/dashboard/components/Sidebar2"; -import Sidebar from "@/components/leftnav"; -import React from "react"; -import useFeatureFlags from "@/hooks/useFeatureFlags"; -import useAuthorized from "@/app/dashboard/hooks/useAuthorized"; - -interface SidebarProviderProps { - defaultSelectedKey: string; - sidebarCollapsed: boolean; - setPage: (page: string) => void; -} - -const SidebarProvider = ({defaultSelectedKey, sidebarCollapsed, setPage}: SidebarProviderProps) => { - - const {accessToken, userRole} = useAuthorized(); - const { refactoredUIFlag, setRefactoredUIFlag } = useFeatureFlags(); - - return ( - refactoredUIFlag ? ( - - ) : ( - - ) - ); - -} - -export default SidebarProvider; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 3815df002b..8e25dc192c 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -40,8 +40,8 @@ import UIThemeSettings from "@/components/ui_theme_settings"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { cx } from "@/lib/cva.config"; import useFeatureFlags, { FeatureFlagsProvider } from "@/hooks/useFeatureFlags"; -import Sidebar2 from "@/app/dashboard/components/Sidebar2"; -import SidebarProvider from "@/app/dashboard/components/SidebarProvider"; +import Sidebar2 from "@/app/(dashboard)/components/Sidebar2"; +import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; function getCookie(name: string) { const cookieValue = document.cookie.split("; ").find((row) => row.startsWith(name + "=")); @@ -126,6 +126,10 @@ export default function CreateKeyPage() { return searchParams.get("page") || "api-keys"; }); + useEffect(() => { + setPage(searchParams.get("page") || "api-keys"); + }, [searchParams]); + // Custom setPage function that updates URL const updatePage = (newPage: string) => { // Update URL without full page reload diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 34916cddb9..a0d8d162c6 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -56,6 +56,17 @@ interface MenuItem { icon?: React.ReactNode; } +/** ---------- Base URL helpers ---------- */ +/** + * Normalizes NEXT_PUBLIC_BASE_URL to either "/" or "/ui/" (always with a trailing slash). + * Supported env values: "" or "ui/". + */ +const getBasePath = () => { + const raw = process.env.NEXT_PUBLIC_BASE_URL ?? ""; + const trimmed = raw.replace(/^\/+|\/+$/g, ""); // strip leading/trailing slashes + return trimmed ? `/${trimmed}/` : "/"; // ensure trailing slash +}; + const Sidebar: React.FC = ({ accessToken, setPage, userRole, defaultSelectedKey, collapsed = false }) => { // Note: If a menu item does not have a role, it is visible to all roles. const menuItems: MenuItem[] = [ @@ -214,6 +225,7 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau ], }, ]; + // Find the menu item that matches the default page, including in submenus const findMenuItemKey = (page: string): string => { // Check top-level items @@ -248,6 +260,15 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau return true; }); + // Centralized navigation that prefixes the base path ("/" or "/ui/") + const goTo = (page: string) => { + const base = getBasePath(); // "/" or "/ui/" + const newSearchParams = new URLSearchParams(window.location.search); + newSearchParams.set("page", page); + window.history.pushState(null, "", `${base}?${newSearchParams.toString()}`); // e.g. "/ui/?page=..." + setPage(page); + }; + return ( = ({ accessToken, setPage, userRole, defau key: child.key, icon: child.icon, label: child.label, - onClick: () => { - const newSearchParams = new URLSearchParams(window.location.search); - newSearchParams.set("page", child.page); - window.history.pushState(null, "", `?${newSearchParams.toString()}`); - setPage(child.page); - }, + onClick: () => goTo(child.page), })), - onClick: !item.children - ? () => { - const newSearchParams = new URLSearchParams(window.location.search); - newSearchParams.set("page", item.page); - window.history.pushState(null, "", `?${newSearchParams.toString()}`); - setPage(item.page); - } - : undefined, + onClick: !item.children ? () => goTo(item.page) : undefined, }))} /> diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index d4035e83f0..c37d94abb1 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -131,11 +131,10 @@ export const fetchUserModels = async ( } }; - /** * ───────────────────────────────────────────────────────────────────────── * @deprecated - * This component is being DEPRECATED in favor of src/app/dashboard/virtual-keys/components/CreateKey.tsx + * This component is being DEPRECATED in favor of src/app/(dashboard)/virtual-keys/components/CreateKey.tsx * Please contribute to the new refactor. * ───────────────────────────────────────────────────────────────────────── */ diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index ba04e2644a..26adaa11db 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -50,7 +50,7 @@ interface KeyInfoViewProps { /** * ───────────────────────────────────────────────────────────────────────── * @deprecated - * This component is being DEPRECATED in favor of src/app/dashboard/virtual-keys/components/KeyInfoView.tsx + * This component is being DEPRECATED in favor of src/app/(dashboard)/virtual-keys/components/KeyInfoView.tsx * Please contribute to the new refactor. * ───────────────────────────────────────────────────────────────────────── */ diff --git a/ui/litellm-dashboard/src/components/templates/view_key_table.tsx b/ui/litellm-dashboard/src/components/templates/view_key_table.tsx index 3fad7c0a35..d146d5ba79 100644 --- a/ui/litellm-dashboard/src/components/templates/view_key_table.tsx +++ b/ui/litellm-dashboard/src/components/templates/view_key_table.tsx @@ -138,7 +138,7 @@ interface CombinedLimits { /** * ───────────────────────────────────────────────────────────────────────── * @deprecated - * This component is being DEPRECATED in favor of src/app/dashboard/virtual-keys/components/VirtualKeysTable/ + * This component is being DEPRECATED in favor of src/app/(dashboard)/virtual-keys/components/VirtualKeysTable/ * Please contribute to the new refactor. * ───────────────────────────────────────────────────────────────────────── */ diff --git a/ui/litellm-dashboard/src/hooks/useFeatureFlags.tsx b/ui/litellm-dashboard/src/hooks/useFeatureFlags.tsx index d50fdf46a6..f1914f463a 100644 --- a/ui/litellm-dashboard/src/hooks/useFeatureFlags.tsx +++ b/ui/litellm-dashboard/src/hooks/useFeatureFlags.tsx @@ -1,26 +1,87 @@ -'use client'; -import React, {createContext, useContext, useState} from 'react'; +"use client"; +import React, { createContext, useContext, useEffect, useState } from "react"; type Flags = { refactoredUIFlag: boolean; setRefactoredUIFlag: (v: boolean) => void; }; +const STORAGE_KEY = "feature.refactoredUIFlag"; + const FeatureFlagsCtx = createContext(null); -export const FeatureFlagsProvider = ({ children }: { children: React.ReactNode }) => { - const [refactoredUIFlag, setRefactoredUIFlag] = useState(false); - return ( - - {children} - -); +/** Safely read the flag from localStorage. If anything goes wrong, reset to false. */ +function readFlagSafely(): boolean { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === null) { + localStorage.setItem(STORAGE_KEY, "false"); + return false; + } + + const v = raw.trim().toLowerCase(); + if (v === "true" || v === "1") return true; + if (v === "false" || v === "0") return false; + + // Last chance: try JSON.parse in case something odd was stored. + const parsed = JSON.parse(raw); + if (typeof parsed === "boolean") return parsed; + + // Malformed → reset to false + localStorage.setItem(STORAGE_KEY, "false"); + return false; + } catch { + // If even accessing localStorage throws, best effort reset then default to false + try { + localStorage.setItem(STORAGE_KEY, "false"); + } catch {} + return false; + } } +function writeFlagSafely(v: boolean) { + try { + localStorage.setItem(STORAGE_KEY, String(v)); + } catch { + // Ignore write errors; state will still reflect the intended value. + } +} + +export const FeatureFlagsProvider = ({ children }: { children: React.ReactNode }) => { + // Lazy init reads from localStorage only on the client + const [refactoredUIFlag, setRefactoredUIFlagState] = useState(() => readFlagSafely()); + + const setRefactoredUIFlag = (v: boolean) => { + setRefactoredUIFlagState(v); + writeFlagSafely(v); + }; + + // Keep this flag in sync across tabs/windows. + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEY && e.newValue != null) { + const next = e.newValue.trim().toLowerCase(); + setRefactoredUIFlagState(next === "true" || next === "1"); + } + // If the key was cleared elsewhere, self-heal to false. + if (e.key === STORAGE_KEY && e.newValue === null) { + writeFlagSafely(false); + setRefactoredUIFlagState(false); + } + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); + + return ( + {children} + ); +}; + const useFeatureFlags = () => { const ctx = useContext(FeatureFlagsCtx); - if (!ctx) throw new Error('useFeatureFlags must be used within FeatureFlagsProvider'); + if (!ctx) throw new Error("useFeatureFlags must be used within FeatureFlagsProvider"); return ctx; -} +}; export default useFeatureFlags; From eed19fb4abc6f1c68fa4680acf9436f8fab06869 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Mon, 6 Oct 2025 19:36:40 -0700 Subject: [PATCH 14/42] slight improvement for Sidebar2 --- .../app/(dashboard)/components/Sidebar2.tsx | 60 ++++++++++++------- ui/litellm-dashboard/src/app/page.tsx | 1 - 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index 209cf2e67f..37901b5e63 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -1,8 +1,7 @@ "use client"; import React from "react"; -import Link from "next/link"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname, useSearchParams, useRouter } from "next/navigation"; import { Layout, Menu, ConfigProvider } from "antd"; import { KeyOutlined, @@ -62,6 +61,11 @@ const withBase = (relativePath: string) => { }; const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + /** ---------- Menu model ---------- */ const menuItems: MenuItem[] = [ { key: "1", page: "api-keys", label: "Virtual Keys", icon: }, { @@ -217,9 +221,7 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect children: item.children?.filter((child) => !child.roles || child.roles.includes(userRole)), })); - // Highlight selection based on pathname or ?page= - const pathname = usePathname(); - const searchParams = useSearchParams(); + /** ---------- Selection state ---------- */ const pageParam = searchParams.get("page") || undefined; const findMenuItemKey = (page: string): string => { @@ -244,31 +246,47 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect ? findMenuItemKey(defaultSelectedKey) : "1"; - // Root-only routing helper: always replace everything after the domain, honoring base path - const rootWithPage = (p: string) => ({ - pathname: getBasePath() || "/", - query: { page: p }, - }); + /** ---------- Navigation helpers (SPA only) ---------- */ + // Build a root URL ("/" or "/base/") with an updated ?page=... + const goTo = (p: string) => { + const base = getBasePath() || "/"; + const root = base.endsWith("/") ? base : `${base}/`; + const sp = new URLSearchParams(typeof window !== "undefined" ? window.location.search : ""); + sp.set("page", p); + // Use Next router for client navigation on the SAME route (no hard fetch) + router.replace(`${root}?${sp.toString()}`, { scroll: false }); + }; - // Convert to AntD Menu items: - // - "Virtual Keys" routes to "//virtual-keys" - // - All other items (and children) route to "//?page=" + // Keep the /virtual-keys path the same, but avoid route fetches/hard reloads. + const goToVirtualKeys = () => { + const base = getBasePath() || "/"; + const root = base.endsWith("/") ? base : `${base}/`; + const sp = new URLSearchParams(typeof window !== "undefined" ? window.location.search : ""); + sp.set("page", "api-keys"); + + // 1) Client transition to the root with ?page=api-keys so the view updates. + router.replace(`${root}?${sp.toString()}`, { scroll: false }); + + // 2) Cosmetic URL swap to ".../virtual-keys" without navigation (keeps path the same). + const vk = withBase("virtual-keys"); + if (typeof window !== "undefined") { + window.history.replaceState(null, "", vk); + } + }; + + /** ---------- AntD items with onClick handlers ---------- */ const antdItems = filteredMenuItems.map((item) => { const isVirtualKeys = item.key === "1"; - const label = isVirtualKeys ? ( - Virtual Keys - ) : ( - {item.label} - ); - return { key: item.key, icon: item.icon, - label, + label: item.label, // plain text; click handled via onClick + onClick: !item.children ? (isVirtualKeys ? goToVirtualKeys : () => goTo(item.page)) : undefined, children: item.children?.map((child) => ({ key: child.key, icon: child.icon, - label: {child.label}, + label: child.label, + onClick: () => goTo(child.page), })), }; }); diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 8e25dc192c..104ef41810 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -39,7 +39,6 @@ import VectorStoreManagement from "@/components/vector_store_management"; import UIThemeSettings from "@/components/ui_theme_settings"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { cx } from "@/lib/cva.config"; -import useFeatureFlags, { FeatureFlagsProvider } from "@/hooks/useFeatureFlags"; import Sidebar2 from "@/app/(dashboard)/components/Sidebar2"; import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; From 051c9a7c6192f5179e867ace63358f1bb67816a6 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Mon, 6 Oct 2025 20:17:19 -0700 Subject: [PATCH 15/42] Sidebar2 uses FF now --- .../app/(dashboard)/components/Sidebar2.tsx | 247 ++++++++---------- .../components/SidebarProvider.tsx | 32 --- .../src/app/(dashboard)/layout.tsx | 26 +- ui/litellm-dashboard/src/app/page.tsx | 18 +- 4 files changed, 141 insertions(+), 182 deletions(-) delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index 37901b5e63..843a176f6e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -1,8 +1,5 @@ -"use client"; - -import React from "react"; -import { usePathname, useSearchParams, useRouter } from "next/navigation"; -import { Layout, Menu, ConfigProvider } from "antd"; +import { Layout, Menu } from "antd"; +import { useRouter, usePathname } from "next/navigation"; // UPDATED: also usePathname to detect current route import { KeyOutlined, PlayCircleOutlined, @@ -23,16 +20,18 @@ import { TagsOutlined, BgColorsOutlined, } from "@ant-design/icons"; +import { ConfigProvider } from "antd"; +import useFeatureFlags from "@/hooks/useFeatureFlags"; import { all_admin_roles, internalUserRoles, isAdminRole, rolesWithWriteAccess } from "@/utils/roles"; import UsageIndicator from "@/components/usage_indicator"; - +import React from "react"; const { Sider } = Layout; interface SidebarProps { accessToken: string | null; + setPage: (page: string) => void; userRole: string; - /** Used to highlight a menu item when pathname doesn't match (e.g., on non-routed pages) */ - defaultSelectedKey?: string; + defaultSelectedKey: string; collapsed?: boolean; } @@ -45,87 +44,77 @@ interface MenuItem { icon?: React.ReactNode; } -/** ---------- Base URL helpers ---------- */ -/** Normalizes NEXT_PUBLIC_BASE_URL into either "" or "/something" (no trailing slash). */ -const getBasePath = () => { - const raw = process.env.NEXT_PUBLIC_BASE_URL ?? ""; - const trimmed = raw.replace(/^\/+|\/+$/g, ""); // strip leading/trailing slashes - return trimmed ? `/${trimmed}` : ""; -}; - -/** Joins base path with a relative path like "virtual-keys" or "/virtual-keys" -> "/base/virtual-keys" */ -const withBase = (relativePath: string) => { - const base = getBasePath(); // "" or "/ui" (no trailing slash) - const rel = relativePath.replace(/^\/+/, ""); // drop any leading slash - return `${base}/${rel}`.replace(/\/{2,}/g, "/"); // collapse accidental doubles -}; - -const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => { +const Sidebar2: React.FC = ({ + accessToken, + setPage, + userRole, + defaultSelectedKey, + collapsed = false, +}) => { const router = useRouter(); const pathname = usePathname(); - const searchParams = useSearchParams(); + const { refactoredUIFlag } = useFeatureFlags(); - /** ---------- Menu model ---------- */ const menuItems: MenuItem[] = [ - { key: "1", page: "api-keys", label: "Virtual Keys", icon: }, + { key: "1", page: "api-keys", label: "Virtual Keys", icon: }, { key: "3", page: "llm-playground", label: "Test Key", - icon: , + icon: , roles: rolesWithWriteAccess, }, { key: "2", page: "models", label: "Models + Endpoints", - icon: , + icon: , roles: rolesWithWriteAccess, }, { key: "12", page: "new_usage", label: "Usage", - icon: , + icon: , roles: [...all_admin_roles, ...internalUserRoles], }, - { key: "6", page: "teams", label: "Teams", icon: }, + { key: "6", page: "teams", label: "Teams", icon: }, { key: "17", page: "organizations", label: "Organizations", - icon: , + icon: , roles: all_admin_roles, }, { key: "5", page: "users", label: "Internal Users", - icon: , + icon: , roles: all_admin_roles, }, - { key: "14", page: "api_ref", label: "API Reference", icon: }, - { key: "16", page: "model-hub-table", label: "Model Hub", icon: }, - { key: "15", page: "logs", label: "Logs", icon: }, + { key: "14", page: "api_ref", label: "API Reference", icon: }, + { key: "16", page: "model-hub-table", label: "Model Hub", icon: }, + { key: "15", page: "logs", label: "Logs", icon: }, { key: "11", page: "guardrails", label: "Guardrails", - icon: , + icon: , roles: all_admin_roles, }, { key: "26", page: "tools", label: "Tools", - icon: , + icon: , children: [ - { key: "18", page: "mcp-servers", label: "MCP Servers", icon: }, + { key: "18", page: "mcp-servers", label: "MCP Servers", icon: }, { key: "21", page: "vector-stores", label: "Vector Stores", - icon: , + icon: , roles: all_admin_roles, }, ], @@ -134,162 +123,145 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect key: "experimental", page: "experimental", label: "Experimental", - icon: , + icon: , children: [ { key: "9", page: "caching", label: "Caching", - icon: , + icon: , roles: all_admin_roles, }, { key: "25", page: "prompts", label: "Prompts", - icon: , + icon: , roles: all_admin_roles, }, { key: "10", page: "budgets", label: "Budgets", - icon: , + icon: , roles: all_admin_roles, }, { key: "20", page: "transform-request", label: "API Playground", - icon: , + icon: , roles: [...all_admin_roles, ...internalUserRoles], }, { key: "19", page: "tag-management", label: "Tag Management", - icon: , + icon: , roles: all_admin_roles, }, - { key: "4", page: "usage", label: "Old Usage", icon: }, + { key: "4", page: "usage", label: "Old Usage", icon: }, ], }, { key: "settings", page: "settings", label: "Settings", - icon: , + icon: , roles: all_admin_roles, children: [ { key: "11", page: "general-settings", label: "Router Settings", - icon: , + icon: , roles: all_admin_roles, }, { key: "8", page: "settings", label: "Logging & Alerts", - icon: , + icon: , roles: all_admin_roles, }, { key: "13", page: "admin-panel", label: "Admin Settings", - icon: , + icon: , roles: all_admin_roles, }, { key: "14", page: "ui-theme", label: "UI Theme", - icon: , + icon: , roles: all_admin_roles, }, ], }, ]; - // Role filtering (preserves original visibility behavior) - const filteredMenuItems: MenuItem[] = menuItems - .filter((item) => !item.roles || item.roles.includes(userRole)) - .map((item) => ({ - ...item, - children: item.children?.filter((child) => !child.roles || child.roles.includes(userRole)), - })); - - /** ---------- Selection state ---------- */ - const pageParam = searchParams.get("page") || undefined; - const findMenuItemKey = (page: string): string => { - const top = filteredMenuItems.find((i) => i.page === page); - if (top) return top.key; - for (const i of filteredMenuItems) { - const child = i.children?.find((c) => c.page === page); - if (child) return child.key; + const topLevelItem = menuItems.find((item) => item.page === page); + if (topLevelItem) return topLevelItem.key; + for (const item of menuItems) { + if (item.children) { + const childItem = item.children.find((child) => child.page === page); + if (childItem) return childItem.key; + } } return "1"; }; - // Match Virtual Keys path with base prefix (e.g., "/virtual-keys" or "/ui/virtual-keys") - const virtualKeysPath = withBase("virtual-keys"); + const selectedMenuKey = findMenuItemKey(defaultSelectedKey); - const selectedMenuKey = - pathname === virtualKeysPath - ? "1" - : pageParam - ? findMenuItemKey(pageParam) - : defaultSelectedKey - ? findMenuItemKey(defaultSelectedKey) - : "1"; + const filteredMenuItems = menuItems.filter((item) => { + const hasParentAccess = !item.roles || item.roles.includes(userRole); + if (!hasParentAccess) return false; + if (item.children) { + item.children = item.children.filter((child) => !child.roles || child.roles.includes(userRole)); + } + return true; + }); - /** ---------- Navigation helpers (SPA only) ---------- */ - // Build a root URL ("/" or "/base/") with an updated ?page=... - const goTo = (p: string) => { - const base = getBasePath() || "/"; - const root = base.endsWith("/") ? base : `${base}/`; - const sp = new URLSearchParams(typeof window !== "undefined" ? window.location.search : ""); - sp.set("page", p); - // Use Next router for client navigation on the SAME route (no hard fetch) - router.replace(`${root}?${sp.toString()}`, { scroll: false }); - }; - - // Keep the /virtual-keys path the same, but avoid route fetches/hard reloads. - const goToVirtualKeys = () => { - const base = getBasePath() || "/"; - const root = base.endsWith("/") ? base : `${base}/`; - const sp = new URLSearchParams(typeof window !== "undefined" ? window.location.search : ""); - sp.set("page", "api-keys"); - - // 1) Client transition to the root with ?page=api-keys so the view updates. - router.replace(`${root}?${sp.toString()}`, { scroll: false }); - - // 2) Cosmetic URL swap to ".../virtual-keys" without navigation (keeps path the same). - const vk = withBase("virtual-keys"); - if (typeof window !== "undefined") { - window.history.replaceState(null, "", vk); + // Helper: go to /?page=...; when on /virtual-keys with refactored UI, replace instead of appending + const pushToRootWithPage = (page: string, useReplace = false) => { + const params = new URLSearchParams(); + params.set("page", page); + const url = `/?${params.toString()}`; + if (useReplace) { + router.replace(url); + } else { + router.push(url); } }; - /** ---------- AntD items with onClick handlers ---------- */ - const antdItems = filteredMenuItems.map((item) => { - const isVirtualKeys = item.key === "1"; - return { - key: item.key, - icon: item.icon, - label: item.label, // plain text; click handled via onClick - onClick: !item.children ? (isVirtualKeys ? goToVirtualKeys : () => goTo(item.page)) : undefined, - children: item.children?.map((child) => ({ - key: child.key, - icon: child.icon, - label: child.label, - onClick: () => goTo(child.page), - })), - }; - }); + // Centralized navigation that satisfies the requirement + const navigateToPage = (page: string) => { + // Special-case: Virtual Keys target + if (page === "api-keys") { + if (refactoredUIFlag) { + // Go to the dedicated /virtual-keys page + router.push("/virtual-keys"); + return; // do not call setPage here (parity with previous behavior) + } + // Legacy behavior + pushToRootWithPage(page); + setPage(page); + return; + } + + // All other pages + if (refactoredUIFlag) { + // If currently on /virtual-keys, REPLACE it with /?page=... + const onVirtualKeys = pathname?.startsWith("/virtual-keys"); + pushToRootWithPage(page, onVirtualKeys); + } else { + pushToRootWithPage(page); + } + setPage(page); + }; return ( @@ -300,29 +272,36 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect collapsedWidth={80} collapsible trigger={null} - style={{ - transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", - position: "relative", - }} + style={{ transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", position: "relative" }} > - + ({ + key: item.key, + icon: item.icon, + label: item.label, + children: item.children?.map((child) => ({ + key: child.key, + icon: child.icon, + label: child.label, + onClick: () => { + navigateToPage(child.page); + }, + })), + onClick: !item.children + ? () => { + navigateToPage(item.page); + } + : undefined, + }))} /> - {isAdminRole(userRole) && !collapsed && } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx deleted file mode 100644 index 822a30e640..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import Sidebar2 from "@/app/(dashboard)/components/Sidebar2"; -import Sidebar from "@/components/leftnav"; -import React from "react"; -import useFeatureFlags from "@/hooks/useFeatureFlags"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; - -interface SidebarProviderProps { - defaultSelectedKey: string; - sidebarCollapsed: boolean; - setPage: (page: string) => void; -} - -const SidebarProvider = ({ defaultSelectedKey, sidebarCollapsed, setPage }: SidebarProviderProps) => { - const { accessToken, userRole } = useAuthorized(); - const { refactoredUIFlag, setRefactoredUIFlag } = useFeatureFlags(); - - return refactoredUIFlag ? ( - - ) : ( - - ); -}; - -export default SidebarProvider; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index ad8a82703f..7e60443d24 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -1,14 +1,31 @@ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; import Navbar from "@/components/navbar"; import { ThemeProvider } from "@/contexts/ThemeContext"; import Sidebar2 from "@/app/(dashboard)/components/Sidebar2"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useRouter, useSearchParams } from "next/navigation"; export default function Layout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const searchParams = useSearchParams(); const { accessToken, userRole } = useAuthorized(); const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false); + const [page, setPage] = useState(() => { + return searchParams.get("page") || "api-keys"; + }); + + const updatePage = (newPage: string) => { + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.set("page", newPage); + router.push(`/?${newSearchParams.toString()}`); // absolute, not relative + setPage(newPage); + }; + + useEffect(() => { + setPage(searchParams.get("page") || "api-keys"); + }, [searchParams]); const toggleSidebar = () => setSidebarCollapsed((v) => !v); @@ -31,12 +48,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { />
- +
{children}
diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 104ef41810..d386df4b77 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -1,7 +1,7 @@ "use client"; import React, { Suspense, useEffect, useState } from "react"; -import { useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { jwtDecode } from "jwt-decode"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Team } from "@/components/key_team_helpers/key_list"; @@ -23,7 +23,6 @@ import ModelHubTable from "@/components/model_hub_table"; import NewUsagePage from "@/components/new_usage"; import APIRef from "@/components/api_ref"; import ChatUI from "@/components/chat_ui/ChatUI"; -import Sidebar from "@/components/leftnav"; import Usage from "@/components/usage"; import CacheDashboard from "@/components/cache_dashboard"; import { getUiConfig, proxyBaseUrl, setGlobalLitellmHeaderName } from "@/components/networking"; @@ -40,7 +39,6 @@ import UIThemeSettings from "@/components/ui_theme_settings"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { cx } from "@/lib/cva.config"; import Sidebar2 from "@/app/(dashboard)/components/Sidebar2"; -import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; function getCookie(name: string) { const cookieValue = document.cookie.split("; ").find((row) => row.startsWith(name + "=")); @@ -97,6 +95,7 @@ function LoadingScreen() { } export default function CreateKeyPage() { + const router = useRouter(); const [userRole, setUserRole] = useState(""); const [premiumUser, setPremiumUser] = useState(false); const [disabledPersonalKeyCreation, setDisabledPersonalKeyCreation] = useState(false); @@ -131,13 +130,9 @@ export default function CreateKeyPage() { // Custom setPage function that updates URL const updatePage = (newPage: string) => { - // Update URL without full page reload const newSearchParams = new URLSearchParams(searchParams); newSearchParams.set("page", newPage); - - // Use Next.js router to update URL - window.history.pushState(null, "", `?${newSearchParams.toString()}`); - + router.push(`/?${newSearchParams.toString()}`); // absolute, not relative setPage(newPage); }; @@ -264,7 +259,12 @@ export default function CreateKeyPage() { />
- +
{page == "api-keys" ? ( From cc94fb87b5179936bf02cede86617eb043fbc2b4 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Mon, 6 Oct 2025 20:32:47 -0700 Subject: [PATCH 16/42] added base URL helpers --- .../app/(dashboard)/components/Sidebar2.tsx | 28 +++++++++++++++---- .../src/app/(dashboard)/layout.tsx | 17 ++++++++++- ui/litellm-dashboard/src/app/page.tsx | 20 +++++++++++-- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index 843a176f6e..95907f3def 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -44,6 +44,24 @@ interface MenuItem { icon?: React.ReactNode; } +/** ---- BASE URL HELPERS (shared pattern across files) ---- */ +function normalizeBasePrefix(raw: string | undefined | null): string { + const trimmed = (raw ?? "").trim(); + if (!trimmed) return ""; // no base + // strip leading/trailing slashes then rebuild as "/segment/" + const core = trimmed.replace(/^\/+/, "").replace(/\/+$/, ""); + return core ? `/${core}/` : "/"; +} +const BASE_PREFIX = normalizeBasePrefix(process.env.NEXT_PUBLIC_BASE_URL); +/** Builds an app-relative path under the configured base prefix. */ +function withBase(path: string): string { + // Accepts paths like "/virtual-keys" or "/?page=..." and prefixes with BASE_PREFIX when present. + const body = path.startsWith("/") ? path.slice(1) : path; + const combined = `${BASE_PREFIX}${body}`; + return combined.startsWith("/") ? combined : `/${combined}`; +} +/** -------------------------------------------------------- */ + const Sidebar2: React.FC = ({ accessToken, setPage, @@ -229,7 +247,7 @@ const Sidebar2: React.FC = ({ const pushToRootWithPage = (page: string, useReplace = false) => { const params = new URLSearchParams(); params.set("page", page); - const url = `/?${params.toString()}`; + const url = withBase(`/?${params.toString()}`); if (useReplace) { router.replace(url); } else { @@ -242,8 +260,8 @@ const Sidebar2: React.FC = ({ // Special-case: Virtual Keys target if (page === "api-keys") { if (refactoredUIFlag) { - // Go to the dedicated /virtual-keys page - router.push("/virtual-keys"); + // Go to the dedicated /virtual-keys page (always under base) + router.push(withBase("/virtual-keys")); return; // do not call setPage here (parity with previous behavior) } // Legacy behavior @@ -255,8 +273,8 @@ const Sidebar2: React.FC = ({ // All other pages if (refactoredUIFlag) { // If currently on /virtual-keys, REPLACE it with /?page=... - const onVirtualKeys = pathname?.startsWith("/virtual-keys"); - pushToRootWithPage(page, onVirtualKeys); + const onVirtualKeys = pathname?.startsWith(withBase("/virtual-keys")); + pushToRootWithPage(page, !!onVirtualKeys); } else { pushToRootWithPage(page); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 7e60443d24..3d38a7362b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -7,6 +7,21 @@ import Sidebar2 from "@/app/(dashboard)/components/Sidebar2"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useRouter, useSearchParams } from "next/navigation"; +/** ---- BASE URL HELPERS ---- */ +function normalizeBasePrefix(raw: string | undefined | null): string { + const trimmed = (raw ?? "").trim(); + if (!trimmed) return ""; + const core = trimmed.replace(/^\/+/, "").replace(/\/+$/, ""); + return core ? `/${core}/` : "/"; +} +const BASE_PREFIX = normalizeBasePrefix(process.env.NEXT_PUBLIC_BASE_URL); +function withBase(path: string): string { + const body = path.startsWith("/") ? path.slice(1) : path; + const combined = `${BASE_PREFIX}${body}`; + return combined.startsWith("/") ? combined : `/${combined}`; +} +/** -------------------------------- */ + export default function Layout({ children }: { children: React.ReactNode }) { const router = useRouter(); const searchParams = useSearchParams(); @@ -19,7 +34,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { const updatePage = (newPage: string) => { const newSearchParams = new URLSearchParams(searchParams); newSearchParams.set("page", newPage); - router.push(`/?${newSearchParams.toString()}`); // absolute, not relative + router.push(withBase(`/?${newSearchParams.toString()}`)); // always under BASE setPage(newPage); }; diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index d386df4b77..b110858349 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -40,6 +40,21 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { cx } from "@/lib/cva.config"; import Sidebar2 from "@/app/(dashboard)/components/Sidebar2"; +/** ---- BASE URL HELPERS ---- */ +function normalizeBasePrefix(raw: string | undefined | null): string { + const trimmed = (raw ?? "").trim(); + if (!trimmed) return ""; + const core = trimmed.replace(/^\/+/, "").replace(/\/+$/, ""); + return core ? `/${core}/` : "/"; +} +const BASE_PREFIX = normalizeBasePrefix(process.env.NEXT_PUBLIC_BASE_URL); +function withBase(path: string): string { + const body = path.startsWith("/") ? path.slice(1) : path; + const combined = `${BASE_PREFIX}${body}`; + return combined.startsWith("/") ? combined : `/${combined}`; +} +/** -------------------------------- */ + function getCookie(name: string) { const cookieValue = document.cookie.split("; ").find((row) => row.startsWith(name + "=")); return cookieValue ? cookieValue.split("=")[1] : null; @@ -128,11 +143,11 @@ export default function CreateKeyPage() { setPage(searchParams.get("page") || "api-keys"); }, [searchParams]); - // Custom setPage function that updates URL + // Custom setPage function that updates URL (always under BASE) const updatePage = (newPage: string) => { const newSearchParams = new URLSearchParams(searchParams); newSearchParams.set("page", newPage); - router.push(`/?${newSearchParams.toString()}`); // absolute, not relative + router.push(withBase(`/?${newSearchParams.toString()}`)); setPage(newPage); }; @@ -160,6 +175,7 @@ export default function CreateKeyPage() { useEffect(() => { if (redirectToLogin) { + // This is an external auth route (kept as-is, not app navigation). window.location.href = (proxyBaseUrl || "") + "/sso/key/generate"; } }, [redirectToLogin]); From 7ed4715a9b763faf15520989637f99f62828989c Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Mon, 6 Oct 2025 20:42:59 -0700 Subject: [PATCH 17/42] eliminates loading screen flash --- .../app/(dashboard)/components/Sidebar2.tsx | 61 +- ui/litellm-dashboard/src/app/page.tsx | 534 +++++++++--------- 2 files changed, 280 insertions(+), 315 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index 95907f3def..2ebfbc5d4f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -1,5 +1,5 @@ import { Layout, Menu } from "antd"; -import { useRouter, usePathname } from "next/navigation"; // UPDATED: also usePathname to detect current route +import { usePathname } from "next/navigation"; import { KeyOutlined, PlayCircleOutlined, @@ -44,23 +44,29 @@ interface MenuItem { icon?: React.ReactNode; } -/** ---- BASE URL HELPERS (shared pattern across files) ---- */ +/** ---- BASE URL HELPERS ---- */ function normalizeBasePrefix(raw: string | undefined | null): string { const trimmed = (raw ?? "").trim(); if (!trimmed) return ""; // no base - // strip leading/trailing slashes then rebuild as "/segment/" const core = trimmed.replace(/^\/+/, "").replace(/\/+$/, ""); - return core ? `/${core}/` : "/"; + return core ? `/${core}` : ""; } const BASE_PREFIX = normalizeBasePrefix(process.env.NEXT_PUBLIC_BASE_URL); -/** Builds an app-relative path under the configured base prefix. */ + +/** Build an absolute path under the configured base. */ function withBase(path: string): string { - // Accepts paths like "/virtual-keys" or "/?page=..." and prefixes with BASE_PREFIX when present. - const body = path.startsWith("/") ? path.slice(1) : path; - const combined = `${BASE_PREFIX}${body}`; - return combined.startsWith("/") ? combined : `/${combined}`; + // path can be "/virtual-keys" or "/?page=..." + const p = path.startsWith("/") ? path : `/${path}`; + return `${BASE_PREFIX}${p}` || p; } -/** -------------------------------------------------------- */ + +/** History-based navigation to prevent hard reloads / suspense flashes */ +function softNavigate(url: string, replace = false) { + if (typeof window === "undefined") return; + if (replace) window.history.replaceState(null, "", url); + else window.history.pushState(null, "", url); +} +/** -------------------------------- */ const Sidebar2: React.FC = ({ accessToken, @@ -69,7 +75,6 @@ const Sidebar2: React.FC = ({ defaultSelectedKey, collapsed = false, }) => { - const router = useRouter(); const pathname = usePathname(); const { refactoredUIFlag } = useFeatureFlags(); @@ -243,38 +248,30 @@ const Sidebar2: React.FC = ({ return true; }); - // Helper: go to /?page=...; when on /virtual-keys with refactored UI, replace instead of appending + // Helper: update /?page=... under the configured base, WITHOUT triggering App Router nav const pushToRootWithPage = (page: string, useReplace = false) => { const params = new URLSearchParams(); params.set("page", page); const url = withBase(`/?${params.toString()}`); - if (useReplace) { - router.replace(url); - } else { - router.push(url); - } + softNavigate(url, useReplace); }; - // Centralized navigation that satisfies the requirement const navigateToPage = (page: string) => { - // Special-case: Virtual Keys target if (page === "api-keys") { if (refactoredUIFlag) { - // Go to the dedicated /virtual-keys page (always under base) - router.push(withBase("/virtual-keys")); - return; // do not call setPage here (parity with previous behavior) + // vanity URL, keep SPA alive + softNavigate(withBase("/virtual-keys")); + return; // don't call setPage to keep parity, UI already shows api-keys by default } - // Legacy behavior pushToRootWithPage(page); setPage(page); return; } - // All other pages if (refactoredUIFlag) { - // If currently on /virtual-keys, REPLACE it with /?page=... - const onVirtualKeys = pathname?.startsWith(withBase("/virtual-keys")); - pushToRootWithPage(page, !!onVirtualKeys); + const onVirtualKeys = + typeof window !== "undefined" && window.location.pathname.startsWith(withBase("/virtual-keys")); + pushToRootWithPage(page, onVirtualKeys); } else { pushToRootWithPage(page); } @@ -308,15 +305,9 @@ const Sidebar2: React.FC = ({ key: child.key, icon: child.icon, label: child.label, - onClick: () => { - navigateToPage(child.page); - }, + onClick: () => navigateToPage(child.page), })), - onClick: !item.children - ? () => { - navigateToPage(item.page); - } - : undefined, + onClick: !item.children ? () => navigateToPage(item.page) : undefined, }))} /> diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index b110858349..f83972e0c1 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -1,7 +1,6 @@ "use client"; -import React, { Suspense, useEffect, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import React, { useEffect, useMemo, useState } from "react"; import { jwtDecode } from "jwt-decode"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Team } from "@/components/key_team_helpers/key_list"; @@ -45,32 +44,28 @@ function normalizeBasePrefix(raw: string | undefined | null): string { const trimmed = (raw ?? "").trim(); if (!trimmed) return ""; const core = trimmed.replace(/^\/+/, "").replace(/\/+$/, ""); - return core ? `/${core}/` : "/"; + return core ? `/${core}` : ""; } const BASE_PREFIX = normalizeBasePrefix(process.env.NEXT_PUBLIC_BASE_URL); function withBase(path: string): string { - const body = path.startsWith("/") ? path.slice(1) : path; - const combined = `${BASE_PREFIX}${body}`; - return combined.startsWith("/") ? combined : `/${combined}`; + const p = path.startsWith("/") ? path : `/${path}`; + return `${BASE_PREFIX}${p}` || p; } /** -------------------------------- */ function getCookie(name: string) { + if (typeof document === "undefined") return null; const cookieValue = document.cookie.split("; ").find((row) => row.startsWith(name + "=")); return cookieValue ? cookieValue.split("=")[1] : null; } function formatUserRole(userRole: string) { - if (!userRole) { - return "Undefined Role"; - } + if (!userRole) return "Undefined Role"; switch (userRole.toLowerCase()) { case "app_owner": - return "App Owner"; case "demo_app_owner": return "App Owner"; case "app_admin": - return "Admin"; case "proxy_admin": return "Admin"; case "proxy_admin_viewer": @@ -80,7 +75,7 @@ function formatUserRole(userRole: string) { case "internal_user": return "Internal User"; case "internal_user_viewer": - case "internal_viewer": // TODO:remove if deprecated + case "internal_viewer": return "Internal Viewer"; case "app_user": return "App User"; @@ -100,7 +95,6 @@ function LoadingScreen() { return (
🚅 LiteLLM
-
Loading... @@ -109,8 +103,17 @@ function LoadingScreen() { ); } +/** Derive the app "page" from the URL without triggering App Router navigations */ +function getPageFromLocation(loc: Location): string { + const sp = new URLSearchParams(loc.search); + const p = sp.get("page"); + if (p) return p; + // vanity route for refactored UI + if (loc.pathname.endsWith("/virtual-keys")) return "api-keys"; + return "api-keys"; +} + export default function CreateKeyPage() { - const router = useRouter(); const [userRole, setUserRole] = useState(""); const [premiumUser, setPremiumUser] = useState(false); const [disabledPersonalKeyCreation, setDisabledPersonalKeyCreation] = useState(false); @@ -119,55 +122,64 @@ export default function CreateKeyPage() { const [keys, setKeys] = useState([]); const [organizations, setOrganizations] = useState([]); const [userModels, setUserModels] = useState([]); - const [proxySettings, setProxySettings] = useState({ - PROXY_BASE_URL: "", - PROXY_LOGOUT_URL: "", - }); - + const [proxySettings, setProxySettings] = useState({ PROXY_BASE_URL: "", PROXY_LOGOUT_URL: "" }); const [showSSOBanner, setShowSSOBanner] = useState(true); - const searchParams = useSearchParams()!; - const [modelData, setModelData] = useState({ data: [] }); + + // Stable local mirror of URLSearchParams (no suspense, no flashes) + const [searchParams, setSearchParams] = useState(() => + typeof window === "undefined" ? new URLSearchParams() : new URLSearchParams(window.location.search), + ); + const invitation_id = useMemo(() => searchParams.get("invitation_id"), [searchParams]); + + // Local page state (drives UI) + const [page, setPage] = useState(() => + typeof window === "undefined" ? "api-keys" : getPageFromLocation(window.location), + ); + + // Keep history/back/forward in sync with UI + useEffect(() => { + const onPop = () => { + if (typeof window === "undefined") return; + setSearchParams(new URLSearchParams(window.location.search)); + setPage(getPageFromLocation(window.location)); + }; + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + }, []); + + // Update URL without triggering App Router reload/suspense + const updatePage = (newPage: string) => { + if (typeof window === "undefined") return; + // 1) instant UI update + setPage(newPage); + // 2) URL update under base prefix + const sp = new URLSearchParams(window.location.search); + sp.set("page", newPage); + const url = withBase(`/?${sp.toString()}`); + window.history.pushState(null, "", url); + // 3) keep our local mirror in sync + setSearchParams(new URLSearchParams(sp)); + }; + const [token, setToken] = useState(null); const [createClicked, setCreateClicked] = useState(false); const [authLoading, setAuthLoading] = useState(true); const [userID, setUserID] = useState(null); - const invitation_id = searchParams.get("invitation_id"); - - // Get page from URL, default to 'api-keys' if not present - const [page, setPage] = useState(() => { - return searchParams.get("page") || "api-keys"; - }); - - useEffect(() => { - setPage(searchParams.get("page") || "api-keys"); - }, [searchParams]); - - // Custom setPage function that updates URL (always under BASE) - const updatePage = (newPage: string) => { - const newSearchParams = new URLSearchParams(searchParams); - newSearchParams.set("page", newPage); - router.push(withBase(`/?${newSearchParams.toString()}`)); - setPage(newPage); - }; - const [accessToken, setAccessToken] = useState(null); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const toggleSidebar = () => { - setSidebarCollapsed(!sidebarCollapsed); - }; + const toggleSidebar = () => setSidebarCollapsed((v) => !v); const addKey = (data: any) => { setKeys((prevData) => (prevData ? [...prevData, data] : [data])); - setCreateClicked(() => !createClicked); + setCreateClicked((v) => !v); }; const redirectToLogin = authLoading === false && token === null && invitation_id === null; useEffect(() => { const token = getCookie("token"); - getUiConfig().then((data) => { - // get the information for constructing the proxy base url, and then set the token and auth loading + getUiConfig().then(() => { setToken(token); setAuthLoading(false); }); @@ -175,59 +187,33 @@ export default function CreateKeyPage() { useEffect(() => { if (redirectToLogin) { - // This is an external auth route (kept as-is, not app navigation). window.location.href = (proxyBaseUrl || "") + "/sso/key/generate"; } }, [redirectToLogin]); useEffect(() => { - if (!token) { - return; - } - + if (!token) return; const decoded = jwtDecode(token) as { [key: string]: any }; - if (decoded) { - // set accessToken - setAccessToken(decoded.key); + if (!decoded) return; - setDisabledPersonalKeyCreation(decoded.disabled_non_admin_personal_key_creation); + setAccessToken(decoded.key); + setDisabledPersonalKeyCreation(decoded.disabled_non_admin_personal_key_creation); - // check if userRole is defined - if (decoded.user_role) { - const formattedUserRole = formatUserRole(decoded.user_role); - setUserRole(formattedUserRole); - if (formattedUserRole == "Admin Viewer") { - setPage("usage"); - } - } - - if (decoded.user_email) { - setUserEmail(decoded.user_email); - } - - if (decoded.login_method) { - setShowSSOBanner(decoded.login_method == "username_password" ? true : false); - } - - if (decoded.premium_user) { - setPremiumUser(decoded.premium_user); - } - - if (decoded.auth_header_name) { - setGlobalLitellmHeaderName(decoded.auth_header_name); - } - - if (decoded.user_id) { - setUserID(decoded.user_id); - } + if (decoded.user_role) { + const formattedUserRole = formatUserRole(decoded.user_role); + setUserRole(formattedUserRole); + if (formattedUserRole === "Admin Viewer") setPage("usage"); } + if (decoded.user_email) setUserEmail(decoded.user_email); + if (decoded.login_method) setShowSSOBanner(decoded.login_method === "username_password"); + if (decoded.premium_user) setPremiumUser(decoded.premium_user); + if (decoded.auth_header_name) setGlobalLitellmHeaderName(decoded.auth_header_name); + if (decoded.user_id) setUserID(decoded.user_id); }, [token]); useEffect(() => { if (accessToken && userID && userRole) { fetchUserModels(userID, userRole, accessToken, setUserModels); - } - if (accessToken && userID && userRole) { fetchTeams(accessToken, userID, userRole, null, setTeams); } if (accessToken) { @@ -240,206 +226,194 @@ export default function CreateKeyPage() { } return ( - }> - - - {invitation_id ? ( - + + {invitation_id ? ( + + ) : ( +
+ - ) : ( -
- -
-
- -
- - {page == "api-keys" ? ( - - ) : page == "models" ? ( - - ) : page == "llm-playground" ? ( - - ) : page == "users" ? ( - - ) : page == "teams" ? ( - - ) : page == "organizations" ? ( - - ) : page == "admin-panel" ? ( - - ) : page == "api_ref" ? ( - - ) : page == "settings" ? ( - - ) : page == "budgets" ? ( - - ) : page == "guardrails" ? ( - - ) : page == "prompts" ? ( - - ) : page == "transform-request" ? ( - - ) : page == "general-settings" ? ( - - ) : page == "ui-theme" ? ( - - ) : page == "model-hub-table" ? ( - - ) : page == "caching" ? ( - - ) : page == "pass-through-settings" ? ( - - ) : page == "logs" ? ( - - ) : page == "mcp-servers" ? ( - - ) : page == "tag-management" ? ( - - ) : page == "vector-stores" ? ( - - ) : page == "new_usage" ? ( - - ) : ( - - )} +
+
+
+ + {page === "api-keys" ? ( + + ) : page === "models" ? ( + {}} + premiumUser={premiumUser} + teams={teams} + /> + ) : page === "llm-playground" ? ( + + ) : page === "users" ? ( + + ) : page === "teams" ? ( + + ) : page === "organizations" ? ( + + ) : page === "admin-panel" ? ( + + ) : page === "api_ref" ? ( + + ) : page === "settings" ? ( + + ) : page === "budgets" ? ( + + ) : page === "guardrails" ? ( + + ) : page === "prompts" ? ( + + ) : page === "transform-request" ? ( + + ) : page === "general-settings" ? ( + + ) : page === "ui-theme" ? ( + + ) : page === "model-hub-table" ? ( + + ) : page === "caching" ? ( + + ) : page === "pass-through-settings" ? ( + + ) : page === "logs" ? ( + + ) : page === "mcp-servers" ? ( + + ) : page === "tag-management" ? ( + + ) : page === "vector-stores" ? ( + + ) : page === "new_usage" ? ( + + ) : ( + + )}
- )} - - - +
+ )} + + ); } From 4de4e4af1a622028ed1091253f3a3899ccb512ae Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Tue, 7 Oct 2025 18:14:20 -0700 Subject: [PATCH 18/42] Update page.tsx --- ui/litellm-dashboard/src/app/page.tsx | 86 +++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 10 deletions(-) diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 579e90e530..b300060926 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -41,8 +41,33 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { cx } from "@/lib/cva.config"; function getCookie(name: string) { - const cookieValue = document.cookie.split("; ").find((row) => row.startsWith(name + "=")); - return cookieValue ? cookieValue.split("=")[1] : null; + // Safer cookie read + decoding; handles '=' inside values + const match = document.cookie.split("; ").find((row) => row.startsWith(name + "=")); + if (!match) return null; + const value = match.slice(name.length + 1); + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function deleteCookie(name: string, path = "/") { + // Best-effort client-side clear (works for non-HttpOnly cookies without Domain) + document.cookie = `${name}=; Max-Age=0; Path=${path}`; +} + +function isJwtExpired(token: string): boolean { + try { + const decoded: any = jwtDecode(token); + if (decoded && typeof decoded.exp === "number") { + return decoded.exp * 1000 <= Date.now(); + } + return false; + } catch { + // If we can't decode, treat as invalid/expired + return true; + } } function formatUserRole(userRole: string) { @@ -149,17 +174,42 @@ export default function CreateKeyPage() { const redirectToLogin = authLoading === false && token === null && invitation_id === null; useEffect(() => { - const token = getCookie("token"); - getUiConfig().then((data) => { - // get the information for constructing the proxy base url, and then set the token and auth loading - setToken(token); - setAuthLoading(false); - }); + let cancelled = false; + + (async () => { + try { + await getUiConfig(); // ensures proxyBaseUrl etc. are ready + } catch { + // proceed regardless; we still need to decide auth state + } + + if (cancelled) return; + + const raw = getCookie("token"); + const valid = raw && !isJwtExpired(raw) ? raw : null; + + // If token exists but is invalid/expired, clear it so downstream code + // doesn't keep trying to use it and cause redirect spasms. + if (raw && !valid) { + deleteCookie("token", "/"); + } + + if (!cancelled) { + setToken(valid); + setAuthLoading(false); + } + })(); + + return () => { + cancelled = true; + }; }, []); useEffect(() => { if (redirectToLogin) { - window.location.href = (proxyBaseUrl || "") + "/sso/key/generate"; + // Replace instead of assigning to avoid back-button loops + const dest = (proxyBaseUrl || "") + "/sso/key/generate"; + window.location.replace(dest); } }, [redirectToLogin]); @@ -168,7 +218,23 @@ export default function CreateKeyPage() { return; } - const decoded = jwtDecode(token) as { [key: string]: any }; + // Defensive: re-check expiry in case cookie changed after mount + if (isJwtExpired(token)) { + deleteCookie("token", "/"); + setToken(null); + return; + } + + let decoded: any = null; + try { + decoded = jwtDecode(token); + } catch { + // Malformed token → treat as unauthenticated + deleteCookie("token", "/"); + setToken(null); + return; + } + if (decoded) { // set accessToken setAccessToken(decoded.key); From 7595acff424ad0b646320fb35ebc29e3fd487e15 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Wed, 8 Oct 2025 12:03:44 -0700 Subject: [PATCH 19/42] empty commit for CI/CD From 45ec1ffdaac1f0d668438280fe5a857b0dec22c6 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 8 Oct 2025 13:13:21 -0700 Subject: [PATCH 20/42] fix(ui/): set is sso modal visible to true for all users allow backend to handle more complex state management --- litellm/model_prices_and_context_window_backup.json | 3 +-- .../out/{model_hub_table.html => model_hub_table/index.html} | 0 litellm/proxy/_experimental/out/onboarding.html | 1 - ui/litellm-dashboard/src/components/admins.tsx | 4 +--- 4 files changed, 2 insertions(+), 6 deletions(-) rename litellm/proxy/_experimental/out/{model_hub_table.html => model_hub_table/index.html} (100%) delete mode 100644 litellm/proxy/_experimental/out/onboarding.html diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index bdfc4fd020..29a6588774 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -13303,8 +13303,7 @@ "supported_endpoints": [ "/v1/images/generations", "/v1/images/edits" - ], - "supports_vision": true + ] }, "gpt-realtime": { "cache_creation_input_audio_token_cost": 4e-07, diff --git a/litellm/proxy/_experimental/out/model_hub_table.html b/litellm/proxy/_experimental/out/model_hub_table/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model_hub_table.html rename to litellm/proxy/_experimental/out/model_hub_table/index.html diff --git a/litellm/proxy/_experimental/out/onboarding.html b/litellm/proxy/_experimental/out/onboarding.html deleted file mode 100644 index 04c6c88676..0000000000 --- a/litellm/proxy/_experimental/out/onboarding.html +++ /dev/null @@ -1 +0,0 @@ -LiteLLM Dashboard \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/admins.tsx b/ui/litellm-dashboard/src/components/admins.tsx index 7c891c71dd..88ec208cb4 100644 --- a/ui/litellm-dashboard/src/components/admins.tsx +++ b/ui/litellm-dashboard/src/components/admins.tsx @@ -524,9 +524,7 @@ const AdminPanel: React.FC = ({ - )} - - - {isCreateUserModalVisible && ( - setIsCreateUserModalVisible(false)} - footer={null} - width={800} - > - - - )} -
- ); -}; - -export default CreateKey; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx deleted file mode 100644 index 33a8ae06cf..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client"; - -import { TextInput, Title } from "@tremor/react"; -import { Form, FormInstance, Select, Tooltip } from "antd"; -import { InfoCircleOutlined } from "@ant-design/icons"; -import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; -import React from "react"; - -export interface KeyDetailsSectionProps { - form: FormInstance; - keyOwner: string; - modelsToPick: string[]; - keyType: string; - setKeyType: (keyType: string) => void; -} - -const { Option } = Select; - -const KeyDetailsSection = ({ form, keyOwner, keyType, modelsToPick, setKeyType }: KeyDetailsSectionProps) => { - return ( -
- Key Details - - {keyOwner === "you" || keyOwner === "another_user" ? "Key Name" : "Service Account ID"}{" "} - - - - - } - name="key_alias" - rules={[ - { - required: true, - message: `Please input a ${keyOwner === "you" ? "key name" : "service account ID"}`, - }, - ]} - help="required" - > - - - - - Models{" "} - - - - - } - name="models" - rules={ - keyType === "management" || keyType === "read_only" - ? [] - : [{ required: true, message: "Please select a model" }] - } - help={ - keyType === "management" || keyType === "read_only" - ? "Models field is disabled for this key type" - : "required" - } - className="mt-4" - > - - - - - Key Type{" "} - - - - - } - name="key_type" - initialValue="default" - className="mt-4" - > - - -
- ); -}; - -export default KeyDetailsSection; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx deleted file mode 100644 index b88f891944..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx +++ /dev/null @@ -1,460 +0,0 @@ -"use client"; - -import { Accordion, AccordionBody, AccordionHeader, Text, Title } from "@tremor/react"; -import { Form, FormInstance, Input, Select, Tooltip } from "antd"; -import { InfoCircleOutlined } from "@ant-design/icons"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; -import NumericalInput from "@/components/shared/numerical_input"; -import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown"; -import RateLimitTypeFormItem from "@/components/common_components/RateLimitTypeFormItem"; -import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector"; -import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector"; -import PremiumLoggingSettings from "@/components/common_components/PremiumLoggingSettings"; -import ModelAliasManager from "@/components/common_components/ModelAliasManager"; -import KeyLifecycleSettings from "@/components/common_components/KeyLifecycleSettings"; -import { proxyBaseUrl } from "@/components/networking"; -import SchemaFormFields from "@/components/common_components/check_openapi_schema"; -import { Team } from "@/components/key_team_helpers/key_list"; -import React from "react"; -import { DefaultOptionType } from "rc-select/lib/Select"; -import { ModelAliases } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/types"; - -export interface OptionalSettingsSectionProps { - form: FormInstance; - team: Team | null; - premiumUser: boolean; - guardrails: string[]; - prompts: string[]; - accessToken: string; - predefinedTags: DefaultOptionType[]; - loggingSettings: any; - setLoggingSettings: (settings: any) => void; - disabledCallbacks: string[]; - setDisabledCallbacks: (disabledCallbacks: string[]) => void; - modelAliases: ModelAliases; - setModelAliases: (aliases: ModelAliases) => void; - autoRotationEnabled: boolean; - setAutoRotationEnabled: (enabled: boolean) => void; - rotationInterval: string; - setRotationInterval: (interval: string) => void; -} - -// TODO: break apart this component into smaller sections -const OptionalSettingsSection = ({ - form, - team, - premiumUser, - guardrails, - prompts, - accessToken, - predefinedTags, - loggingSettings, - setLoggingSettings, - disabledCallbacks, - setDisabledCallbacks, - modelAliases, - setModelAliases, - autoRotationEnabled, - setAutoRotationEnabled, - rotationInterval, - setRotationInterval, -}: OptionalSettingsSectionProps) => { - return ( -
- - - Optional Settings - - - - Max Budget (USD){" "} - - - - - } - name="max_budget" - help={`Budget cannot exceed team max budget: $${team?.max_budget !== null && team?.max_budget !== undefined ? team?.max_budget : "unlimited"}`} - rules={[ - { - validator: async (_, value) => { - if (value && team && team.max_budget !== null && value > team.max_budget) { - throw new Error( - `Budget cannot exceed team max budget: $${formatNumberWithCommas(team.max_budget, 4)}`, - ); - } - }, - }, - ]} - > - - - - Reset Budget{" "} - - - - - } - name="budget_duration" - help={`Team Reset Budget: ${team?.budget_duration !== null && team?.budget_duration !== undefined ? team?.budget_duration : "None"}`} - > - form.setFieldValue("budget_duration", value)} /> - - - Tokens per minute Limit (TPM){" "} - - - - - } - name="tpm_limit" - help={`TPM cannot exceed team TPM limit: ${team?.tpm_limit !== null && team?.tpm_limit !== undefined ? team?.tpm_limit : "unlimited"}`} - rules={[ - { - validator: async (_, value) => { - if (value && team && team.tpm_limit !== null && value > team.tpm_limit) { - throw new Error(`TPM limit cannot exceed team TPM limit: ${team.tpm_limit}`); - } - }, - }, - ]} - > - - - - - Requests per minute Limit (RPM){" "} - - - - - } - name="rpm_limit" - help={`RPM cannot exceed team RPM limit: ${team?.rpm_limit !== null && team?.rpm_limit !== undefined ? team?.rpm_limit : "unlimited"}`} - rules={[ - { - validator: async (_, value) => { - if (value && team && team.rpm_limit !== null && value > team.rpm_limit) { - throw new Error(`RPM limit cannot exceed team RPM limit: ${team.rpm_limit}`); - } - }, - }, - ]} - > - - - - - Guardrails{" "} - - e.stopPropagation()} // Prevent accordion from collapsing when clicking link - > - - - - - } - name="guardrails" - className="mt-4" - help={ - premiumUser - ? "Select existing guardrails or enter new ones" - : "Premium feature - Upgrade to set guardrails by key" - } - > - ({ value: name, label: name }))} - /> - - - Allowed Vector Stores{" "} - - - - - } - name="allowed_vector_store_ids" - className="mt-4" - help="Select vector stores this key can access. Leave empty for access to all vector stores" - > - form.setFieldValue("allowed_vector_store_ids", values)} - value={form.getFieldValue("allowed_vector_store_ids")} - accessToken={accessToken} - placeholder="Select vector stores (optional)" - /> - - - - Allowed MCP Servers{" "} - - - - - } - name="allowed_mcp_servers_and_groups" - className="mt-4" - help="Select MCP servers or access groups this key can access. " - > - form.setFieldValue("allowed_mcp_servers_and_groups", val)} - value={form.getFieldValue("allowed_mcp_servers_and_groups")} - accessToken={accessToken} - placeholder="Select MCP servers or access groups (optional)" - /> - - - - Metadata{" "} - - - - - } - name="metadata" - className="mt-4" - > - - - - Tags{" "} - - - - - } - name="tags" - className="mt-4" - help={`Tags for tracking spend and/or doing tag-based routing.`} - > - handleUserSelect(value, option as UserOption)} - options={userOptions} - loading={userSearchLoading} - allowClear - style={{ width: "100%" }} - notFoundContent={userSearchLoading ? "Searching..." : "No users found"} - /> - setIsCreateUserModalVisible(true)} style={{ marginLeft: "8px" }}> - Create User - -
-
Search by email to find users
-
- - )} - - Team{" "} - - - - - } - name="team_id" - initialValue={team ? team.team_id : null} - className="mt-4" - rules={[ - { - required: keyOwner === "service_account", - message: "Please select a team for the service account", - }, - ]} - help={keyOwner === "service_account" ? "required" : ""} - > - { - const selectedTeam = teams?.find((t) => t.team_id === teamId) || null; - setSelectedCreateKeyTeam(selectedTeam); - }} - /> - -
- ); -}; - -export default OwnershipSection; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx deleted file mode 100644 index cbe3db4e8f..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx +++ /dev/null @@ -1,235 +0,0 @@ -"use client"; - -import { Button as Button2, Form, FormInstance, Modal } from "antd"; -import { Text } from "@tremor/react"; -import { keyCreateCall, keyCreateServiceAccountCall } from "@/components/networking"; -import React, { useEffect, useState } from "react"; -import { Team } from "@/components/key_team_helpers/key_list"; -import SaveKeyModal from "@/app/(dashboard)/virtual-keys/components/SaveKeyModal"; -import NotificationsManager from "@/components/molecules/notifications_manager"; -import { - useGuardrailsAndPrompts, - useMcpAccessGroups, - useTeamModels, - useUserModels, - useUserSearch, -} from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks"; -import OwnershipSection from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection"; -import KeyDetailsSection from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection"; -import OptionalSettingsSection from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection"; -import { ModelAliases } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/types"; -import { getPredefinedTags, prepareFormValues } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/utils"; -import { fetchTeams } from "@/app/(dashboard)/virtual-keys/networking"; -import useTeams from "@/app/(dashboard)/virtual-keys/hooks/useTeams"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; - -export interface CreateKeyModalProps { - isModalVisible: boolean; - setIsModalVisible: (isModalVisible: boolean) => void; - form: FormInstance; - handleUserSelect: (_value: string, option: UserOption) => void; - setIsCreateUserModalVisible: (visible: boolean) => void; - team: Team | null; - data: any[] | null; - addKey: (data: any) => void; -} - -interface User { - user_id: string; - user_email: string; - role?: string; -} - -interface UserOption { - label: string; - value: string; - user: User; -} - -const CreateKeyModal = ({ - isModalVisible, - setIsModalVisible, - form, - handleUserSelect, - setIsCreateUserModalVisible, - team, - data, - addKey, -}: CreateKeyModalProps) => { - const { userId: userID, userRole, accessToken, premiumUser } = useAuthorized(); - const [apiKey, setApiKey] = useState(null); - const [keyOwner, setKeyOwner] = useState("you"); - const [keyType, setKeyType] = useState("default"); - - const [selectedCreateKeyTeam, setSelectedCreateKeyTeam] = useState(team); - - const [softBudget, setSoftBudget] = useState(null); - const [loggingSettings, setLoggingSettings] = useState([]); - const [disabledCallbacks, setDisabledCallbacks] = useState([]); - const [autoRotationEnabled, setAutoRotationEnabled] = useState(false); - const [rotationInterval, setRotationInterval] = useState("30d"); - const [modelAliases, setModelAliases] = useState({}); - const predefinedTags = getPredefinedTags(data); - - const { options: userOptions, loading: userSearchLoading, onSearch: handleUserSearch } = useUserSearch(); - const { guardrails, prompts } = useGuardrailsAndPrompts(); - const teams = useTeams(); - const mcpAccessGroups = useMcpAccessGroups(); - const userModels = useUserModels(); - const modelsToPick = useTeamModels(selectedCreateKeyTeam); - - const isTeamSelectionRequired = modelsToPick.includes("no-default-models"); - const isFormDisabled = isTeamSelectionRequired && !selectedCreateKeyTeam; - - const handleCancel = () => { - setIsModalVisible(false); - setApiKey(null); - setSelectedCreateKeyTeam(null); - form.resetFields(); - setLoggingSettings([]); - setDisabledCallbacks([]); - setKeyType("default"); - setModelAliases({}); - setAutoRotationEnabled(false); - setRotationInterval("30d"); - }; - - const handleOk = () => { - setIsModalVisible(false); - form.resetFields(); - setLoggingSettings([]); - setDisabledCallbacks([]); - setKeyType("default"); - setModelAliases({}); - setAutoRotationEnabled(false); - setRotationInterval("30d"); - }; - - const handleCreate = async (formValues: Record) => { - try { - const newKeyAlias = formValues?.key_alias ?? ""; - const newKeyTeamId = formValues?.team_id ?? null; - - const existingKeyAliases = data?.filter((k) => k.team_id === newKeyTeamId).map((k) => k.key_alias) ?? []; - - if (existingKeyAliases.includes(newKeyAlias)) { - throw new Error( - `Key alias ${newKeyAlias} already exists for team with ID ${newKeyTeamId}, please provide another key alias`, - ); - } - - NotificationsManager.info("Making API Call"); - setIsModalVisible(true); - - const prepared = prepareFormValues(formValues, { - keyOwner, - userID, - loggingSettings, - disabledCallbacks, - autoRotationEnabled, - rotationInterval, - modelAliases, - }); - - let response; - if (keyOwner === "service_account") { - response = await keyCreateServiceAccountCall(accessToken, prepared); - } else { - response = await keyCreateCall(accessToken, userID, prepared); - } - - // TODO: change logic to trigger API call in parent component - addKey(response); - - setApiKey(response["key"]); - setSoftBudget(response["soft_budget"]); - NotificationsManager.success("API Key Created"); - form.resetFields(); - localStorage.removeItem("userData" + userID); - } catch (error) { - console.log("error in create key:", error); - NotificationsManager.fromBackend(`Error creating the key: ${error}`); - } - }; - - useEffect(() => { - form.setFieldValue("models", []); - }, [selectedCreateKeyTeam, accessToken, userID, userRole, form]); - - return ( -
- -
- - - {/* Show message when team selection is required */} - {isFormDisabled && ( -
- - Please select a team to continue configuring your API key. If you do not see any teams, please contact - your Proxy Admin to either provide you with access to models or to add you to a team. - -
- )} - - {/* Section 2: Key Details */} - {!isFormDisabled && ( - - )} - - {/* Section 3: Optional Settings */} - {!isFormDisabled && ( - - )} - -
- - Create Key - -
- -
- {apiKey && ( - - )} -
- ); -}; - -export default CreateKeyModal; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/index.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/index.ts deleted file mode 100644 index ca3c4a663b..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./useGuardrailsAndPrompts"; -export * from "./useMcpAccessGroups"; -export * from "./useUserModels"; -export * from "./useTeamModels"; -export * from "./useUserSearch"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts deleted file mode 100644 index f4718e8ba0..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect, useState } from "react"; -import { fetchGuardrails, fetchPrompts } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; - -export const useGuardrailsAndPrompts = () => { - const [guardrails, setGuardrails] = useState([]); - const [prompts, setPrompts] = useState([]); - const { accessToken } = useAuthorized(); - - useEffect(() => { - if (!accessToken) { - setGuardrails([]); - setPrompts([]); - return; - } - (async () => { - const [g, p] = await Promise.all([fetchGuardrails(accessToken), fetchPrompts(accessToken)]); - setGuardrails(g || []); - setPrompts(p || []); - })(); - }, [accessToken]); - - return { guardrails, prompts }; -}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts deleted file mode 100644 index 998b392f77..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useEffect, useState } from "react"; -import { getMCPAccessGroups } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; - -export const useMcpAccessGroups = () => { - const [mcpAccessGroups, setMcpAccessGroups] = useState([]); - const { accessToken } = useAuthorized(); - - useEffect(() => { - if (!accessToken) { - setMcpAccessGroups([]); - return; - } - (async () => { - const groups = await getMCPAccessGroups(accessToken); - setMcpAccessGroups(groups || []); - })(); - }, [accessToken]); - - return mcpAccessGroups; -}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts deleted file mode 100644 index dd99de0615..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import type { Team } from "@/components/key_team_helpers/key_list"; -import { fetchTeamModels } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; - -export const useTeamModels = (selectedTeam: Team | null) => { - const { userId: userID, userRole, accessToken } = useAuthorized(); - const [modelsToPick, setModelsToPick] = useState([]); - - // turn array into a stable dep so it only re-runs when contents change - const selectedTeamModelsKey = useMemo(() => (selectedTeam?.models ?? []).join("|"), [selectedTeam?.models]); - - useEffect(() => { - if (!userID || !userRole || !accessToken) { - setModelsToPick([]); - return; - } - (async () => { - const fetched = await fetchTeamModels(userID, userRole, accessToken, selectedTeam?.team_id ?? null); - const union = Array.from(new Set([...(selectedTeam?.models ?? []), ...(fetched || [])])); - setModelsToPick(union); - })(); - }, [userID, userRole, accessToken, selectedTeam?.team_id, selectedTeamModelsKey, selectedTeam?.models]); - - return modelsToPick; -}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts deleted file mode 100644 index 66b5520f06..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useEffect, useState } from "react"; -import { getUserModelNames } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; - -export const useUserModels = () => { - const { userId: userID, userRole, accessToken } = useAuthorized(); - const [userModels, setUserModels] = useState([]); - - useEffect(() => { - if (!userID || !userRole || !accessToken) { - setUserModels([]); - return; - } - (async () => { - const modelNames = await getUserModelNames(userID, userRole, accessToken); - setUserModels(modelNames || []); - })(); - }, [userID, userRole, accessToken]); - - return userModels; -}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts deleted file mode 100644 index d266a51667..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { debounce } from "lodash"; -import { searchUserOptionsByEmail } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; - -type User = { user_id: string; user_email: string; role?: string }; -export interface UserOption { - label: string; - value: string; - user: User; -} - -export const useUserSearch = (delay = 300) => { - const { accessToken } = useAuthorized(); - const [options, setOptions] = useState([]); - const [loading, setLoading] = useState(false); - - const doSearch = async (raw: string) => { - const q = raw?.trim(); - if (!q || !accessToken) { - setOptions([]); - setLoading(false); - return; - } - setLoading(true); - try { - const res = await searchUserOptionsByEmail(accessToken, q); - setOptions(res || []); - } finally { - setLoading(false); - } - }; - - const onSearch = useMemo(() => debounce(doSearch, delay), [accessToken, delay]); - - useEffect(() => { - return () => onSearch.cancel(); - }, [onSearch]); - - return { options, loading, onSearch }; -}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking.ts deleted file mode 100644 index 377f6d3113..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/networking.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - fetchMCPAccessGroups, - getGuardrailsList, - getPromptsList, - modelAvailableCall, - User, - userFilterUICall, -} from "@/components/networking"; -import { ModelAvailableResponse, UserOption } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/types"; - -export const fetchGuardrails = async (accessToken: string) => { - try { - const response = await getGuardrailsList(accessToken); - return response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name); - } catch (error) { - console.error("Failed to fetch guardrails:", error); - return []; - } -}; - -export const fetchPrompts = async (accessToken: string) => { - try { - const response = await getPromptsList(accessToken); - return response.prompts.map((prompt) => prompt.prompt_id); - } catch (error) { - console.error("Failed to fetch prompts:", error); - return []; - } -}; - -export const searchUserOptionsByEmail = async (accessToken: string, emailQuery: string): Promise => { - const params = new URLSearchParams(); - params.append("user_email", emailQuery); - - const response = await userFilterUICall(accessToken, params); - const users: User[] = response; - - return users.map((user) => ({ - label: `${user.user_email} (${user.user_id})`, - value: user.user_id, - user, - })); -}; - -export const getUserModelNames = async (userID: string, userRole: string, accessToken: string): Promise => { - const res: ModelAvailableResponse = await modelAvailableCall(accessToken, userID, userRole); - return (res?.data ?? []).map((m) => m.id); -}; - -export const fetchTeamModels = async ( - userID: string, - userRole: string, - accessToken: string, - teamID: string | null, -): Promise => { - try { - if (userID === null || userRole === null) { - return []; - } - - if (accessToken !== null) { - const model_available = await modelAvailableCall(accessToken, userID, userRole, true, teamID, true); - let available_model_names = model_available["data"].map((element: { id: string }) => element.id); - console.log("available_model_names:", available_model_names); - return available_model_names; - } - return []; - } catch (error) { - console.error("Error fetching user models:", error); - return []; - } -}; - -export const getMCPAccessGroups = async (accessToken: string): Promise => { - try { - if (accessToken == null) { - return []; - } - return await fetchMCPAccessGroups(accessToken); - } catch (error) { - console.error("Failed to fetch MCP access groups:", error); - return []; - } -}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/types.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/types.ts deleted file mode 100644 index 4d48df82d4..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface User { - user_id: string; - user_email: string; - role?: string; -} - -export interface UserOption { - label: string; - value: string; - user: User; -} - -export type Model = { id: string }; - -export type ModelAvailableResponse = { data: Model[] }; - -export type ModelAliases = { [key: string]: string }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/utils.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/utils.ts deleted file mode 100644 index 1fb40b8789..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/CreateKeyModal/utils.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { mapDisplayToInternalNames } from "@/components/callback_info_helpers"; -import { ModelAliases } from "@/app/(dashboard)/virtual-keys/components/CreateKeyModal/types"; - -export const getPredefinedTags = (data: any[] | null) => { - let allTags = []; - - console.log("data:", JSON.stringify(data)); - - if (data) { - for (let key of data) { - if (key["metadata"] && key["metadata"]["tags"]) { - allTags.push(...key["metadata"]["tags"]); - } - } - } - - const uniqueTags = Array.from(new Set(allTags)).map((tag) => ({ - value: tag, - label: tag, - })); - - console.log("uniqueTags:", uniqueTags); - return uniqueTags; -}; - -type AnyObj = Record; - -type PrepareOptions = { - keyOwner: string; - userID: string; - loggingSettings: any[]; - disabledCallbacks: string[]; - autoRotationEnabled: boolean; - rotationInterval: string; - modelAliases: ModelAliases; -}; - -export function safeParseMetadata(metadataStr: string | undefined): AnyObj { - try { - return JSON.parse(metadataStr || "{}"); - } catch (error) { - console.error("Error parsing metadata:", error); - return {}; - } -} - -export function withOwnerAssignments( - values: AnyObj, - { keyOwner, userID }: Pick, -): AnyObj { - // If owned by "you", set user_id - const base = keyOwner === "you" ? { ...values, user_id: userID } : { ...values }; - return base; -} - -export function enrichMetadata( - metadata: AnyObj, - values: AnyObj, - { - keyOwner, - loggingSettings, - disabledCallbacks, - }: Pick, -): AnyObj { - let next = { ...metadata }; - - // If it's a service account, add the service_account_id to the metadata - if (keyOwner === "service_account") { - next = { ...next, service_account_id: values.key_alias }; - } - - // Add logging settings to the metadata - if (Array.isArray(loggingSettings) && loggingSettings.length > 0) { - next = { ...next, logging: loggingSettings.filter((config: any) => config?.callback_name) }; - } - - // Add disabled callbacks to the metadata - if (Array.isArray(disabledCallbacks) && disabledCallbacks.length > 0) { - const mappedDisabledCallbacks = mapDisplayToInternalNames(disabledCallbacks); - next = { ...next, litellm_disabled_callbacks: mappedDisabledCallbacks }; - } - - return next; -} - -export function withAutoRotation( - values: AnyObj, - { autoRotationEnabled, rotationInterval }: Pick, -): AnyObj { - if (!autoRotationEnabled) return { ...values }; - return { ...values, auto_rotate: true, rotation_interval: rotationInterval }; -} - -export function withDurationNoop(values: AnyObj): AnyObj { - // Preserve the original no-op logic: if duration exists, set it to itself - if (values.duration) { - return { ...values, duration: values.duration }; - } - return { ...values }; -} - -export function mergeObjectPermission(values: AnyObj, patch: AnyObj): AnyObj { - const object_permission = { ...(values.object_permission || {}), ...patch }; - return { ...values, object_permission }; -} - -export function withVectorStores(values: AnyObj): AnyObj { - const { allowed_vector_store_ids, ...rest } = values; - if (Array.isArray(allowed_vector_store_ids) && allowed_vector_store_ids.length > 0) { - const patched = mergeObjectPermission(rest, { vector_stores: allowed_vector_store_ids }); - return patched; // original field removed by destructuring - } - return { ...values }; -} - -export function withMcpServersAndGroups(values: AnyObj): AnyObj { - const { allowed_mcp_servers_and_groups, ...rest } = values; - const servers = allowed_mcp_servers_and_groups?.servers; - const accessGroups = allowed_mcp_servers_and_groups?.accessGroups; - - const hasServers = Array.isArray(servers) && servers.length > 0; - const hasAccessGroups = Array.isArray(accessGroups) && accessGroups.length > 0; - - if (!hasServers && !hasAccessGroups) return { ...values }; - - let patched = { ...rest }; - if (hasServers) patched = mergeObjectPermission(patched, { mcp_servers: servers }); - if (hasAccessGroups) patched = mergeObjectPermission(patched, { mcp_access_groups: accessGroups }); - - // original field removed by destructuring - return patched; -} - -export function withMcpAccessGroups(values: AnyObj): AnyObj { - const { allowed_mcp_access_groups, ...rest } = values; - if (Array.isArray(allowed_mcp_access_groups) && allowed_mcp_access_groups.length > 0) { - const patched = mergeObjectPermission(rest, { mcp_access_groups: allowed_mcp_access_groups }); - return patched; // original field removed by destructuring - } - return { ...values }; -} - -function withAliases(values: AnyObj, modelAliases: ModelAliases): AnyObj { - if (modelAliases && Object.keys(modelAliases).length > 0) { - return { ...values, aliases: JSON.stringify(modelAliases) }; - } - return { ...values }; -} - -function withMetadataString(values: AnyObj, metadata: AnyObj): AnyObj { - return { ...values, metadata: JSON.stringify(metadata) }; -} - -export function prepareFormValues(initialValues: AnyObj, opts: PrepareOptions): AnyObj { - // Step 1: assign user id if needed - let values = withOwnerAssignments(initialValues, { keyOwner: opts.keyOwner, userID: opts.userID }); - - // Step 2: metadata parse & enrichment - const parsedMetadata = safeParseMetadata(values.metadata); - const enrichedMetadata = enrichMetadata(parsedMetadata, values, { - keyOwner: opts.keyOwner, - loggingSettings: opts.loggingSettings, - disabledCallbacks: opts.disabledCallbacks, - }); - - // Step 3: auto-rotation - values = withAutoRotation(values, { - autoRotationEnabled: opts.autoRotationEnabled, - rotationInterval: opts.rotationInterval, - }); - - // Step 4: duration (no-op preservation) - values = withDurationNoop(values); - - // Step 5: commit metadata string - values = withMetadataString(values, enrichedMetadata); - - // Step 6: object_permission transformations - values = withVectorStores(values); - values = withMcpServersAndGroups(values); - values = withMcpAccessGroups(values); - - // Step 7: aliases - values = withAliases(values, opts.modelAliases); - - return values; -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/KeyInfoView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/KeyInfoView.tsx deleted file mode 100644 index 9f9a47fbe1..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/KeyInfoView.tsx +++ /dev/null @@ -1,699 +0,0 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import { - Card, - Text, - Button, - Grid, - Tab, - TabList, - TabGroup, - TabPanel, - TabPanels, - Title, - Badge, -} from "@tremor/react"; -import { ArrowLeftIcon, TrashIcon, RefreshIcon } from "@heroicons/react/outline"; -import { Form, Tooltip, Button as AntdButton } from "antd"; -import { copyToClipboard as utilCopyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils"; -import { CopyIcon, CheckIcon } from "lucide-react"; -import { KeyResponse } from "@/components/key_team_helpers/key_list" -import { mapDisplayToInternalNames, mapInternalToDisplayNames } from "@/components/callback_info_helpers" -import NotificationManager from "@/components/molecules/notifications_manager" -import { keyDeleteCall, keyUpdateCall } from "@/components/networking" -import { parseErrorMessage } from "@/components/shared/errorUtils" -import { rolesWithWriteAccess } from "@/utils/roles" -import { RegenerateKeyModal } from "@/components/organisms/regenerate_key_modal" -import ObjectPermissionsView from "@/components/object_permissions_view" -import LoggingSettingsView from "@/components/logging_settings_view" -import { extractLoggingSettings, formatMetadataForDisplay } from "@/components/key_info_utils" -import AutoRotationView from "@/components/common_components/AutoRotationView" -import { KeyEditView } from "@/components/templates/key_edit_view" - - -interface KeyInfoViewProps { - keyId: string; - onClose: () => void; - keyData: KeyResponse | undefined; - onKeyDataUpdate?: (data: Partial) => void; - onDelete?: () => void; - accessToken: string | null; - userID: string | null; - userRole: string | null; - teams: any[] | null; - premiumUser: boolean; - setAccessToken?: (token: string) => void; - backButtonText?: string; -} - -const KeyInfoView = ({ - keyId, - onClose, - keyData, - accessToken, - userID, - userRole, - teams, - onKeyDataUpdate, - onDelete, - premiumUser, - setAccessToken, - backButtonText = "Back to Keys", - }: KeyInfoViewProps) => { - const [isEditing, setIsEditing] = useState(false); - const [form] = Form.useForm(); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [deleteConfirmInput, setDeleteConfirmInput] = useState(""); - const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false); - const [copiedStates, setCopiedStates] = useState>({}); - - // Add local state to maintain key data and track regeneration - const [currentKeyData, setCurrentKeyData] = useState(keyData); - const [lastRegeneratedAt, setLastRegeneratedAt] = useState(null); - const [isRecentlyRegenerated, setIsRecentlyRegenerated] = useState(false); - - // Update local state when keyData prop changes (but don't reset to undefined) - useEffect(() => { - if (keyData) { - setCurrentKeyData(keyData); - } - }, [keyData]); - - // Reset recent regeneration indicator after 5 seconds - useEffect(() => { - if (isRecentlyRegenerated) { - const timer = setTimeout(() => { - setIsRecentlyRegenerated(false); - }, 5000); - return () => clearTimeout(timer); - } - }, [isRecentlyRegenerated]); - - // Use currentKeyData instead of keyData throughout the component - if (!currentKeyData) { - return ( -
- - Key not found -
- ); - } - - const handleKeyUpdate = async (formValues: Record) => { - try { - if (!accessToken) return; - - const currentKey = formValues.token; - formValues.key = currentKey; - - // Guard premium features - if (!premiumUser) { - delete formValues.guardrails; - delete formValues.prompts; - } - - // Handle object_permission updates - if (formValues.vector_stores !== undefined) { - formValues.object_permission = { - ...currentKeyData.object_permission, - vector_stores: formValues.vector_stores || [], - }; - // Remove vector_stores from the top level as it should be in object_permission - delete formValues.vector_stores; - } - - if (formValues.mcp_servers_and_groups !== undefined) { - const { servers, accessGroups } = formValues.mcp_servers_and_groups || { servers: [], accessGroups: [] }; - formValues.object_permission = { - ...currentKeyData.object_permission, - mcp_servers: servers || [], - mcp_access_groups: accessGroups || [], - }; - // Remove mcp_servers_and_groups from the top level as it should be in object_permission - delete formValues.mcp_servers_and_groups; - } - - // Convert metadata back to an object if it exists and is a string - if (formValues.metadata && typeof formValues.metadata === "string") { - try { - const parsedMetadata = JSON.parse(formValues.metadata); - formValues.metadata = { - ...parsedMetadata, - ...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}), - ...(formValues.logging_settings ? { logging: formValues.logging_settings } : {}), - ...(formValues.disabled_callbacks?.length > 0 - ? { - litellm_disabled_callbacks: mapDisplayToInternalNames(formValues.disabled_callbacks), - } - : {}), - }; - } catch (error) { - console.error("Error parsing metadata JSON:", error); - NotificationManager.error("Invalid metadata JSON"); - return; - } - } else { - formValues.metadata = { - ...(formValues.metadata || {}), - ...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}), - ...(formValues.logging_settings ? { logging: formValues.logging_settings } : {}), - ...(formValues.disabled_callbacks?.length > 0 - ? { - litellm_disabled_callbacks: mapDisplayToInternalNames(formValues.disabled_callbacks), - } - : {}), - }; - } - - delete formValues.logging_settings; - - // Convert budget_duration to API format - if (formValues.budget_duration) { - const durationMap: Record = { - daily: "24h", - weekly: "7d", - monthly: "30d", - }; - formValues.budget_duration = durationMap[formValues.budget_duration]; - } - - const newKeyValues = await keyUpdateCall(accessToken, formValues); - - // Update local state - setCurrentKeyData((prevData) => (prevData ? { ...prevData, ...newKeyValues } : undefined)); - - if (onKeyDataUpdate) { - onKeyDataUpdate(newKeyValues); - } - NotificationManager.success("Key updated successfully"); - setIsEditing(false); - // Refresh key data here if needed - } catch (error) { - NotificationManager.fromBackend(parseErrorMessage(error)); - console.error("Error updating key:", error); - } - }; - - const handleDelete = async () => { - try { - if (!accessToken) return; - await keyDeleteCall(accessToken as string, currentKeyData.token || currentKeyData.token_id); - NotificationManager.success("Key deleted successfully"); - if (onDelete) { - onDelete(); - } - onClose(); - } catch (error) { - console.error("Error deleting the key:", error); - NotificationManager.fromBackend(error); - } - // Reset the confirmation input - setDeleteConfirmInput(""); - }; - - const copyToClipboard = async (text: string, key: string) => { - const success = await utilCopyToClipboard(text); - if (success) { - setCopiedStates((prev) => ({ ...prev, [key]: true })); - setTimeout(() => { - setCopiedStates((prev) => ({ ...prev, [key]: false })); - }, 2000); - } - }; - - const handleRegenerateKeyUpdate = (updatedKeyData: Partial) => { - // Update local state immediately with ALL the new data - setCurrentKeyData((prevData) => { - if (!prevData) return undefined; - const newData = { - ...prevData, - ...updatedKeyData, // This should include the new token (key-id) - // Update the created_at to show when it was regenerated - created_at: new Date().toLocaleString(), - }; - return newData; - }); - - // Track regeneration timestamp - setLastRegeneratedAt(new Date()); - setIsRecentlyRegenerated(true); - - if (onKeyDataUpdate) { - onKeyDataUpdate({ - ...updatedKeyData, - created_at: new Date().toLocaleString(), - }); - } - }; - - // Update the formatTimestamp function to use the desired date format - const formatTimestamp = (timestamp: string | Date) => { - const date = new Date(timestamp); - const dateStr = date.toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); - const timeStr = date.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - hour12: true, - }); - return `${dateStr} at ${timeStr}`; - }; - - return ( -
-
-
- - {currentKeyData.key_alias || "API Key"} - -
-
- Key ID - {currentKeyData.token_id || currentKeyData.token} -
- : } - onClick={() => copyToClipboard(currentKeyData.token_id || currentKeyData.token, "key-id")} - className={`ml-2 transition-all duration-200${ - copiedStates["key-id"] - ? "text-green-600 bg-green-50 border-green-200" - : "text-gray-500 hover:text-gray-700 hover:bg-gray-100" - }`} - /> -
- - {/* Add timestamp and regeneration indicator */} -
- - {currentKeyData.updated_at && currentKeyData.updated_at !== currentKeyData.created_at - ? `Updated: ${formatTimestamp(currentKeyData.updated_at)}` - : `Created: ${formatTimestamp(currentKeyData.created_at)}`} - - - {isRecentlyRegenerated && ( - - Recently Regenerated - - )} - - {lastRegeneratedAt && ( - - Regenerated - - )} -
-
- {userRole && rolesWithWriteAccess.includes(userRole) && ( -
- - - - - - -
- )} -
- - {/* Add RegenerateKeyModal */} - setIsRegenerateModalOpen(false)} - accessToken={accessToken} - premiumUser={premiumUser} - setAccessToken={setAccessToken} - onKeyUpdate={handleRegenerateKeyUpdate} - /> - - {/* Delete Confirmation Modal */} - {isDeleteModalOpen && - (() => { - const keyName = currentKeyData?.key_alias || currentKeyData?.token_id || "API Key"; - const isValid = deleteConfirmInput === keyName; - return ( -
-
-
-
-

Delete Key

- -
-
-
-
- - - -
-
-

- Warning: You are about to delete this API key. -

-

- This action is irreversible and will immediately revoke access for any applications using this - key. -

-
-
-

Are you sure you want to delete this API key?

-
- - setDeleteConfirmInput(e.target.value)} - placeholder="Enter key name exactly" - className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-base" - autoFocus - /> -
-
-
-
- - -
-
-
- ); - })()} - - - - Overview - Settings - - - - {/* Overview Panel */} - - - - Spend -
- ${formatNumberWithCommas(currentKeyData.spend, 4)} - - of{" "} - {currentKeyData.max_budget !== null - ? `$${formatNumberWithCommas(currentKeyData.max_budget)}` - : "Unlimited"} - -
-
- - - Rate Limits -
- TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"} - RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"} -
-
- - - Models -
- {currentKeyData.models && currentKeyData.models.length > 0 ? ( - currentKeyData.models.map((model, index) => ( - - {model} - - )) - ) : ( - No models specified - )} -
-
- - - - - - - - -
-
- - {/* Settings Panel */} - - -
- Key Settings - {!isEditing && userRole && rolesWithWriteAccess.includes(userRole) && ( - - )} -
- - {isEditing ? ( - setIsEditing(false)} - onSubmit={handleKeyUpdate} - teams={teams} - accessToken={accessToken} - userID={userID} - userRole={userRole} - premiumUser={premiumUser} - /> - ) : ( -
-
- Key ID - {currentKeyData.token_id || currentKeyData.token} -
- -
- Key Alias - {currentKeyData.key_alias || "Not Set"} -
- -
- Secret Key - {currentKeyData.key_name} -
- -
- Team ID - {currentKeyData.team_id || "Not Set"} -
- -
- Organization - {currentKeyData.organization_id || "Not Set"} -
- -
- Created - {formatTimestamp(currentKeyData.created_at)} -
- - {lastRegeneratedAt && ( -
- Last Regenerated -
- {formatTimestamp(lastRegeneratedAt)} - - Recent - -
-
- )} - -
- Expires - {currentKeyData.expires ? formatTimestamp(currentKeyData.expires) : "Never"} -
- - - -
- Spend - ${formatNumberWithCommas(currentKeyData.spend, 4)} USD -
- -
- Budget - - {currentKeyData.max_budget !== null - ? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}` - : "Unlimited"} - -
- -
- Prompts - - {Array.isArray(currentKeyData.metadata?.prompts) && currentKeyData.metadata.prompts.length > 0 - ? currentKeyData.metadata.prompts.map((prompt, index) => ( - - {prompt} - - )) - : "No prompts specified"} - -
- -
- Models -
- {currentKeyData.models && currentKeyData.models.length > 0 ? ( - currentKeyData.models.map((model, index) => ( - - {model} - - )) - ) : ( - No models specified - )} -
-
- -
- Rate Limits - TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"} - RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"} - - Max Parallel Requests:{" "} - {currentKeyData.max_parallel_requests !== null - ? currentKeyData.max_parallel_requests - : "Unlimited"} - - - Model TPM Limits:{" "} - {currentKeyData.metadata?.model_tpm_limit - ? JSON.stringify(currentKeyData.metadata.model_tpm_limit) - : "Unlimited"} - - - Model RPM Limits:{" "} - {currentKeyData.metadata?.model_rpm_limit - ? JSON.stringify(currentKeyData.metadata.model_rpm_limit) - : "Unlimited"} - -
- -
- Metadata -
-                      {formatMetadataForDisplay(currentKeyData.metadata)}
-                    
-
- - - - -
- )} -
-
-
-
-
- ); -} - -export default KeyInfoView; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/SaveKeyModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/SaveKeyModal.tsx deleted file mode 100644 index fed678aadc..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/SaveKeyModal.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import { Button, Col, Grid, Text, Title } from "@tremor/react"; -import { CopyToClipboard } from "react-copy-to-clipboard"; -import { Modal } from "antd"; -import React from "react"; -import NotificationsManager from "@/components/molecules/notifications_manager"; - -export interface SaveKeyModalProps { - apiKey: string; - isModalVisible: boolean; - handleOk: () => void; - handleCancel: () => void; -} - -const SaveKeyModal = ({ apiKey, isModalVisible, handleOk, handleCancel }: SaveKeyModalProps) => { - const handleCopy = () => { - NotificationsManager.success("API Key copied to clipboard"); - }; - - return ( - - - Save your Key - -

- Please save this secret key somewhere safe and accessible. For security reasons,{" "} - you will not be able to view it again through your LiteLLM account. If you lose this secret key, you - will need to generate a new one. -

- - - {apiKey != null ? ( -
- API Key: -
-
{apiKey}
-
- - - - -
- ) : ( - Key being created, this might take 30s - )} - -
-
- ); -}; - -export default SaveKeyModal; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx deleted file mode 100644 index 180d3336f1..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx +++ /dev/null @@ -1,667 +0,0 @@ -"use client"; -// TODO: refactor - -import React, { useEffect, useState } from "react"; -import { ColumnDef } from "@tanstack/react-table"; -import { Button } from "@tremor/react"; -import { Tooltip } from "antd"; -import { updateExistingKeys } from "@/utils/dataUtils"; -import { flexRender, getCoreRowModel, getSortedRowModel, SortingState, useReactTable } from "@tanstack/react-table"; -import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell, Icon } from "@tremor/react"; -import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; -import { Badge, Text } from "@tremor/react"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { KeyResponse, Team } from "@/components/key_team_helpers/key_list"; -import { Organization, userListCall } from "@/components/networking"; -import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; -import FilterComponent, { FilterOption } from "@/components/molecules/filter"; -import { useFilterLogic } from "@/components/key_team_helpers/filter_logic"; -import useTeams from "@/app/(dashboard)/virtual-keys/hooks/useTeams"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import KeyInfoView from "@/app/(dashboard)/virtual-keys/components/KeyInfoView"; - -interface AllKeysTableProps { - keys: KeyResponse[]; - setKeys: (keys: KeyResponse[] | ((prev: KeyResponse[]) => KeyResponse[])) => void; - isLoading?: boolean; - pagination: { - currentPage: number; - totalPages: number; - totalCount: number; - }; - onPageChange: (page: number) => void; - pageSize?: number; - organizations: Organization[] | null; - refresh?: () => void; - onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void; - currentSort?: { - sortBy: string; - sortOrder: "asc" | "desc"; - }; - setAccessToken?: (token: string) => void; -} - -interface UserResponse { - user_id: string; - user_email: string; - user_role: string; -} - -const VirtualKeysTable = ({ - keys, - setKeys, - isLoading = false, - pagination, - onPageChange, - pageSize = 50, - organizations, - refresh, - onSortChange, - currentSort, - setAccessToken, -}: AllKeysTableProps) => { - const { userId: userID, userRole, accessToken, premiumUser } = useAuthorized(); - const { teams } = useTeams(); - const [selectedKeyId, setSelectedKeyId] = useState(null); - const [userList, setUserList] = useState([]); - const [sorting, setSorting] = React.useState(() => { - if (currentSort) { - return [ - { - id: currentSort.sortBy, - desc: currentSort.sortOrder === "desc", - }, - ]; - } - return [ - { - id: "created_at", - desc: true, - }, - ]; - }); - const [expandedAccordions, setExpandedAccordions] = useState>({}); - - // Use the filter logic hook - - const { filters, filteredKeys, allKeyAliases, allTeams, allOrganizations, handleFilterChange, handleFilterReset } = - useFilterLogic({ - keys, - teams, - organizations, - accessToken, - }); - - useEffect(() => { - if (accessToken) { - const user_IDs = keys.map((key) => key.user_id).filter((id) => id !== null); - const fetchUserList = async () => { - const userListData = await userListCall(accessToken, user_IDs, 1, 100); - setUserList(userListData.users); - }; - fetchUserList(); - } - }, [accessToken, keys]); - - // Add a useEffect to call refresh when a key is created - useEffect(() => { - if (!refresh || typeof window === "undefined") { - return; - } - const handleStorageChange = () => { - refresh(); - }; - - // Listen for storage events that might indicate a key was created - window.addEventListener("storage", handleStorageChange); - - return () => { - window.removeEventListener("storage", handleStorageChange); - }; - }, [refresh]); - - const columns: ColumnDef[] = [ - { - id: "expander", - header: () => null, - cell: ({ row }) => - row.getCanExpand() ? ( - - ) : null, - }, - { - id: "token", - accessorKey: "token", - header: "Key ID", - cell: (info) => ( -
- - - -
- ), - }, - { - id: "key_alias", - accessorKey: "key_alias", - header: "Key Alias", - cell: (info) => { - const value = info.getValue() as string; - return ( - {value ? (value.length > 20 ? `${value.slice(0, 20)}...` : value) : "-"} - ); - }, - }, - { - id: "key_name", - accessorKey: "key_name", - header: "Secret Key", - cell: (info) => {info.getValue() as string}, - }, - { - id: "team_alias", - accessorKey: "team_id", - header: "Team Alias", - cell: ({ row, getValue }) => { - const teamId = getValue() as string; - const team = teams?.find((t) => t.team_id === teamId); - return team?.team_alias || "Unknown"; - }, - }, - { - id: "team_id", - accessorKey: "team_id", - header: "Team ID", - cell: (info) => ( - - {info.getValue() ? `${(info.getValue() as string).slice(0, 7)}...` : "-"} - - ), - }, - { - id: "organization_id", - accessorKey: "organization_id", - header: "Organization ID", - cell: (info) => (info.getValue() ? info.renderValue() : "-"), - }, - { - id: "user_email", - accessorKey: "user_id", - header: "User Email", - cell: (info) => { - const userId = info.getValue() as string; - const user = userList.find((u) => u.user_id === userId); - return user?.user_email ? ( - - {user?.user_email.slice(0, 20)}... - - ) : ( - "-" - ); - }, - }, - { - id: "user_id", - accessorKey: "user_id", - header: "User ID", - cell: (info) => { - const userId = info.getValue() as string | null; - if (userId && userId.length > 15) { - return ( - - {userId.slice(0, 7)}... - - ); - } - return userId ? userId : "-"; - }, - }, - { - id: "created_at", - accessorKey: "created_at", - header: "Created At", - cell: (info) => { - const value = info.getValue(); - return value ? new Date(value as string).toLocaleDateString() : "-"; - }, - }, - { - id: "created_by", - accessorKey: "created_by", - header: "Created By", - cell: (info) => { - const value = info.getValue() as string | null; - if (value && value.length > 15) { - return ( - - {value.slice(0, 7)}... - - ); - } - return value; - }, - }, - { - id: "updated_at", - accessorKey: "updated_at", - header: "Updated At", - cell: (info) => { - const value = info.getValue(); - return value ? new Date(value as string).toLocaleDateString() : "Never"; - }, - }, - { - id: "expires", - accessorKey: "expires", - header: "Expires", - cell: (info) => { - const value = info.getValue(); - return value ? new Date(value as string).toLocaleDateString() : "Never"; - }, - }, - { - id: "spend", - accessorKey: "spend", - header: "Spend (USD)", - cell: (info) => formatNumberWithCommas(info.getValue() as number, 4), - }, - { - id: "max_budget", - accessorKey: "max_budget", - header: "Budget (USD)", - 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", - cell: (info) => { - const value = info.getValue(); - return value ? new Date(value as string).toLocaleString() : "Never"; - }, - }, - { - id: "models", - accessorKey: "models", - header: "Models", - 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", - 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"}
-
- ); - }, - }, - ]; - - const filterOptions: FilterOption[] = [ - { - name: "Team ID", - label: "Team ID", - isSearchable: true, - searchFn: async (searchText: string) => { - if (!allTeams || allTeams.length === 0) return []; - - const filteredTeams = allTeams.filter( - (team) => - team.team_id.toLowerCase().includes(searchText.toLowerCase()) || - (team.team_alias && team.team_alias.toLowerCase().includes(searchText.toLowerCase())), - ); - - return filteredTeams.map((team) => ({ - label: `${team.team_alias || team.team_id} (${team.team_id})`, - value: team.team_id, - })); - }, - }, - { - name: "Organization ID", - label: "Organization ID", - isSearchable: true, - searchFn: async (searchText: string) => { - if (!allOrganizations || allOrganizations.length === 0) return []; - - const filteredOrgs = allOrganizations.filter( - (org) => org.organization_id?.toLowerCase().includes(searchText.toLowerCase()) ?? false, - ); - - return filteredOrgs - .filter((org) => org.organization_id !== null && org.organization_id !== undefined) - .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) => { - const filteredKeyAliases = allKeyAliases.filter((key) => { - return key.toLowerCase().includes(searchText.toLowerCase()); - }); - - return filteredKeyAliases.map((key) => { - return { - label: key, - value: key, - }; - }); - }, - }, - { - name: "User ID", - label: "User ID", - isSearchable: false, - }, - { - name: "Key Hash", - label: "Key Hash", - isSearchable: false, - }, - ]; - - console.log(`keys: ${JSON.stringify(keys)}`); - - const table = useReactTable({ - data: filteredKeys, - columns: columns.filter((col) => col.id !== "expander"), - state: { - sorting, - }, - onSortingChange: (updaterOrValue) => { - const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; - console.log(`newSorting: ${JSON.stringify(newSorting)}`); - setSorting(newSorting); - if (newSorting && newSorting.length > 0) { - const sortState = newSorting[0]; - const sortBy = sortState.id; - const sortOrder = sortState.desc ? "desc" : "asc"; - console.log(`sortBy: ${sortBy}, sortOrder: ${sortOrder}`); - handleFilterChange({ - ...filters, - "Sort By": sortBy, - "Sort Order": sortOrder, - }); - onSortChange?.(sortBy, sortOrder); - } - }, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - enableSorting: true, - manualSorting: false, - }); - - // Update local sorting state when currentSort prop changes - React.useEffect(() => { - if (currentSort) { - setSorting([ - { - id: currentSort.sortBy, - desc: currentSort.sortOrder === "desc", - }, - ]); - } - }, [currentSort]); - - return ( -
- {selectedKeyId ? ( - setSelectedKeyId(null)} - keyData={filteredKeys.find((k) => k.token === selectedKeyId)} - onKeyDataUpdate={(updatedKeyData) => { - setKeys((keys) => - keys.map((key) => { - if (key.token === updatedKeyData.token) { - return updateExistingKeys(key, updatedKeyData); - } - return key; - }), - ); - if (refresh) refresh(); // Minimal fix: refresh the full key list after an update - }} - onDelete={() => { - setKeys((keys) => keys.filter((key) => key.token !== selectedKeyId)); - if (refresh) refresh(); // Minimal fix: refresh the full key list after a delete - }} - accessToken={accessToken} - userID={userID} - userRole={userRole} - teams={allTeams} - premiumUser={premiumUser} - setAccessToken={setAccessToken} - /> - ) : ( -
-
- -
- -
- - Showing{" "} - {isLoading - ? "..." - : `${(pagination.currentPage - 1) * pageSize + 1} - ${Math.min(pagination.currentPage * pageSize, pagination.totalCount)}`}{" "} - of {isLoading ? "..." : pagination.totalCount} results - - -
- - Page {isLoading ? "..." : pagination.currentPage} of {isLoading ? "..." : pagination.totalPages} - - - - - -
-
-
-
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - -
-
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} -
- {header.id !== "actions" && ( -
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
- )} -
-
- ))} -
- ))} -
- - {isLoading ? ( - - -
-

🚅 Loading keys...

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

No keys found

-
-
-
- )} -
-
-
-
-
-
- )} -
- ); -}; - -export default VirtualKeysTable; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts deleted file mode 100644 index ac174a554e..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { useCallback, useEffect, useState, useRef } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { debounce } from "lodash"; -import { KeyResponse, Team } from "@/components/key_team_helpers/key_list"; -import { keyListCall, Organization } from "@/components/networking"; -import { defaultPageSize } from "@/components/constants"; -import { fetchAllKeyAliases, fetchAllOrganizations, fetchAllTeams } from "@/components/key_team_helpers/filter_helpers"; - -export interface FilterState { - "Team ID": string; - "Organization ID": string; - "Key Alias": string; - [key: string]: string; - "User ID": string; - "Sort By": string; - "Sort Order": string; -} - -export function useFilterLogic({ - keys, - teams, - organizations, - accessToken, -}: { - keys: KeyResponse[]; - teams: Team[] | null; - organizations: Organization[] | null; - accessToken: string | null; -}) { - const defaultFilters: FilterState = { - "Team ID": "", - "Organization ID": "", - "Key Alias": "", - "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", - }; - const [filters, setFilters] = useState(defaultFilters); - const [allTeams, setAllTeams] = useState(teams || []); - const [allOrganizations, setAllOrganizations] = useState(organizations || []); - const [filteredKeys, setFilteredKeys] = useState(keys); - const lastSearchTimestamp = useRef(0); - const debouncedSearch = useCallback( - debounce(async (filters: FilterState) => { - if (!accessToken) { - return; - } - - const currentTimestamp = Date.now(); - lastSearchTimestamp.current = currentTimestamp; - - try { - // Make the API call using userListCall with all filter parameters - const data = await keyListCall( - accessToken, - filters["Organization ID"] || null, - filters["Team ID"] || null, - filters["Key Alias"] || null, - filters["User ID"] || null, - filters["Key Hash"] || null, - 1, // Reset to first page when searching - defaultPageSize, - filters["Sort By"] || null, - filters["Sort Order"] || null, - ); - - // Only update state if this is the most recent search - if (currentTimestamp === lastSearchTimestamp.current) { - if (data) { - setFilteredKeys(data.keys); - console.log("called from debouncedSearch filters:", JSON.stringify(filters)); - console.log("called from debouncedSearch data:", JSON.stringify(data)); - } - } - } catch (error) { - console.error("Error searching users:", error); - } - }, 300), - [accessToken], - ); - // Apply filters to keys whenever keys or filters change - useEffect(() => { - if (!keys) { - setFilteredKeys([]); - return; - } - - let result = [...keys]; - - // Apply Team ID filter - if (filters["Team ID"]) { - result = result.filter((key) => key.team_id === filters["Team ID"]); - } - - // Apply Organization ID filter - if (filters["Organization ID"]) { - result = result.filter((key) => key.organization_id === filters["Organization ID"]); - } - - setFilteredKeys(result); - }, [keys, filters]); - - // Fetch all data for filters when component mounts - useEffect(() => { - const loadAllFilterData = async () => { - // Load all teams - no organization filter needed here - const teamsData = await fetchAllTeams(accessToken); - if (teamsData.length > 0) { - setAllTeams(teamsData); - } - - // Load all organizations - const orgsData = await fetchAllOrganizations(accessToken); - if (orgsData.length > 0) { - setAllOrganizations(orgsData); - } - }; - - if (accessToken) { - loadAllFilterData(); - } - }, [accessToken]); - - const queryAllKeysQuery = useQuery({ - queryKey: ["allKeys"], - queryFn: async () => { - if (!accessToken) throw new Error("Access token required"); - return await fetchAllKeyAliases(accessToken); - }, - enabled: !!accessToken, - }); - const allKeyAliases = queryAllKeysQuery.data || []; - - // Update teams and organizations when props change - useEffect(() => { - if (teams && teams.length > 0) { - setAllTeams((prevTeams) => { - // Only update if we don't already have a larger set of teams - return prevTeams.length < teams.length ? teams : prevTeams; - }); - } - }, [teams]); - - useEffect(() => { - if (organizations && organizations.length > 0) { - setAllOrganizations((prevOrgs) => { - // Only update if we don't already have a larger set of organizations - return prevOrgs.length < organizations.length ? organizations : prevOrgs; - }); - } - }, [organizations]); - - const handleFilterChange = (newFilters: Record) => { - // Update filters state - setFilters({ - "Team ID": newFilters["Team ID"] || "", - "Organization ID": newFilters["Organization ID"] || "", - "Key Alias": newFilters["Key Alias"] || "", - "User ID": newFilters["User ID"] || "", - "Sort By": newFilters["Sort By"] || "created_at", - "Sort Order": newFilters["Sort Order"] || "desc", - }); - - // Fetch keys based on new filters - const updatedFilters = { - ...filters, - ...newFilters, - }; - debouncedSearch(updatedFilters); - }; - - const handleFilterReset = () => { - // Reset filters state - setFilters(defaultFilters); - - // Reset selections - debouncedSearch(defaultFilters); - }; - - return { - filters, - filteredKeys, - allKeyAliases, - allTeams, - allOrganizations, - handleFilterChange, - handleFilterReset, - }; -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/page.tsx index 611d92f1a3..56f780b296 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/virtual-keys/page.tsx @@ -1,16 +1,18 @@ "use client"; -import VirtualKeysTable from "@/app/(dashboard)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable"; import { useState } from "react"; -import useKeyList from "@/components/key_team_helpers/key_list"; +import useKeyList, { KeyResponse } from "@/components/key_team_helpers/key_list"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { Col, Grid } from "@tremor/react"; -import CreateKey from "@/app/(dashboard)/virtual-keys/components/CreateKey"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import UserDashboard from "@/components/user_dashboard"; +import useTeams from "@/app/(dashboard)/hooks/useTeams"; +import { Organization } from "@/components/networking"; const VirtualKeysPage = () => { - const { accessToken, userRole } = useAuthorized(); + const { accessToken, userRole, userId, premiumUser, userEmail } = useAuthorized(); + const { teams, setTeams } = useTeams(); const [createClicked, setCreateClicked] = useState(false); + const [organizations, setOrganizations] = useState([]); const queryClient = new QueryClient(); @@ -28,20 +30,21 @@ const VirtualKeysPage = () => { return ( -
- - - - {}} - organizations={null} - /> - - -
+ {}} + setUserEmail={() => {}} + setTeams={setTeams} + setKeys={setKeys} + premiumUser={premiumUser} + organizations={organizations} + addKey={addKey} + createClicked={createClicked} + />
); }; diff --git a/ui/litellm-dashboard/src/components/templates/view_key_table.tsx b/ui/litellm-dashboard/src/components/templates/view_key_table.tsx index d146d5ba79..86903d5553 100644 --- a/ui/litellm-dashboard/src/components/templates/view_key_table.tsx +++ b/ui/litellm-dashboard/src/components/templates/view_key_table.tsx @@ -90,7 +90,7 @@ interface ViewKeyTableProps { selectedTeam: Team | null; setSelectedTeam: React.Dispatch>; data: KeyResponse[] | null; - setData: React.Dispatch>; + setData: (keys: KeyResponse[]) => void; teams: Team[] | null; premiumUser: boolean; currentOrg: Organization | null; @@ -135,13 +135,6 @@ interface CombinedLimits { [key: string]: CombinedLimit; // Index signature allowing string keys } -/** - * ───────────────────────────────────────────────────────────────────────── - * @deprecated - * This component is being DEPRECATED in favor of src/app/(dashboard)/virtual-keys/components/VirtualKeysTable/ - * Please contribute to the new refactor. - * ───────────────────────────────────────────────────────────────────────── - */ const ViewKeyTable: React.FC = ({ userID, userRole, diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index b6470e4952..4d98b37a8f 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -20,11 +20,12 @@ import ViewUserTeam from "./view_user_team"; import DashboardTeam from "./dashboard_default_team"; import Onboarding from "../app/onboarding/page"; import { useSearchParams, useRouter } from "next/navigation"; -import { Team } from "./key_team_helpers/key_list"; +import { KeyResponse, Team } from "./key_team_helpers/key_list"; import { jwtDecode } from "jwt-decode"; import { Typography } from "antd"; import { clearTokenCookies } from "@/utils/cookieUtils"; import { clearMCPAuthTokens } from "./mcp_tools/mcp_auth_storage"; +import { Setter } from "@/types"; export interface ProxySettings { PROXY_BASE_URL: string | null; @@ -56,7 +57,7 @@ interface UserDashboardProps { setUserRole: React.Dispatch>; setUserEmail: React.Dispatch>; setTeams: React.Dispatch>; - setKeys: React.Dispatch>; + setKeys: (keys: KeyResponse[]) => void; premiumUser: boolean; organizations: Organization[] | null; addKey: (data: any) => void; diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx index 19479ce21a..7fcb7f54b8 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -30,6 +30,8 @@ import { updateExistingKeys } from "@/utils/dataUtils"; import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; import { isAdminRole } from "@/utils/roles"; import NotificationsManager from "./molecules/notifications_manager"; +import { Setter } from "@/types"; +import { KeyResponse } from "@/components/key_team_helpers/key_list"; interface ViewUserDashboardProps { accessToken: string | null; From 97031dc8eeac09b3576c6a40dffdce5d59084de5 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Wed, 8 Oct 2025 17:41:04 -0700 Subject: [PATCH 32/42] Fix - (openrouter): move cache_control to content blocks for claude/gemini (#15345) * test_openrouter_transform_request_with_cache_control * fix CacheControlSupportedModels * test_openrouter_transform_request_with_cache_control_gemini --- .../llms/openrouter/chat/transformation.py | 66 +++++- .../test_openrouter_chat_transformation.py | 203 ++++++++++++++++++ 2 files changed, 268 insertions(+), 1 deletion(-) diff --git a/litellm/llms/openrouter/chat/transformation.py b/litellm/llms/openrouter/chat/transformation.py index bf57218c91..a47689a30b 100644 --- a/litellm/llms/openrouter/chat/transformation.py +++ b/litellm/llms/openrouter/chat/transformation.py @@ -6,6 +6,7 @@ Calls done in OpenAI/openai.py as OpenRouter is openai-compatible. Docs: https://openrouter.ai/docs/parameters """ +from enum import Enum from typing import Any, AsyncIterator, Iterator, List, Optional, Tuple, Union import httpx @@ -20,6 +21,12 @@ from ...openai.chat.gpt_transformation import OpenAIGPTConfig from ..common_utils import OpenRouterException +class CacheControlSupportedModels(str, Enum): + """Models that support cache_control in content blocks.""" + CLAUDE = "claude" + GEMINI = "gemini" + + class OpenrouterConfig(OpenAIGPTConfig): def map_openai_params( self, @@ -48,19 +55,73 @@ class OpenrouterConfig(OpenAIGPTConfig): ) return mapped_openai_params + def _supports_cache_control_in_content(self, model: str) -> bool: + """ + Check if the model supports cache_control in content blocks. + + Returns: + bool: True if model supports cache_control (Claude or Gemini models) + """ + model_lower = model.lower() + return any( + supported_model.value in model_lower + for supported_model in CacheControlSupportedModels + ) + def remove_cache_control_flag_from_messages_and_tools( self, model: str, messages: List[AllMessageValues], tools: Optional[List["ChatCompletionToolParam"]] = None, ) -> Tuple[List[AllMessageValues], Optional[List["ChatCompletionToolParam"]]]: - if "claude" in model.lower(): # don't remove 'cache_control' flag + if self._supports_cache_control_in_content(model): return messages, tools else: return super().remove_cache_control_flag_from_messages_and_tools( model, messages, tools ) + def _move_cache_control_to_content( + self, messages: List[AllMessageValues] + ) -> List[AllMessageValues]: + """ + Move cache_control from message level to content blocks. + OpenRouter requires cache_control to be inside content blocks, not at message level. + + When cache_control is at message level, it's added to ALL content blocks + to cache the entire message content. + """ + transformed_messages = [] + for message in messages: + message_copy = dict(message) + cache_control = message_copy.pop("cache_control", None) + + if cache_control is not None: + content = message_copy.get("content") + + if isinstance(content, list): + # Content is already a list, add cache_control to all blocks + if len(content) > 0: + content_copy = [] + for block in content: + block_copy = dict(block) + block_copy["cache_control"] = cache_control + content_copy.append(block_copy) + message_copy["content"] = content_copy + else: + # Content is a string, convert to structured format + message_copy["content"] = [ + { + "type": "text", + "text": content, + "cache_control": cache_control, + } + ] + + transformed_messages.append(message_copy) + + return transformed_messages + def transform_request( self, model: str, @@ -75,6 +136,9 @@ class OpenrouterConfig(OpenAIGPTConfig): Returns: dict: The transformed request. Sent as the body of the API call. """ + if self._supports_cache_control_in_content(model): + messages = self._move_cache_control_to_content(messages) + extra_body = optional_params.pop("extra_body", {}) response = super().transform_request( model, messages, optional_params, litellm_params, headers diff --git a/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py b/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py index 02eb872aba..ff81ba84c0 100644 --- a/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py +++ b/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py @@ -114,3 +114,206 @@ def test_openrouter_cache_control_flag_removal(): headers={}, ) assert transformed_request["messages"][0].get("cache_control") is None + + + +def test_openrouter_transform_request_with_cache_control(): + """ + Test transform_request moves cache_control from message level to content blocks (string content). + + Input: + { + "role": "user", + "content": "what are the key terms...", + "cache_control": {"type": "ephemeral"} + } + + Expected Output: + { + "role": "user", + "content": [ + { + "type": "text", + "text": "what are the key terms...", + "cache_control": {"type": "ephemeral"} + } + ] + } + """ + import json + config = OpenrouterConfig() + + messages = [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are an AI assistant tasked with analyzing legal documents." + }, + { + "type": "text", + "text": "Here is the full text of a complex legal agreement" + } + ] + }, + { + "role": "user", + "content": "what are the key terms and conditions in this agreement?", + "cache_control": {"type": "ephemeral"} + } + ] + + transformed_request = config.transform_request( + model="openrouter/anthropic/claude-3-5-sonnet-20240620", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + + print("\n=== Transformed Request ===") + print(json.dumps(transformed_request, indent=4, default=str)) + + assert "messages" in transformed_request + assert len(transformed_request["messages"]) == 2 + + user_message = transformed_request["messages"][1] + assert user_message["role"] == "user" + assert isinstance(user_message["content"], list) + assert user_message["content"][0]["type"] == "text" + assert user_message["content"][0]["cache_control"] == {"type": "ephemeral"} + + +def test_openrouter_transform_request_with_cache_control_list_content(): + """ + Test transform_request moves cache_control to all content blocks when content is already a list. + + Input: + { + "role": "system", + "content": [ + {"type": "text", "text": "You are a historian..."}, + {"type": "text", "text": "HUGE TEXT BODY"} + ], + "cache_control": {"type": "ephemeral"} + } + + Expected Output: + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are a historian...", + "cache_control": {"type": "ephemeral"} + }, + { + "type": "text", + "text": "HUGE TEXT BODY", + "cache_control": {"type": "ephemeral"} + } + ] + } + """ + import json + config = OpenrouterConfig() + + messages = [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are a historian studying the fall of the Roman Empire." + }, + { + "type": "text", + "text": "HUGE TEXT BODY" + } + ], + "cache_control": {"type": "ephemeral"} + }, + { + "role": "user", + "content": "What triggered the collapse?" + } + ] + + transformed_request = config.transform_request( + model="openrouter/anthropic/claude-3-5-sonnet-20240620", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + + print("\n=== Transformed Request (List Content) ===") + print(json.dumps(transformed_request, indent=4, default=str)) + + assert "messages" in transformed_request + assert len(transformed_request["messages"]) == 2 + + system_message = transformed_request["messages"][0] + assert system_message["role"] == "system" + assert isinstance(system_message["content"], list) + assert len(system_message["content"]) == 2 + assert system_message["content"][0]["cache_control"] == {"type": "ephemeral"} + assert system_message["content"][1]["cache_control"] == {"type": "ephemeral"} + assert "cache_control" not in system_message + + +def test_openrouter_transform_request_with_cache_control_gemini(): + """ + Test transform_request moves cache_control to content blocks for Gemini models. + + Input: + { + "role": "user", + "content": "Analyze this data", + "cache_control": {"type": "ephemeral"} + } + + Expected Output: + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Analyze this data", + "cache_control": {"type": "ephemeral"} + } + ] + } + """ + import json + config = OpenrouterConfig() + + messages = [ + { + "role": "user", + "content": "Analyze this data", + "cache_control": {"type": "ephemeral"} + } + ] + + transformed_request = config.transform_request( + model="openrouter/google/gemini-2.0-flash-exp:free", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + + print("\n=== Transformed Request (Gemini) ===") + print(json.dumps(transformed_request, indent=4, default=str)) + + assert "messages" in transformed_request + assert len(transformed_request["messages"]) == 1 + + user_message = transformed_request["messages"][0] + assert user_message["role"] == "user" + assert isinstance(user_message["content"], list) + assert user_message["content"][0]["type"] == "text" + assert user_message["content"][0]["cache_control"] == {"type": "ephemeral"} + \ No newline at end of file From 3beb4a0797baee8694804b02aaa171be312ba1de Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Wed, 8 Oct 2025 17:54:48 -0700 Subject: [PATCH 33/42] added reload to base URL when FF is turned off --- .../src/app/(dashboard)/layout.tsx | 2 +- ui/litellm-dashboard/src/app/page.tsx | 1 - .../src/hooks/useFeatureFlags.tsx | 25 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 3d38a7362b..68ab361356 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -63,7 +63,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { />
- +
{children}
diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index ceee52399e..cefa680fc8 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -23,7 +23,6 @@ import ModelHubTable from "@/components/model_hub_table"; import NewUsagePage from "@/components/new_usage"; import APIRef from "@/components/api_ref"; import ChatUI from "@/components/chat_ui/ChatUI"; -import Sidebar from "@/components/leftnav"; import Usage from "@/components/usage"; import CacheDashboard from "@/components/cache_dashboard"; import { getUiConfig, proxyBaseUrl, setGlobalLitellmHeaderName } from "@/components/networking"; diff --git a/ui/litellm-dashboard/src/hooks/useFeatureFlags.tsx b/ui/litellm-dashboard/src/hooks/useFeatureFlags.tsx index f1914f463a..d3d676d2d2 100644 --- a/ui/litellm-dashboard/src/hooks/useFeatureFlags.tsx +++ b/ui/litellm-dashboard/src/hooks/useFeatureFlags.tsx @@ -1,5 +1,13 @@ "use client"; + +const getBasePath = () => { + const raw = process.env.NEXT_PUBLIC_BASE_URL ?? ""; + const trimmed = raw.replace(/^\/+|\/+$/g, ""); // strip leading/trailing slashes + return trimmed ? `/${trimmed}/` : "/"; // ensure trailing slash +}; + import React, { createContext, useContext, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; // ⟵ add this type Flags = { refactoredUIFlag: boolean; @@ -48,6 +56,8 @@ function writeFlagSafely(v: boolean) { } export const FeatureFlagsProvider = ({ children }: { children: React.ReactNode }) => { + const router = useRouter(); // ⟵ add this + // Lazy init reads from localStorage only on the client const [refactoredUIFlag, setRefactoredUIFlagState] = useState(() => readFlagSafely()); @@ -73,6 +83,21 @@ export const FeatureFlagsProvider = ({ children }: { children: React.ReactNode } return () => window.removeEventListener("storage", onStorage); }, []); + // Redirect to base path the moment the flag is OFF. + useEffect(() => { + if (refactoredUIFlag) return; // only act when turned off + + const base = getBasePath(); + const normalize = (p: string) => (p.endsWith("/") ? p : p + "/"); + const current = normalize(window.location.pathname); + + // Avoid a redirect loop if we're already at the base path. + if (current !== base) { + // Replace so the "off" redirect doesn't pollute history. + router.replace(base); + } + }, [refactoredUIFlag, router]); + return ( {children} ); From ff4eda0ea67730da3363f2d151decb9ee3ce4e9c Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Wed, 8 Oct 2025 18:00:15 -0700 Subject: [PATCH 34/42] removed unscoped changes (will be added later) --- .../components/modals/CreateUserModal.tsx | 346 ------------------ .../src/components/leftnav.tsx | 41 +-- 2 files changed, 18 insertions(+), 369 deletions(-) delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/components/modals/CreateUserModal.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/modals/CreateUserModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/modals/CreateUserModal.tsx deleted file mode 100644 index be3ea48bf2..0000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/modals/CreateUserModal.tsx +++ /dev/null @@ -1,346 +0,0 @@ -"use client"; - -// TODO: refactor -import React, { useState, useEffect } from "react"; -import { Button, Modal, Form, Input, message, Select, InputNumber, Select as Select2 } from "antd"; -import { - Button as Button2, - Text, - TextInput, - SelectItem, - Accordion, - AccordionHeader, - AccordionBody, - Title, -} from "@tremor/react"; -import NotificationsManager from "@/components/molecules/notifications_manager"; -const { Option } = Select; -import { Tooltip } from "antd"; -import { InfoCircleOutlined } from "@ant-design/icons"; -import { useQueryClient } from "@tanstack/react-query"; -import OnboardingModal, { InvitationLink } from "@/components/onboarding_link"; -import { - getProxyBaseUrl, - getProxyUISettings, - invitationCreateCall, - modelAvailableCall, - Team, - userCreateCall, -} from "@/components/networking"; -import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; -import BulkCreateUsersButton from "@/components/bulk_create_users_button"; -import useTeams from "@/app/(dashboard)/hooks/useTeams"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; - -// Helper function to generate UUID compatible across all environments -const generateUUID = (): string => { - if (typeof crypto !== "undefined" && crypto.randomUUID) { - return crypto.randomUUID(); - } - // Fallback UUID generation for environments without crypto.randomUUID - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { - const r = (Math.random() * 16) | 0; - const v = c == "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -}; - -interface CreateUserModalProps { - possibleUIRoles: null | Record>; - onUserCreated?: (userId: string) => void; - isEmbedded?: boolean; -} - -interface UISettings { - PROXY_BASE_URL: string | null; - PROXY_LOGOUT_URL: string | null; - DEFAULT_TEAM_DISABLED: boolean; - SSO_ENABLED: boolean; -} - -const CreateUserModal: React.FC = ({ possibleUIRoles, onUserCreated, isEmbedded = false }) => { - const { userId: userID, accessToken } = useAuthorized(); - const queryClient = useQueryClient(); - const [uiSettings, setUISettings] = useState(null); - const [form] = Form.useForm(); - const [isModalVisible, setIsModalVisible] = useState(false); - const [apiuser, setApiuser] = useState(false); - const [userModels, setUserModels] = useState([]); - const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false); - const [invitationLinkData, setInvitationLinkData] = useState(null); - const [baseUrl, setBaseUrl] = useState(null); - const { teams } = useTeams(); - - // get all models - useEffect(() => { - const fetchData = async () => { - try { - const userRole = "any"; // You may need to get the user role dynamically - const modelDataResponse = await modelAvailableCall(accessToken, userID, userRole); - // Assuming modelDataResponse.data contains an array of model objects with a 'model_name' property - const availableModels = []; - for (let i = 0; i < modelDataResponse.data.length; i++) { - const model = modelDataResponse.data[i]; - availableModels.push(model.id); - } - console.log("Model data response:", modelDataResponse.data); - console.log("Available models:", availableModels); - - // Assuming modelDataResponse.data contains an array of model names - setUserModels(availableModels); - - // get ui settings - const uiSettingsResponse = await getProxyUISettings(accessToken); - console.log("uiSettingsResponse:", uiSettingsResponse); - - setUISettings(uiSettingsResponse); - } catch (error) { - console.error("Error fetching model data:", error); - } - }; - - setBaseUrl(getProxyBaseUrl()); - - fetchData(); // Call the function to fetch model data when the component mounts - }, []); // Empty dependency array to run only once - - const handleOk = () => { - setIsModalVisible(false); - form.resetFields(); - }; - - const handleCancel = () => { - setIsModalVisible(false); - setApiuser(false); - form.resetFields(); - }; - - const handleCreate = async (formValues: { user_id: string; models?: string[]; user_role: string }) => { - try { - NotificationsManager.info("Making API Call"); - if (!isEmbedded) { - setIsModalVisible(true); - } - if ((!formValues.models || formValues.models.length === 0) && formValues.user_role !== "proxy_admin") { - console.log("formValues.user_role", formValues.user_role); - // If models is empty or undefined, set it to "no-default-models" - formValues.models = ["no-default-models"]; - } - console.log("formValues in create user:", formValues); - const response = await userCreateCall(accessToken, null, formValues); - await queryClient.invalidateQueries({ queryKey: ["userList"] }); - console.log("user create Response:", response); - setApiuser(true); - const user_id = response.data?.user_id || response.user_id; - - // Call the callback if provided (for embedded mode) - if (onUserCreated && isEmbedded) { - onUserCreated(user_id); - form.resetFields(); - return; // Skip the invitation flow when embedded - } - - // only do invite link flow if sso is not enabled - if (!uiSettings?.SSO_ENABLED) { - invitationCreateCall(accessToken, user_id).then((data) => { - data.has_user_setup_sso = false; - setInvitationLinkData(data); - setIsInvitationLinkModalVisible(true); - }); - } else { - // create an InvitationLink Object for this user for the SSO flow - // for SSO the invite link is the proxy base url since the User just needs to login - const invitationLink: InvitationLink = { - id: generateUUID(), // Generate a unique ID - user_id: user_id, - is_accepted: false, - accepted_at: null, - expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // Set expiry to 7 days from now - created_at: new Date(), - created_by: userID, // Assuming userID is the current user creating the invitation - updated_at: new Date(), - updated_by: userID, - has_user_setup_sso: true, - }; - setInvitationLinkData(invitationLink); - setIsInvitationLinkModalVisible(true); - } - - NotificationsManager.success("API user Created"); - form.resetFields(); - localStorage.removeItem("userData" + userID); - } catch (error: any) { - const errorMessage = error.response?.data?.detail || error?.message || "Error creating the user"; - NotificationsManager.fromBackend(errorMessage); - console.error("Error creating the user:", error); - } - }; - - // Modify the return statement to handle embedded mode - if (isEmbedded) { - return ( -
- - - - - - {possibleUIRoles && - Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => ( - -
- {ui_label}{" "} -

- {description} -

-
-
- ))} -
-
- - - - - - - - -
- -
-
- ); - } - - // Original return for standalone mode - return ( -
- setIsModalVisible(true)}> - + Invite User - - - - Create a User who can own keys -
- - - - - Global Proxy Role{" "} - - - - - } - name="user_role" - > - - {possibleUIRoles && - Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => ( - -
- {ui_label}{" "} -

- {description} -

-
-
- ))} -
-
- - - - - - - - - - - Personal Key Creation - - - - Models{" "} - - - - - } - name="models" - help="Models user has access to, outside of team scope." - > - - - All Proxy Models - - {userModels.map((model) => ( - - {getModelDisplayName(model)} - - ))} - - - - -
- -
-
-
- {apiuser && ( - - )} -
- ); -}; - -export default CreateUserModal; diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index a0d8d162c6..e47691d3bb 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -1,4 +1,8 @@ import { Layout, Menu } from "antd"; +import Link from "next/link"; +import { List } from "postcss/lib/list"; +import { Text, Button } from "@tremor/react"; +import { useState } from "react"; import { KeyOutlined, PlayCircleOutlined, @@ -56,17 +60,6 @@ interface MenuItem { icon?: React.ReactNode; } -/** ---------- Base URL helpers ---------- */ -/** - * Normalizes NEXT_PUBLIC_BASE_URL to either "/" or "/ui/" (always with a trailing slash). - * Supported env values: "" or "ui/". - */ -const getBasePath = () => { - const raw = process.env.NEXT_PUBLIC_BASE_URL ?? ""; - const trimmed = raw.replace(/^\/+|\/+$/g, ""); // strip leading/trailing slashes - return trimmed ? `/${trimmed}/` : "/"; // ensure trailing slash -}; - const Sidebar: React.FC = ({ accessToken, setPage, userRole, defaultSelectedKey, collapsed = false }) => { // Note: If a menu item does not have a role, it is visible to all roles. const menuItems: MenuItem[] = [ @@ -225,7 +218,6 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau ], }, ]; - // Find the menu item that matches the default page, including in submenus const findMenuItemKey = (page: string): string => { // Check top-level items @@ -260,15 +252,6 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau return true; }); - // Centralized navigation that prefixes the base path ("/" or "/ui/") - const goTo = (page: string) => { - const base = getBasePath(); // "/" or "/ui/" - const newSearchParams = new URLSearchParams(window.location.search); - newSearchParams.set("page", page); - window.history.pushState(null, "", `${base}?${newSearchParams.toString()}`); // e.g. "/ui/?page=..." - setPage(page); - }; - return ( = ({ accessToken, setPage, userRole, defau key: child.key, icon: child.icon, label: child.label, - onClick: () => goTo(child.page), + onClick: () => { + const newSearchParams = new URLSearchParams(window.location.search); + newSearchParams.set("page", child.page); + window.history.pushState(null, "", `?${newSearchParams.toString()}`); + setPage(child.page); + }, })), - onClick: !item.children ? () => goTo(item.page) : undefined, + onClick: !item.children + ? () => { + const newSearchParams = new URLSearchParams(window.location.search); + newSearchParams.set("page", item.page); + window.history.pushState(null, "", `?${newSearchParams.toString()}`); + setPage(item.page); + } + : undefined, }))} /> From 2f42c806cbba2693b222026ec6cbc99432dff716 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Wed, 8 Oct 2025 18:10:43 -0700 Subject: [PATCH 35/42] [Fix] x-litellm-cache-key header not being returned on cache hit (#15348) * fix: x-cache-key * test_cache_key_in_hidden_params_acompletion * fix: remove_cache_control_flag_from_messages_and_tools --- litellm/caching/caching_handler.py | 16 ++---- .../llms/openai/chat/gpt_transformation.py | 8 +-- tests/local_testing/test_caching.py | 53 +++++++++++++++++++ 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/litellm/caching/caching_handler.py b/litellm/caching/caching_handler.py index 17cd50f75a..6bbc323122 100644 --- a/litellm/caching/caching_handler.py +++ b/litellm/caching/caching_handler.py @@ -14,10 +14,10 @@ It utilizes the (RedisCache, s3Cache, RedisSemanticCache, QdrantSemanticCache, I In each method it will call the appropriate method from caching.py """ -import time import asyncio import datetime import inspect +import time from typing import ( TYPE_CHECKING, Any, @@ -62,12 +62,10 @@ else: LiteLLMLoggingObj = Any -from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper - - from litellm.litellm_core_utils.core_helpers import ( -_get_parent_otel_span_from_kwargs, + _get_parent_otel_span_from_kwargs, ) +from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper class CachingHandlerResponse(BaseModel): @@ -214,9 +212,7 @@ class LLMCachingHandler: end_time=end_time, cache_hit=cache_hit, ) - cache_key = litellm.cache._get_preset_cache_key_from_kwargs( - **kwargs - ) + cache_key = litellm.cache.get_cache_key(**kwargs) if ( isinstance(cached_result, BaseModel) or isinstance(cached_result, CustomStreamWrapper) @@ -330,9 +326,7 @@ class LLMCachingHandler: end_time=end_time, cache_hit=cache_hit ) - cache_key = litellm.cache._get_preset_cache_key_from_kwargs( - **kwargs - ) + cache_key = litellm.cache.get_cache_key(**kwargs) if ( isinstance(cached_result, BaseModel) or isinstance(cached_result, CustomStreamWrapper) diff --git a/litellm/llms/openai/chat/gpt_transformation.py b/litellm/llms/openai/chat/gpt_transformation.py index 204916e3a4..3e18617905 100644 --- a/litellm/llms/openai/chat/gpt_transformation.py +++ b/litellm/llms/openai/chat/gpt_transformation.py @@ -397,13 +397,13 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): ) from litellm.types.llms.openai import ChatCompletionToolParam - for message in messages: - message = cast( + for i, message in enumerate(messages): + messages[i] = cast( AllMessageValues, filter_value_from_dict(message, "cache_control") # type: ignore ) if tools is not None: - for tool in tools: - tool = cast( + for i, tool in enumerate(tools): + tools[i] = cast( ChatCompletionToolParam, filter_value_from_dict(tool, "cache_control"), # type: ignore ) diff --git a/tests/local_testing/test_caching.py b/tests/local_testing/test_caching.py index ee3082a9d3..30bacfff8b 100644 --- a/tests/local_testing/test_caching.py +++ b/tests/local_testing/test_caching.py @@ -2765,3 +2765,56 @@ def test_caching_thinking_args_hit(): # test in memory cache except Exception as e: print(f"error occurred: {traceback.format_exc()}") pytest.fail(f"Error occurred: {e}") + + +@pytest.mark.asyncio +async def test_cache_key_in_hidden_params_acompletion(): + """ + Test that cache_key is present in _hidden_params on cache hits for acompletion. + + Validates fix for missing x-litellm-cache-key header on proxy cache hits. + """ + litellm.cache = Cache( + type="redis", + host=os.environ["REDIS_HOST"], + port=os.environ["REDIS_PORT"], + password=os.environ["REDIS_PASSWORD"], + ) + + unique_content = f"test cache key hidden params {uuid.uuid4()}" + messages = [{"role": "user", "content": unique_content}] + + # First call - cache miss + response1 = await litellm.acompletion( + model="gpt-3.5-turbo", + messages=messages, + mock_response="test response", + caching=True, + ) + + print(f"Response 1 _hidden_params: {response1._hidden_params}") + assert response1._hidden_params.get("cache_hit") is not True + + await asyncio.sleep(0.5) + + # Second call - cache hit + response2 = await litellm.acompletion( + model="gpt-3.5-turbo", + messages=messages, + mock_response="test response", + caching=True, + ) + + print(f"Response 2 _hidden_params: {response2._hidden_params}") + + # Verify cache hit occurred + assert response2._hidden_params.get("cache_hit") is True + + # Verify cache_key is present in _hidden_params + assert "cache_key" in response2._hidden_params + assert response2._hidden_params["cache_key"] is not None + + # Verify both responses have same ID (cache hit) + assert response1.id == response2.id + + litellm.cache = None From 422631409678036468a9fdc927c4cdca3a9196a7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Oct 2025 18:31:26 -0700 Subject: [PATCH 36/42] Add native Responses API support for litellm_proxy provider (#15347) * Initial plan * Add native Responses API support for litellm_proxy provider Co-authored-by: ishaan-jaff <29436595+ishaan-jaff@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ishaan-jaff <29436595+ishaan-jaff@users.noreply.github.com> --- litellm/__init__.py | 3 + .../litellm_proxy/responses/transformation.py | 48 +++++++++++ litellm/utils.py | 2 + tests/local_testing/test_config.py | 21 +++++ tests/test_litellm_proxy_responses_config.py | 84 +++++++++++++++++++ 5 files changed, 158 insertions(+) create mode 100644 litellm/llms/litellm_proxy/responses/transformation.py create mode 100644 tests/test_litellm_proxy_responses_config.py diff --git a/litellm/__init__.py b/litellm/__init__.py index 60cf327c26..e461c88efd 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1190,6 +1190,9 @@ from .llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig from .llms.azure.responses.o_series_transformation import ( AzureOpenAIOSeriesResponsesAPIConfig, ) +from .llms.litellm_proxy.responses.transformation import ( + LiteLLMProxyResponsesAPIConfig, +) from .llms.openai.chat.o_series_transformation import ( OpenAIOSeriesConfig as OpenAIO1Config, # maintain backwards compatibility OpenAIOSeriesConfig, diff --git a/litellm/llms/litellm_proxy/responses/transformation.py b/litellm/llms/litellm_proxy/responses/transformation.py new file mode 100644 index 0000000000..0b81d8be7d --- /dev/null +++ b/litellm/llms/litellm_proxy/responses/transformation.py @@ -0,0 +1,48 @@ +""" +Responses API transformation for LiteLLM Proxy provider. + +LiteLLM Proxy supports the OpenAI Responses API natively when the underlying model supports it. +This config enables pass-through behavior to the proxy's /v1/responses endpoint. +""" + +from typing import Optional + +from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig +from litellm.secret_managers.main import get_secret_str +from litellm.types.utils import LlmProviders + + +class LiteLLMProxyResponsesAPIConfig(OpenAIResponsesAPIConfig): + """ + Configuration for LiteLLM Proxy Responses API support. + + Extends OpenAI's config since the proxy follows OpenAI's API spec, + but uses LITELLM_PROXY_API_BASE for the base URL. + """ + + @property + def custom_llm_provider(self) -> LlmProviders: + return LlmProviders.LITELLM_PROXY + + def get_complete_url( + self, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + """ + Get the endpoint for LiteLLM Proxy responses API. + + Uses LITELLM_PROXY_API_BASE environment variable if api_base is not provided. + """ + api_base = api_base or get_secret_str("LITELLM_PROXY_API_BASE") + + if api_base is None: + raise ValueError( + "api_base not set for LiteLLM Proxy responses API. " + "Set via api_base parameter or LITELLM_PROXY_API_BASE environment variable" + ) + + # Remove trailing slashes + api_base = api_base.rstrip("/") + + return f"{api_base}/responses" diff --git a/litellm/utils.py b/litellm/utils.py index 47250615ef..5861d703a3 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7305,6 +7305,8 @@ class ProviderConfigManager: return litellm.AzureOpenAIOSeriesResponsesAPIConfig() else: return litellm.AzureOpenAIResponsesAPIConfig() + elif litellm.LlmProviders.LITELLM_PROXY == provider: + return litellm.LiteLLMProxyResponsesAPIConfig() return None @staticmethod diff --git a/tests/local_testing/test_config.py b/tests/local_testing/test_config.py index a0a31c2e78..5d8fae282c 100644 --- a/tests/local_testing/test_config.py +++ b/tests/local_testing/test_config.py @@ -401,3 +401,24 @@ def test_provider_config_manager_bedrock_converse_like(): # model="gpt-3.5-turbo", provider=LlmProviders(provider) # ) # _check_provider_config(config, provider) + + +def test_litellm_proxy_responses_api_config(): + """Test that litellm_proxy provider returns correct Responses API config""" + from litellm.llms.litellm_proxy.responses.transformation import ( + LiteLLMProxyResponsesAPIConfig, + ) + + config = ProviderConfigManager.get_provider_responses_api_config( + model="litellm_proxy/gpt-4", + provider=LlmProviders.LITELLM_PROXY, + ) + print(f"config: {config}") + assert config is not None, "Config should not be None for litellm_proxy provider" + assert isinstance( + config, LiteLLMProxyResponsesAPIConfig + ), f"Expected LiteLLMProxyResponsesAPIConfig, got {type(config)}" + assert ( + config.custom_llm_provider == LlmProviders.LITELLM_PROXY + ), "custom_llm_provider should be LITELLM_PROXY" + diff --git a/tests/test_litellm_proxy_responses_config.py b/tests/test_litellm_proxy_responses_config.py new file mode 100644 index 0000000000..6242a9dbf3 --- /dev/null +++ b/tests/test_litellm_proxy_responses_config.py @@ -0,0 +1,84 @@ +""" +Unit test for LiteLLM Proxy Responses API configuration. +""" + +import pytest + +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager + + +def test_litellm_proxy_responses_api_config(): + """Test that litellm_proxy provider returns correct Responses API config""" + from litellm.llms.litellm_proxy.responses.transformation import ( + LiteLLMProxyResponsesAPIConfig, + ) + + config = ProviderConfigManager.get_provider_responses_api_config( + model="litellm_proxy/gpt-4", + provider=LlmProviders.LITELLM_PROXY, + ) + print(f"config: {config}") + assert config is not None, "Config should not be None for litellm_proxy provider" + assert isinstance( + config, LiteLLMProxyResponsesAPIConfig + ), f"Expected LiteLLMProxyResponsesAPIConfig, got {type(config)}" + assert ( + config.custom_llm_provider == LlmProviders.LITELLM_PROXY + ), "custom_llm_provider should be LITELLM_PROXY" + + +def test_litellm_proxy_responses_api_config_get_complete_url(): + """Test that get_complete_url works correctly""" + import os + from litellm.llms.litellm_proxy.responses.transformation import ( + LiteLLMProxyResponsesAPIConfig, + ) + + config = LiteLLMProxyResponsesAPIConfig() + + # Test with explicit api_base + url = config.get_complete_url( + api_base="https://my-proxy.example.com", + litellm_params={}, + ) + assert url == "https://my-proxy.example.com/responses" + + # Test with trailing slash + url = config.get_complete_url( + api_base="https://my-proxy.example.com/", + litellm_params={}, + ) + assert url == "https://my-proxy.example.com/responses" + + # Test that it raises error when api_base is None and env var is not set + if "LITELLM_PROXY_API_BASE" in os.environ: + del os.environ["LITELLM_PROXY_API_BASE"] + + with pytest.raises(ValueError, match="api_base not set"): + config.get_complete_url(api_base=None, litellm_params={}) + + +def test_litellm_proxy_responses_api_config_inherits_from_openai(): + """Test that LiteLLMProxyResponsesAPIConfig extends OpenAI config properly""" + from litellm.llms.litellm_proxy.responses.transformation import ( + LiteLLMProxyResponsesAPIConfig, + ) + from litellm.llms.openai.responses.transformation import ( + OpenAIResponsesAPIConfig, + ) + + config = LiteLLMProxyResponsesAPIConfig() + + # Should inherit from OpenAI config + assert isinstance(config, OpenAIResponsesAPIConfig) + + # Should have the correct provider set + assert config.custom_llm_provider == LlmProviders.LITELLM_PROXY + + +if __name__ == "__main__": + test_litellm_proxy_responses_api_config() + test_litellm_proxy_responses_api_config_get_complete_url() + test_litellm_proxy_responses_api_config_inherits_from_openai() + print("All tests passed!") From c4022ade497a18cb41be464bc7bce938fb589ca3 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Wed, 8 Oct 2025 18:34:30 -0700 Subject: [PATCH 37/42] test mapped tests MCP --- ...odel_prices_and_context_window_backup.json | 33 ++++++ .../auth/test_user_api_key_auth_mcp.py | 112 ++++++++++-------- .../test_team_endpoints.py | 7 +- 3 files changed, 100 insertions(+), 52 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 29a6588774..06cb67e56c 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -12975,6 +12975,39 @@ "supports_vision": true, "supports_web_search": true }, + "gpt-5-pro-2025-10-06": { + "input_cost_per_token": 1.5e-05, + "input_cost_per_token_batches": 7.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 400000, + "max_output_tokens": 272000, + "max_tokens": 272000, + "mode": "responses", + "output_cost_per_token": 1.2e-04, + "output_cost_per_token_batches": 6e-05, + "supported_endpoints": [ + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": false, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "gpt-5-codex": { "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 7da941db39..7927aa7f48 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -1053,7 +1053,7 @@ async def test_get_team_object_permission_with_already_loaded_permission(): from the team object without making an additional DB call. """ from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable - + # Create mock object permission mock_object_permission = LiteLLM_ObjectPermissionTable( object_permission_id="perm-123", @@ -1077,28 +1077,34 @@ async def test_get_team_object_permission_with_already_loaded_permission(): ) # Mock get_team_object to return our team with loaded permission + # Also need to mock prisma_client from proxy_server + mock_prisma = MagicMock() with patch( - "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.get_team_object" - ) as mock_get_team: + "litellm.proxy.proxy_server.prisma_client", + mock_prisma, + ): with patch( - "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.get_object_permission" - ) as mock_get_perm: - mock_get_team.return_value = mock_team_obj - - # Call the method - result = await MCPRequestHandler._get_team_object_permission( - mock_user_auth - ) - - # Assert we got the object permission - assert result == mock_object_permission - assert result.mcp_servers == ["server1", "server2"] - - # Verify get_team_object was called - mock_get_team.assert_called_once() - - # Verify get_object_permission was NOT called (since it was already loaded) - mock_get_perm.assert_not_called() + "litellm.proxy.auth.auth_checks.get_team_object" + ) as mock_get_team: + with patch( + "litellm.proxy.auth.auth_checks.get_object_permission" + ) as mock_get_perm: + mock_get_team.return_value = mock_team_obj + + # Call the method + result = await MCPRequestHandler._get_team_object_permission( + mock_user_auth + ) + + # Assert we got the object permission + assert result == mock_object_permission + assert result.mcp_servers == ["server1", "server2"] + + # Verify get_team_object was called + mock_get_team.assert_called_once() + + # Verify get_object_permission was NOT called (since it was already loaded) + mock_get_perm.assert_not_called() @pytest.mark.asyncio @@ -1108,7 +1114,7 @@ async def test_get_team_object_permission_fetches_from_db_when_not_loaded(): is not loaded but object_permission_id exists. """ from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable - + # Create mock object permission (to be returned from DB) mock_object_permission = LiteLLM_ObjectPermissionTable( object_permission_id="perm-456", @@ -1132,35 +1138,41 @@ async def test_get_team_object_permission_fetches_from_db_when_not_loaded(): ) # Mock the methods + # Also need to mock prisma_client from proxy_server + mock_prisma = MagicMock() with patch( - "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.get_team_object" - ) as mock_get_team: + "litellm.proxy.proxy_server.prisma_client", + mock_prisma, + ): with patch( - "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.get_object_permission" - ) as mock_get_perm: - mock_get_team.return_value = mock_team_obj - mock_get_perm.return_value = mock_object_permission - - # Call the method - result = await MCPRequestHandler._get_team_object_permission( - mock_user_auth - ) - - # Assert we got the object permission - assert result == mock_object_permission - assert result.mcp_servers == ["server3", "server4"] - - # Verify get_team_object was called - mock_get_team.assert_called_once() - - # Verify get_object_permission WAS called (since it wasn't loaded) - mock_get_perm.assert_called_once_with( - object_permission_id="perm-456", - prisma_client=mock.ANY, - user_api_key_cache=mock.ANY, - parent_otel_span=mock_user_auth.parent_otel_span, - proxy_logging_obj=mock.ANY, - ) + "litellm.proxy.auth.auth_checks.get_team_object" + ) as mock_get_team: + with patch( + "litellm.proxy.auth.auth_checks.get_object_permission" + ) as mock_get_perm: + mock_get_team.return_value = mock_team_obj + mock_get_perm.return_value = mock_object_permission + + # Call the method + result = await MCPRequestHandler._get_team_object_permission( + mock_user_auth + ) + + # Assert we got the object permission + assert result == mock_object_permission + assert result.mcp_servers == ["server3", "server4"] + + # Verify get_team_object was called + mock_get_team.assert_called_once() + + # Verify get_object_permission WAS called (since it wasn't loaded) + mock_get_perm.assert_called_once_with( + object_permission_id="perm-456", + prisma_client=mock.ANY, + user_api_key_cache=mock.ANY, + parent_otel_span=mock_user_auth.parent_otel_span, + proxy_logging_obj=mock.ANY, + ) @pytest.mark.asyncio @@ -1170,7 +1182,7 @@ async def test_get_allowed_mcp_servers_for_team_uses_helper(): helper which handles both loaded and unloaded object_permission cases. """ from litellm.proxy._types import LiteLLM_ObjectPermissionTable - + # Create mock object permission with servers and access groups mock_object_permission = LiteLLM_ObjectPermissionTable( object_permission_id="perm-789", diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 929ec41e09..99325693b5 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -2,7 +2,6 @@ import asyncio import json import os import sys -from litellm._uuid import uuid from typing import Optional, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -10,6 +9,8 @@ import pytest from fastapi import HTTPException from fastapi.testclient import TestClient +from litellm._uuid import uuid + sys.path.insert( 0, os.path.abspath("../../../") ) # Adds the parent directory to the system path @@ -412,8 +413,10 @@ async def test_new_team_with_mcp_tool_permissions(mock_db_client, mock_admin_aut ) # Verify mcp_tool_permissions was stored + import json assert "mcp_tool_permissions" in created_permission_data - assert created_permission_data["mcp_tool_permissions"] == { + # mcp_tool_permissions is stored as a JSON string + assert json.loads(created_permission_data["mcp_tool_permissions"]) == { "server_a": ["read_wiki_structure", "read_wiki_contents"], "server_b": ["ask_question"], } From d28ecbc900584426dd36395e2b2403e196e73d9b Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 8 Oct 2025 18:45:13 -0700 Subject: [PATCH 38/42] feat(ui_sso.py): support mapping app roles from azure entra id to litellm user roles Closes LIT-1228 --- litellm/proxy/management_endpoints/types.py | 40 +++++++++- litellm/proxy/management_endpoints/ui_sso.py | 80 +++++++++++++++++++- 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/management_endpoints/types.py b/litellm/proxy/management_endpoints/types.py index 0e811669d1..ad2ad0a5fe 100644 --- a/litellm/proxy/management_endpoints/types.py +++ b/litellm/proxy/management_endpoints/types.py @@ -4,10 +4,48 @@ Types for the management endpoints Might include fastapi/proxy requirements.txt related imports """ -from typing import List +from typing import List, Optional, cast from fastapi_sso.sso.base import OpenID +from litellm.proxy._types import LitellmUserRoles + + +def is_valid_litellm_user_role(role_str: str) -> bool: + """ + Check if a string is a valid LitellmUserRoles enum value (case-insensitive). + + Args: + role_str: String to validate (e.g., "proxy_admin", "PROXY_ADMIN", "internal_user") + + Returns: + True if the string matches a valid LitellmUserRoles value, False otherwise + """ + try: + # Use _value2member_map_ for O(1) lookup, case-insensitive + return role_str.lower() in LitellmUserRoles._value2member_map_ + except Exception: + return False + + +def get_litellm_user_role(role_str: str) -> Optional[LitellmUserRoles]: + """ + Convert a string to a LitellmUserRoles enum if valid (case-insensitive). + + Args: + role_str: String to convert (e.g., "proxy_admin", "PROXY_ADMIN", "internal_user") + + Returns: + LitellmUserRoles enum if valid, None otherwise + """ + try: + # Use _value2member_map_ for O(1) lookup, case-insensitive + result = LitellmUserRoles._value2member_map_.get(role_str.lower()) + return cast(Optional[LitellmUserRoles], result) + except Exception: + return None + class CustomOpenID(OpenID): team_ids: List[str] + user_role: Optional[LitellmUserRoles] = None diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 9eec551fb7..d8f426e1ae 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -58,7 +58,7 @@ from litellm.proxy.management_endpoints.sso_helper_utils import ( has_admin_ui_access, ) from litellm.proxy.management_endpoints.team_endpoints import new_team, team_member_add -from litellm.proxy.management_endpoints.types import CustomOpenID +from litellm.proxy.management_endpoints.types import CustomOpenID, get_litellm_user_role from litellm.proxy.utils import ( PrismaClient, ProxyLogging, @@ -277,6 +277,7 @@ def generic_response_convertor( last_name=response.get(generic_user_last_name_attribute_name), provider=response.get(generic_provider_attribute_name), team_ids=all_teams, + user_role=None, ) @@ -1145,7 +1146,7 @@ class SSOAuthenticationHandler: ) -> str: """ Get the redirect URL for SSO - + Note: existing_key is not added to the URL to avoid changing the callback URL. It should be passed via the state parameter instead. """ @@ -1348,7 +1349,7 @@ class SSOAuthenticationHandler: Checks the request 'source' if a cli state token was passed in This is used to authenticate through the CLI login flow. - + The state parameter format is: {PREFIX}:{key}:{existing_key} - If existing_key is provided, it's included in the state - The state parameter is used to pass data through the OAuth flow without changing the callback URL @@ -1673,22 +1674,49 @@ class MicrosoftSSOHandler: access_token=microsoft_sso.access_token ) + # Extract app roles from the id_token JWT + app_roles = MicrosoftSSOHandler.get_app_roles_from_id_token( + id_token=microsoft_sso.id_token + ) + verbose_proxy_logger.debug(f"Extracted app roles from id_token: {app_roles}") + + # Combine groups and app roles + user_role: Optional[LitellmUserRoles] = None + if app_roles: + # Check if any app role is a valid LitellmUserRoles + for role_str in app_roles: + role = get_litellm_user_role(role_str) + if role is not None: + user_role = role + verbose_proxy_logger.debug( + f"Found valid LitellmUserRoles '{role.value}' in app_roles" + ) + break + + verbose_proxy_logger.debug( + f"Combined team_ids (groups + app roles): {user_team_ids}" + ) + # if user is trying to get the raw sso response for debugging, return the raw sso response if return_raw_sso_response: original_msft_result[MicrosoftSSOHandler.GRAPH_API_RESPONSE_KEY] = ( user_team_ids ) + original_msft_result["app_roles"] = app_roles return original_msft_result or {} result = MicrosoftSSOHandler.openid_from_response( response=original_msft_result, team_ids=user_team_ids, + user_role=user_role, ) return result @staticmethod def openid_from_response( - response: Optional[dict], team_ids: List[str] + response: Optional[dict], + team_ids: List[str], + user_role: Optional[LitellmUserRoles], ) -> CustomOpenID: response = response or {} verbose_proxy_logger.debug(f"Microsoft SSO Callback Response: {response}") @@ -1700,10 +1728,54 @@ class MicrosoftSSOHandler: first_name=response.get("givenName"), last_name=response.get("surname"), team_ids=team_ids, + user_role=user_role, ) verbose_proxy_logger.debug(f"Microsoft SSO OpenID Response: {openid_response}") return openid_response + @staticmethod + def get_app_roles_from_id_token(id_token: Optional[str]) -> List[str]: + """ + Extract app roles from the Microsoft Entra ID (Azure AD) id_token JWT. + + App roles are assigned in the Azure AD Enterprise Application and appear + in the 'roles' claim of the id_token. + + Args: + id_token (Optional[str]): The JWT id_token from Microsoft SSO + + Returns: + List[str]: List of app role names assigned to the user + """ + if not id_token: + verbose_proxy_logger.debug("No id_token provided for app role extraction") + return [] + + try: + import jwt + + # Decode the JWT without signature verification + # (signature is already verified by fastapi_sso) + decoded_token = jwt.decode(id_token, options={"verify_signature": False}) + + # Extract roles claim from the token + roles = decoded_token.get("roles", []) + + if roles and isinstance(roles, list): + verbose_proxy_logger.debug( + f"Found {len(roles)} app role(s) in id_token: {roles}" + ) + return roles + else: + verbose_proxy_logger.debug( + "No app roles found in id_token or roles claim is not a list" + ) + return [] + + except Exception as e: + verbose_proxy_logger.error(f"Error extracting app roles from id_token: {e}") + return [] + @staticmethod async def get_user_groups_from_graph_api( access_token: Optional[str] = None, From ff3f356416562dc27b5648876e69e22e02f7ced4 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 8 Oct 2025 18:46:28 -0700 Subject: [PATCH 39/42] docs(docs/): add app role support to docs --- docs/my-website/docs/proxy/admin_ui_sso.md | 17 +++++++ docs/my-website/docs/tutorials/msft_sso.md | 52 ++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/docs/my-website/docs/proxy/admin_ui_sso.md b/docs/my-website/docs/proxy/admin_ui_sso.md index 32bf97410c..439aae6f96 100644 --- a/docs/my-website/docs/proxy/admin_ui_sso.md +++ b/docs/my-website/docs/proxy/admin_ui_sso.md @@ -81,6 +81,23 @@ MICROSOFT_TENANT="5a39737 http://localhost:4000/sso/callback ``` +**Using App Roles for User Permissions** + +You can assign user roles directly from Entra ID using App Roles. LiteLLM will automatically read the app roles from the JWT token and assign the corresponding role to the user. + +Supported roles: +- `proxy_admin` - Admin over the platform +- `proxy_admin_viewer` - Can login, view all keys, view all spend (read-only) +- `org_admin` - Admin over a specific organization +- `internal_user` - Can login, view/create/delete their own keys, view their spend + +To set up app roles: +1. Navigate to your App Registration on https://portal.azure.com/ +2. Go to "App roles" and create a new app role +3. Use one of the supported role names above (e.g., `proxy_admin`) +4. Assign users to these roles in your Enterprise Application +5. When users sign in via SSO, LiteLLM will automatically assign them the corresponding role + diff --git a/docs/my-website/docs/tutorials/msft_sso.md b/docs/my-website/docs/tutorials/msft_sso.md index f7ad6440f2..d24a289669 100644 --- a/docs/my-website/docs/tutorials/msft_sso.md +++ b/docs/my-website/docs/tutorials/msft_sso.md @@ -140,6 +140,58 @@ litellm_settings: +## 4. Using Entra ID App Roles for User Permissions + +You can assign user roles directly from Entra ID using App Roles. LiteLLM will automatically read the app roles from the JWT token during SSO sign-in and assign the corresponding role to the user. + +### 4.1 Supported Roles + +LiteLLM supports the following app roles (case-insensitive): + +- `proxy_admin` - Admin over the entire LiteLLM platform +- `proxy_admin_viewer` - Read-only admin access (can view all keys and spend) +- `org_admin` - Admin over a specific organization (can create teams and users within their org) +- `internal_user` - Standard user (can create/view/delete their own keys and view their own spend) + +### 4.2 Create App Roles in Entra ID + +1. Navigate to your App Registration on https://portal.azure.com/ +2. Go to **App roles** > **Create app role** + + + +3. Configure the app role: + - **Display name**: Proxy Admin (or your preferred display name) + - **Value**: `proxy_admin` (use one of the supported role values above) + - **Description**: Administrator access to LiteLLM proxy + - **Allowed member types**: Users/Groups + + + +4. Click **Apply** to save the role + +### 4.3 Assign Users to App Roles + +1. Navigate to **Enterprise Applications** on https://portal.azure.com/ +2. Select your LiteLLM application +3. Go to **Users and groups** > **Add user/group** +4. Select the user and assign them to one of the app roles you created + + + +### 4.4 Test the Role Assignment + +1. Sign in to LiteLLM UI via SSO as a user with an assigned app role +2. LiteLLM will automatically extract the app role from the JWT token +3. The user will be assigned the corresponding LiteLLM role in the database +4. The user's permissions will reflect their assigned role + +**How it works:** +- When a user signs in via Microsoft SSO, LiteLLM extracts the `roles` claim from the JWT `id_token` +- If any of the roles match a valid LiteLLM role (case-insensitive), that role is assigned to the user +- If multiple roles are present, LiteLLM uses the first valid role it finds +- This role assignment persists in the LiteLLM database and determines the user's access level + ## Video Walkthrough This walks through setting up sso auto-add for **Microsoft Entra ID** From 8eafb7a8fa93023d7b520bda3d22f4a1ac761758 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 8 Oct 2025 18:50:42 -0700 Subject: [PATCH 40/42] docs: doc improvements --- docs/my-website/docs/proxy/admin_ui_sso.md | 4 ++-- .../out/{model_hub_table.html => model_hub_table/index.html} | 0 litellm/proxy/_experimental/out/onboarding.html | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) rename litellm/proxy/_experimental/out/{model_hub_table.html => model_hub_table/index.html} (100%) delete mode 100644 litellm/proxy/_experimental/out/onboarding.html diff --git a/docs/my-website/docs/proxy/admin_ui_sso.md b/docs/my-website/docs/proxy/admin_ui_sso.md index 439aae6f96..bd18dd9c69 100644 --- a/docs/my-website/docs/proxy/admin_ui_sso.md +++ b/docs/my-website/docs/proxy/admin_ui_sso.md @@ -88,8 +88,8 @@ You can assign user roles directly from Entra ID using App Roles. LiteLLM will a Supported roles: - `proxy_admin` - Admin over the platform - `proxy_admin_viewer` - Can login, view all keys, view all spend (read-only) -- `org_admin` - Admin over a specific organization -- `internal_user` - Can login, view/create/delete their own keys, view their spend +- `internal_user` - Normal user. Can login, view spend and depending on team-member permissions - view/create/delete their own keys. + To set up app roles: 1. Navigate to your App Registration on https://portal.azure.com/ diff --git a/litellm/proxy/_experimental/out/model_hub_table.html b/litellm/proxy/_experimental/out/model_hub_table/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model_hub_table.html rename to litellm/proxy/_experimental/out/model_hub_table/index.html diff --git a/litellm/proxy/_experimental/out/onboarding.html b/litellm/proxy/_experimental/out/onboarding.html deleted file mode 100644 index 04c6c88676..0000000000 --- a/litellm/proxy/_experimental/out/onboarding.html +++ /dev/null @@ -1 +0,0 @@ -LiteLLM Dashboard \ No newline at end of file From 697f99c1d5be1ffc6fc9d3dea1fb150f3860f0eb Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 8 Oct 2025 18:59:00 -0700 Subject: [PATCH 41/42] docs: doc improvements --- docs/my-website/docs/tutorials/msft_sso.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/my-website/docs/tutorials/msft_sso.md b/docs/my-website/docs/tutorials/msft_sso.md index d24a289669..2936f27297 100644 --- a/docs/my-website/docs/tutorials/msft_sso.md +++ b/docs/my-website/docs/tutorials/msft_sso.md @@ -158,15 +158,12 @@ LiteLLM supports the following app roles (case-insensitive): 1. Navigate to your App Registration on https://portal.azure.com/ 2. Go to **App roles** > **Create app role** - - 3. Configure the app role: - **Display name**: Proxy Admin (or your preferred display name) - **Value**: `proxy_admin` (use one of the supported role values above) - **Description**: Administrator access to LiteLLM proxy - **Allowed member types**: Users/Groups - 4. Click **Apply** to save the role @@ -177,7 +174,6 @@ LiteLLM supports the following app roles (case-insensitive): 3. Go to **Users and groups** > **Add user/group** 4. Select the user and assign them to one of the app roles you created - ### 4.4 Test the Role Assignment From e2b7b05774ba4403f9a8c2efa12d65b348b07919 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 8 Oct 2025 19:16:22 -0700 Subject: [PATCH 42/42] docs(mcp.md): document new openapi to mcp tool support --- docs/my-website/docs/mcp.md | 195 ++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/docs/my-website/docs/mcp.md b/docs/my-website/docs/mcp.md index 9365b0a554..462306e961 100644 --- a/docs/my-website/docs/mcp.md +++ b/docs/my-website/docs/mcp.md @@ -246,6 +246,201 @@ litellm_settings: +## Converting OpenAPI Specs to MCP Servers + +LiteLLM can automatically convert OpenAPI specifications into MCP servers, allowing you to expose any REST API as MCP tools. This is useful when you have existing APIs with OpenAPI/Swagger documentation and want to make them available as MCP tools. + +### Benefits + +- **Rapid Integration**: Convert existing APIs to MCP tools without writing custom MCP server code +- **Automatic Tool Generation**: LiteLLM automatically generates MCP tools from your OpenAPI spec +- **Unified Interface**: Use the same MCP interface for both native MCP servers and OpenAPI-based APIs +- **Easy Testing**: Test and iterate on API integrations quickly + +### Configuration + +Add your OpenAPI-based MCP server to your `config.yaml`: + +```yaml title="config.yaml - OpenAPI to MCP" showLineNumbers +model_list: + - model_name: gpt-4o + litellm_params: + model: openai/gpt-4o + api_key: sk-xxxxxxx + +mcp_servers: + # OpenAPI Spec Example - Petstore API + petstore_mcp: + url: "https://petstore.swagger.io/v2" + spec_path: "/path/to/openapi.json" + auth_type: "none" + + # OpenAPI Spec with API Key Authentication + my_api_mcp: + url: "http://0.0.0.0:8090" + spec_path: "/path/to/openapi.json" + auth_type: "api_key" + auth_value: "your-api-key-here" + + # OpenAPI Spec with Bearer Token + secured_api_mcp: + url: "https://api.example.com" + spec_path: "/path/to/openapi.json" + auth_type: "bearer_token" + auth_value: "your-bearer-token" +``` + +### Configuration Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `url` | Yes | The base URL of your API endpoint | +| `spec_path` | Yes | Path or URL to your OpenAPI specification file (JSON or YAML) | +| `auth_type` | No | Authentication type: `none`, `api_key`, `bearer_token`, `basic`, `authorization` | +| `auth_value` | No | Authentication value (required if `auth_type` is set) | +| `description` | No | Optional description for the MCP server | +| `allowed_tools` | No | List of specific tools to allow (see [MCP Tool Filtering](#mcp-tool-filtering)) | +| `disallowed_tools` | No | List of specific tools to block (see [MCP Tool Filtering](#mcp-tool-filtering)) | + +### Usage Example + +Once configured, you can use the OpenAPI-based MCP server just like any other MCP server: + + + + +```python title="Using OpenAPI-based MCP Server" showLineNumbers +from fastmcp import Client +import asyncio + +# Standard MCP configuration +config = { + "mcpServers": { + "petstore": { + "url": "http://localhost:4000/petstore_mcp/mcp", + "headers": { + "x-litellm-api-key": "Bearer sk-1234" + } + } + } +} + +# Create a client that connects to the server +client = Client(config) + +async def main(): + async with client: + # List available tools generated from OpenAPI spec + tools = await client.list_tools() + print(f"Available tools: {[tool.name for tool in tools]}") + + # Example: Get a pet by ID (from Petstore API) + response = await client.call_tool( + name="getpetbyid", + arguments={"petId": "1"} + ) + print(f"Response:\n{response}\n") + + # Example: Find pets by status + response = await client.call_tool( + name="findpetsbystatus", + arguments={"status": "available"} + ) + print(f"Response:\n{response}\n") + +if __name__ == "__main__": + asyncio.run(main()) +``` + + + + + +```json title="Cursor MCP Configuration for OpenAPI Server" showLineNumbers +{ + "mcpServers": { + "Petstore": { + "url": "http://localhost:4000/petstore_mcp/mcp", + "headers": { + "x-litellm-api-key": "Bearer $LITELLM_API_KEY" + } + } + } +} +``` + + + + + +```bash title="Using OpenAPI MCP Server with OpenAI" showLineNumbers +curl --location 'https://api.openai.com/v1/responses' \ +--header 'Content-Type: application/json' \ +--header "Authorization: Bearer $OPENAI_API_KEY" \ +--data '{ + "model": "gpt-4o", + "tools": [ + { + "type": "mcp", + "server_label": "petstore", + "server_url": "http://localhost:4000/petstore_mcp/mcp", + "require_approval": "never", + "headers": { + "x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY" + } + } + ], + "input": "Find all available pets in the petstore", + "tool_choice": "required" +}' +``` + + + + +### How It Works + +1. **Spec Loading**: LiteLLM loads your OpenAPI specification from the provided `spec_path` +2. **Tool Generation**: Each API endpoint in the spec becomes an MCP tool +3. **Parameter Mapping**: OpenAPI parameters are automatically mapped to MCP tool parameters +4. **Request Handling**: When a tool is called, LiteLLM converts the MCP request to the appropriate HTTP request +5. **Response Translation**: API responses are converted back to MCP format + +### OpenAPI Spec Requirements + +Your OpenAPI specification should follow standard OpenAPI/Swagger conventions: +- **Supported versions**: OpenAPI 3.0.x, OpenAPI 3.1.x, Swagger 2.0 +- **Required fields**: `paths`, `info` sections should be properly defined +- **Operation IDs**: Each operation should have a unique `operationId` (this becomes the tool name) +- **Parameters**: Request parameters should be properly documented with types and descriptions + +### Example OpenAPI Spec Structure + +```yaml title="sample-openapi.yaml" showLineNumbers +openapi: 3.0.0 +info: + title: My API + version: 1.0.0 +paths: + /pets/{petId}: + get: + operationId: getPetById + summary: Get a pet by ID + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object +``` + ## MCP Tool Filtering Control which tools are available from your MCP servers. You can either allow only specific tools or block dangerous ones.