diff --git a/litellm/constants.py b/litellm/constants.py index 9f60c635249..b9b9c0ba604 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1145,6 +1145,7 @@ BEDROCK_CONVERSE_MODELS = [ "anthropic.claude-sonnet-4-5-20250929-v1:0", "anthropic.claude-fable-5", "anthropic.claude-sonnet-5", + "anthropic.claude-opus-5", "anthropic.claude-opus-4-8", "anthropic.claude-opus-4-7", "anthropic.claude-opus-4-6-v1:0", diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d3917886060..d43eda39b1f 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1502,6 +1502,222 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 1024 }, + "anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, + "global.anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, + "us.anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_1hr": 1.1e-05, + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 5.5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.75e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, + "eu.anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_1hr": 1.1e-05, + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 5.5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.75e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, + "au.anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_1hr": 1.1e-05, + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 5.5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.75e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, + "jp.anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_1hr": 1.1e-05, + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 5.5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.75e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "anthropic.claude-opus-4-8": { "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, @@ -2756,6 +2972,38 @@ "supports_xhigh_reasoning_effort": true, "supports_max_reasoning_effort": true }, + "azure_ai/claude-opus-5": { + "supports_mid_conversation_system": true, + "supports_adaptive_thinking": true, + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "litellm_provider": "azure_ai", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 + }, "azure_ai/claude-opus-4-8": { "supports_mid_conversation_system": true, "supports_adaptive_thinking": true, @@ -11846,6 +12094,44 @@ "supports_output_config": true, "prompt_cache_min_tokens": 512 }, + "claude-opus-5": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_native_structured_output": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "provider_specific_entry": { + "us": 1.1, + "fast": 2.0 + }, + "supports_output_config": true, + "supports_speed": true, + "prompt_cache_min_tokens": 512 + }, "claude-opus-4-8": { "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, @@ -36896,6 +37182,70 @@ "supports_xhigh_reasoning_effort": true, "supports_max_reasoning_effort": true }, + "vertex_ai/claude-opus-5": { + "supports_mid_conversation_system": true, + "supports_adaptive_thinking": true, + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 + }, + "vertex_ai/claude-opus-5@default": { + "supports_mid_conversation_system": true, + "supports_adaptive_thinking": true, + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 + }, "vertex_ai/claude-opus-4-8": { "supports_mid_conversation_system": true, "supports_adaptive_thinking": true, diff --git a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py index 31535a5b569..28b9dec100f 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py +++ b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py @@ -432,7 +432,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): Override to store only the Model Armor API response, not the entire data dict. This prevents circular references in logging. """ - metadata = request_data.get("metadata", {}) if isinstance(request_data, dict) else {} + metadata = (request_data.get("metadata") or {}) if isinstance(request_data, dict) else {} guardrail_response = metadata.get("_model_armor_response", {}) # Determine status – default to "success" but prefer the explicit value if present. diff --git a/litellm/setup_wizard.py b/litellm/setup_wizard.py index 10b4fb30f22..c6d0c1717a9 100644 --- a/litellm/setup_wizard.py +++ b/litellm/setup_wizard.py @@ -52,12 +52,13 @@ PROVIDERS: List[Dict] = [ { "id": "anthropic", "name": "Anthropic", - "description": "Claude Fable 5, Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 4.6, Haiku 4.5", + "description": "Claude Fable 5, Opus 5, Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 5, Sonnet 4.6, Haiku 4.5", "env_key": "ANTHROPIC_API_KEY", "key_hint": "sk-ant-...", "test_model": "claude-haiku-4-5-20251001", "models": [ "claude-fable-5", + "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8", "claude-opus-4-7", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index c9d871fc41d..749b2566c2a 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1502,6 +1502,222 @@ "supports_parallel_tool_use_config": true, "prompt_cache_min_tokens": 1024 }, + "anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, + "global.anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, + "us.anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_1hr": 1.1e-05, + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 5.5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.75e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, + "eu.anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_1hr": 1.1e-05, + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 5.5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.75e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, + "au.anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_1hr": 1.1e-05, + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 5.5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.75e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, + "jp.anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "supports_adaptive_thinking": true, + "supports_mid_conversation_system": true, + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_1hr": 1.1e-05, + "cache_read_input_token_cost": 5.5e-07, + "input_cost_per_token": 5.5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.75e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "anthropic.claude-opus-4-8": { "bedrock_converse_supports_strict_tools": false, "supports_adaptive_thinking": true, @@ -2756,6 +2972,38 @@ "supports_xhigh_reasoning_effort": true, "supports_max_reasoning_effort": true }, + "azure_ai/claude-opus-5": { + "supports_mid_conversation_system": true, + "supports_adaptive_thinking": true, + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "litellm_provider": "azure_ai", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 + }, "azure_ai/claude-opus-4-8": { "supports_mid_conversation_system": true, "supports_adaptive_thinking": true, @@ -11846,6 +12094,44 @@ "supports_output_config": true, "prompt_cache_min_tokens": 512 }, + "claude-opus-5": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_native_structured_output": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "provider_specific_entry": { + "us": 1.1, + "fast": 2.0 + }, + "supports_output_config": true, + "supports_speed": true, + "prompt_cache_min_tokens": 512 + }, "claude-opus-4-8": { "cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost_above_1hr": 1e-05, @@ -36987,6 +37273,70 @@ "supports_xhigh_reasoning_effort": true, "supports_max_reasoning_effort": true }, + "vertex_ai/claude-opus-5": { + "supports_mid_conversation_system": true, + "supports_adaptive_thinking": true, + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 + }, + "vertex_ai/claude-opus-5@default": { + "supports_mid_conversation_system": true, + "supports_adaptive_thinking": true, + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 + }, "vertex_ai/claude-opus-4-8": { "supports_mid_conversation_system": true, "supports_adaptive_thinking": true, diff --git a/tests/e2e/a2a/a2a_client.py b/tests/e2e/a2a/a2a_client.py index 97ffa8c34a3..3835ef64220 100644 --- a/tests/e2e/a2a/a2a_client.py +++ b/tests/e2e/a2a/a2a_client.py @@ -85,6 +85,7 @@ class A2ABridgeParams(BaseModel): custom_llm_provider: str model: str + api_key: str | None = None class AgentRegisterBody(BaseModel): diff --git a/tests/e2e/a2a/test_a2a_agent_e2e.py b/tests/e2e/a2a/test_a2a_agent_e2e.py index aa60b57f99b..4b05dbeb3ed 100644 --- a/tests/e2e/a2a/test_a2a_agent_e2e.py +++ b/tests/e2e/a2a/test_a2a_agent_e2e.py @@ -32,7 +32,11 @@ from e2e_config import unique_marker from e2e_http import Result, UnknownApiError, unwrap from lifecycle import ResourceManager -BRIDGE = A2ABridgeParams(custom_llm_provider="anthropic", model="claude-haiku-4-5") +BRIDGE = A2ABridgeParams( + custom_llm_provider="anthropic", + model="claude-haiku-4-5", + api_key="os.environ/ANTHROPIC_API_KEY", +) MOVEHOME_AGENT_CARD_URL = "https://movehome.org/.well-known/agent.json" MOVEHOME_ORIGIN = "https://movehome.org" diff --git a/tests/llm_translation/reasoning_effort_grid/grid_spec.py b/tests/llm_translation/reasoning_effort_grid/grid_spec.py index c47fddb1d8d..4fa77f38940 100644 --- a/tests/llm_translation/reasoning_effort_grid/grid_spec.py +++ b/tests/llm_translation/reasoning_effort_grid/grid_spec.py @@ -167,6 +167,13 @@ ANTHROPIC_DIRECT_MODELS: Tuple[ModelEntry, ...] = ( "once the model is available." ), ), + ModelEntry( + alias="claude-opus-5", + model="anthropic/claude-opus-5", + mode="adaptive", + required_env=_ANTHROPIC_REQ, + caps=_CAPS_XHIGH_MAX, + ), ModelEntry( alias="claude-opus-4-8", model="anthropic/claude-opus-4-8", diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py index 4021f922877..18b5bd92411 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_model_armor.py @@ -3680,6 +3680,22 @@ async def test_pre_call_hook_skips_chat_traffic_when_configured_for_pre_mcp_call mock_post.assert_not_called() +def test_process_response_with_none_metadata_does_not_crash(): + guardrail = _make_guardrail() + response = {"id": "batch_123", "status": "validating"} + request_data = {"model": "gemini-2.5-flash", "metadata": None} + + result = guardrail._process_response( + response=response, + request_data=request_data, + event_type=GuardrailEventHooks.post_call, + ) + + assert result is response + assert isinstance(request_data["metadata"], dict) + assert "standard_logging_guardrail_information" in request_data["metadata"] + + @pytest.mark.asyncio async def test_moderation_hook_scans_mcp_tool_call_when_configured_for_during_mcp_call(): """A guardrail configured with mode `during_mcp_call` must scan MCP tool calls. diff --git a/tests/test_litellm/test_claude_opus_5_config.py b/tests/test_litellm/test_claude_opus_5_config.py new file mode 100644 index 00000000000..84021a83a5a --- /dev/null +++ b/tests/test_litellm/test_claude_opus_5_config.py @@ -0,0 +1,277 @@ +""" +Validate Claude Opus 5 model configuration entries. + +Opus 5 carries Opus 4.8's pricing ($5 / $25 per MTok) and the gen-5 adaptive +thinking profile, but differs from 4.8 in two ways that are behavior-bearing in +LiteLLM: the cacheable-prefix minimum drops to 512 tokens, and Bedrock's Opus 5 +validator accepts the full effort ladder, so the entries must not carry the +``bedrock_output_config_effort_ceiling`` that silently clamps ``max`` to +``xhigh`` on 4.8. The cost-map entries are also what populate +``litellm.anthropic_models`` at import, which is what lets a bare +``claude-opus-5`` name resolve to the ``anthropic`` provider (and match an +``anthropic/*`` wildcard deployment). +""" + +import json +import os + +import pytest + +import litellm +from litellm.constants import BEDROCK_CONVERSE_MODELS +from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap + +REPO_ROOT = os.path.join(os.path.dirname(__file__), "../..") + +ALL_OPUS_5_VARIANTS = ( + "claude-opus-5", + "anthropic.claude-opus-5", + "global.anthropic.claude-opus-5", + "us.anthropic.claude-opus-5", + "eu.anthropic.claude-opus-5", + "au.anthropic.claude-opus-5", + "jp.anthropic.claude-opus-5", + "vertex_ai/claude-opus-5", + "vertex_ai/claude-opus-5@default", + "azure_ai/claude-opus-5", +) + +BEDROCK_OPUS_5_VARIANTS = ( + "anthropic.claude-opus-5", + "global.anthropic.claude-opus-5", + "us.anthropic.claude-opus-5", + "eu.anthropic.claude-opus-5", + "au.anthropic.claude-opus-5", + "jp.anthropic.claude-opus-5", +) + + +def _load_root_cost_map() -> dict: + json_path = os.path.join(REPO_ROOT, "model_prices_and_context_window.json") + with open(json_path) as f: + return json.load(f) + + +@pytest.fixture +def local_model_cost_map(monkeypatch): + """Force the bundled backup cost map so assertions don't depend on the + network-fetched ``main`` copy (which lags this branch until merge).""" + original_model_cost = litellm.model_cost + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + litellm.model_cost = litellm.get_model_cost_map(url="") + litellm.get_model_info.cache_clear() + try: + yield + finally: + litellm.model_cost = original_model_cost + litellm.get_model_info.cache_clear() + + +def test_opus_5_pricing_and_capabilities(): + model_data = _load_root_cost_map() + + expected_providers = { + "claude-opus-5": "anthropic", + "anthropic.claude-opus-5": "bedrock_converse", + "vertex_ai/claude-opus-5": "vertex_ai-anthropic_models", + "azure_ai/claude-opus-5": "azure_ai", + } + + for model_name, provider in expected_providers.items(): + assert model_name in model_data, f"Missing model entry: {model_name}" + info = model_data[model_name] + + assert info["litellm_provider"] == provider + assert info["mode"] == "chat" + assert info["max_input_tokens"] == 1000000 + assert info["max_output_tokens"] == 128000 + assert info["max_tokens"] == 128000 + + # Opus 5 ships at Opus 4.8's rates: $5 / $25 per MTok, with the standard + # 1.25x cache-write, 2x 1-hour cache-write, and 0.1x cache-read multipliers. + assert info["input_cost_per_token"] == 5e-06 + assert info["output_cost_per_token"] == 2.5e-05 + assert info["cache_creation_input_token_cost"] == 6.25e-06 + assert info["cache_creation_input_token_cost_above_1hr"] == 1e-05 + assert info["cache_read_input_token_cost"] == 5e-07 + + # Flat rate across the full 1M window, no long-context premium. + assert "input_cost_per_token_above_200k_tokens" not in info + assert "output_cost_per_token_above_200k_tokens" not in info + + # gen-5 adaptive-thinking profile: effort-driven, no sampling params, no + # assistant prefill. + assert info["supports_adaptive_thinking"] is True + assert info["supports_reasoning"] is True + assert info["supports_sampling_params"] is False + assert info["supports_assistant_prefill"] is False + assert info["supports_xhigh_reasoning_effort"] is True + assert info["supports_max_reasoning_effort"] is True + + assert info["supports_function_calling"] is True + assert info["supports_prompt_caching"] is True + assert info["supports_tool_choice"] is True + assert info["supports_vision"] is True + + +def test_opus_5_bedrock_regional_pricing(): + """Global/base endpoints use base pricing; the us./eu./au./jp. regional + cross-region inference profiles carry a 10% premium.""" + model_data = _load_root_cost_map() + + base_pricing = { + "input_cost_per_token": 5e-06, + "output_cost_per_token": 2.5e-05, + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + } + regional_pricing = { + "input_cost_per_token": 5.5e-06, + "output_cost_per_token": 2.75e-05, + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_1hr": 1.1e-05, + "cache_read_input_token_cost": 5.5e-07, + } + + expected = { + "anthropic.claude-opus-5": base_pricing, + "global.anthropic.claude-opus-5": base_pricing, + "us.anthropic.claude-opus-5": regional_pricing, + "eu.anthropic.claude-opus-5": regional_pricing, + "au.anthropic.claude-opus-5": regional_pricing, + "jp.anthropic.claude-opus-5": regional_pricing, + } + + for model_name, pricing in expected.items(): + assert model_name in model_data, f"Missing model entry: {model_name}" + info = model_data[model_name] + assert info["litellm_provider"] == "bedrock_converse" + for key, value in pricing.items(): + assert info[key] == value, f"{model_name}.{key} = {info[key]}, want {value}" + + +@pytest.mark.parametrize("model_name", BEDROCK_OPUS_5_VARIANTS) +def test_opus_5_bedrock_entries_declare_no_effort_ceiling(model_name): + """Bedrock accepts every effort level for Opus 5, so no clamp belongs here. + + Opus 4.7/4.8 carry ``bedrock_output_config_effort_ceiling: "xhigh"``, which + is what ``normalize_bedrock_opus_output_config_effort`` reads to rewrite a + caller's effort down. Verified against Bedrock on 2026-07-24 that + ``output_config.effort="max"`` returns 200 for the Opus 5 profiles, so the + ceiling is deliberately absent; adding one back would silently downgrade + requests. + + This asserts the cost-map entry rather than calling the normalizer because + ``_BEDROCK_OUTPUT_CONFIG_EFFORT_ORDER`` currently ranks ``max`` (3) below + ``xhigh`` (4), so an ``xhigh`` ceiling never clamps ``max`` and a behavioral + assertion would pass either way. Keeping the entry clean means Opus 5 stays + correct once that ordering is fixed.""" + info = _load_root_cost_map()[model_name] + assert "bedrock_output_config_effort_ceiling" not in info + + +@pytest.mark.parametrize("model_name", BEDROCK_OPUS_5_VARIANTS) +def test_opus_5_bedrock_rejects_strict_tools(model_name, local_model_cost_map): + """Bedrock Converse routes Opus through a validator that rejects + ``toolSpec.strict`` (``tools.0.custom.strict: Extra inputs are not + permitted``), same as Opus 4.7/4.8; verified against Bedrock on 2026-07-24. + Without the flag LiteLLM forwards ``strict`` and every tool call 400s.""" + from litellm.llms.bedrock.common_utils import bedrock_converse_supports_strict_tools + + assert bedrock_converse_supports_strict_tools(model_name) is False + + +def test_opus_5_prompt_cache_minimum_is_512(local_model_cost_map): + """Opus 5 halves the cacheable-prefix minimum (Opus 4.8 is 1024). + + The router's prompt-caching deployment check reads this value, so a stale + 1024 would route prompts of 512-1023 tokens away from a warm Opus 5 + deployment even though they cache fine.""" + from litellm.utils import get_prompt_cache_min_tokens + + assert get_prompt_cache_min_tokens(model="claude-opus-5") == 512 + assert get_prompt_cache_min_tokens(model="us.anthropic.claude-opus-5") == 512 + + +def test_opus_5_supports_fast_mode(local_model_cost_map): + """Fast mode is Opus 5 on the first-party API at $10 / $50 per MTok, i.e. 2x + base. ``supports_speed`` gates whether ``speed="fast"`` is forwarded at all, + and ``provider_specific_entry.fast`` is what prices the response.""" + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + from litellm.llms.anthropic.cost_calculation import ( + cost_per_token as anthropic_cost_per_token, + ) + from litellm.types.utils import Usage + + assert ( + AnthropicConfig._model_supports_speed_param("claude-opus-5", "anthropic") is True + ) + + usage = Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500) + usage.speed = "fast" + prompt_cost, completion_cost = anthropic_cost_per_token( + model="claude-opus-5", usage=usage + ) + assert prompt_cost == pytest.approx(1000 * 5e-06 * 2.0) + assert completion_cost == pytest.approx(500 * 2.5e-05 * 2.0) + + +def test_opus_5_present_in_bundled_backup(): + """The bundled backup is the runtime fallback (and what tests load with + ``LITELLM_LOCAL_MODEL_COST_MAP=True``); it must carry the same entries as the + root cost map, otherwise the model resolves on one path but not the other.""" + backup = GetModelCostMap.load_local_model_cost_map() + for model_name in ALL_OPUS_5_VARIANTS: + assert model_name in backup, f"Missing from backup cost map: {model_name}" + + +def test_opus_5_registered_for_bedrock_converse(): + assert "anthropic.claude-opus-5" in BEDROCK_CONVERSE_MODELS + + +def test_opus_5_provider_resolves_via_model_info(local_model_cost_map): + """Regression: ``claude-opus-5`` must resolve to provider ``anthropic``. + + Without the cost-map entry the model is unknown to LiteLLM, so it cannot be + tied to the ``anthropic`` provider and an ``anthropic/*`` wildcard deployment + would not match it.""" + info = litellm.get_model_info(model="claude-opus-5") + assert info["litellm_provider"] == "anthropic" + assert info["max_input_tokens"] == 1000000 + assert info["max_output_tokens"] == 128000 + + +@pytest.mark.parametrize( + "cost_map", + [_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()], + ids=["root", "bundled_backup"], +) +def test_opus_5_all_variants_carry_adaptive_thinking_flag(cost_map): + """Every Opus 5 entry must advertise ``supports_adaptive_thinking``. + + Adaptive-thinking detection is cost-map driven, so a single variant missing + the flag silently sends the legacy ``thinking.type='enabled'`` shape, which + Opus 5 rejects with a 400.""" + variants = [k for k in cost_map if "claude-opus-5" in k] + assert variants, "no claude-opus-5 entries found in cost map" + missing = [ + k for k in variants if cost_map[k].get("supports_adaptive_thinking") is not True + ] + assert not missing, f"missing supports_adaptive_thinking: {missing}" + + +@pytest.mark.parametrize( + "cost_map", + [_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()], + ids=["root", "bundled_backup"], +) +def test_opus_5_all_variants_carry_512_token_cache_minimum(cost_map): + variants = [k for k in cost_map if "claude-opus-5" in k] + assert variants, "no claude-opus-5 entries found in cost map" + wrong = { + k: cost_map[k].get("prompt_cache_min_tokens") + for k in variants + if cost_map[k].get("prompt_cache_min_tokens") != 512 + } + assert not wrong, f"prompt_cache_min_tokens must be 512: {wrong}" diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index ec1e3ac05ba..ce743063309 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -4,11 +4,6 @@ "count": 1 } }, - "src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx": { "no-restricted-imports": { "count": 2 @@ -24,11 +19,6 @@ "count": 1 } }, - "src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": { "no-restricted-imports": { "count": 2 @@ -144,9 +134,6 @@ "src/app/(dashboard)/budgets/_components/budget_panel.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx": { @@ -177,9 +164,6 @@ "src/app/(dashboard)/caching/_components/cache_health.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx": { @@ -187,11 +171,6 @@ "count": 1 } }, - "src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts": { "no-restricted-imports": { "count": 1 @@ -213,11 +192,6 @@ "count": 1 } }, - "src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/caching/_components/coordination_redis_settings/coordinationRedisFields.ts": { "no-restricted-imports": { "count": 1 @@ -838,15 +812,7 @@ "count": 1 } }, - "src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/immutability": { "count": 2 } @@ -861,11 +827,6 @@ "count": 1 } }, - "src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -904,9 +865,6 @@ } }, "src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -947,11 +905,6 @@ "count": 1 } }, - "src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": { "no-nested-ternary": { "count": 2 @@ -999,9 +952,6 @@ }, "no-nested-ternary": { "count": 3 - }, - "no-restricted-imports": { - "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": { @@ -1011,9 +961,6 @@ "local/no-complex-jsx-arrow": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 2 } @@ -1021,17 +968,11 @@ "src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": { @@ -1057,9 +998,6 @@ "src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": { @@ -1072,9 +1010,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 2 } @@ -1082,9 +1017,6 @@ "src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": { @@ -1097,9 +1029,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 2 } @@ -1109,21 +1038,11 @@ "count": 1 } }, - "src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/memory/_components/MemoryEditModal.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/memory/_components/MemoryView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.test.tsx": { "react/display-name": { "count": 1 @@ -1469,9 +1388,6 @@ "no-nested-ternary": { "count": 10 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/immutability": { "count": 1 } @@ -1483,9 +1399,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1509,9 +1422,6 @@ "src/app/(dashboard)/policies/_components/impact_preview_alert.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/policies/_components/index.test.tsx": { @@ -1526,9 +1436,6 @@ "local/no-complex-jsx-arrow": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1543,9 +1450,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 2 } @@ -1554,9 +1458,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1564,9 +1465,6 @@ "src/app/(dashboard)/policies/_components/policy_templates.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/policies/_components/policy_test_panel.tsx": { @@ -1584,9 +1482,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/immutability": { "count": 1 }, @@ -1840,9 +1735,6 @@ } }, "src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1864,9 +1756,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1888,9 +1777,6 @@ } }, "src/app/(dashboard)/ui-theme/UIThemeSettings.tsx": { - "no-restricted-imports": { - "count": 1 - }, "no-restricted-syntax": { "count": 3 }, @@ -2054,11 +1940,6 @@ "count": 1 } }, - "src/app/(dashboard)/vector-stores/_components/TestVectorStoreTab.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx": { "no-nested-ternary": { "count": 2 @@ -2070,18 +1951,10 @@ "count": 1 } }, - "src/app/(dashboard)/vector-stores/_components/VectorStoreTester.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/vector-stores/_components/index.tsx": { "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2218,26 +2091,11 @@ "count": 1 } }, - "src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx": { "no-restricted-imports": { "count": 1 @@ -2435,11 +2293,6 @@ "count": 1 } }, - "src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx": { "no-restricted-imports": { "count": 1 @@ -2605,20 +2458,7 @@ } }, "src/components/ToolDetail.tsx": { - "no-restricted-imports": { - "count": 1 - }, "unused-imports/no-unused-imports": { - "count": 2 - } - }, - "src/components/ToolPolicies/PolicySelect.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/ToolPolicies/ToolPoliciesTableColumns.tsx": { - "no-restricted-imports": { "count": 1 } }, @@ -3017,23 +2857,10 @@ } }, "src/components/common_components/Filters/FilterInput.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/components/common_components/Filters/FiltersButton.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/common_components/Filters/ResetFiltersButton.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/IconActionButton/BaseActionButton.tsx": { "no-restricted-imports": { "count": 1 @@ -3240,9 +3067,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/immutability": { "count": 1 } @@ -3251,9 +3075,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "prefer-const": { "count": 1 } @@ -3595,7 +3416,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 3 + "count": 1 } }, "src/components/page_utils.test.ts": { @@ -4116,6 +3937,11 @@ "count": 1 } }, + "src/components/ui/radio-group.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/ui/scroll-area.tsx": { "local/filename-pascal-case": { "count": 1 @@ -4304,7 +4130,7 @@ }, "src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx": { "no-nested-ternary": { - "count": 3 + "count": 2 }, "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx index 72a89093bdb..9476a8d98af 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx @@ -1,68 +1,63 @@ import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails"; -import { - Button, - Card, - Col, - Descriptions, - Empty, - Flex, - Layout, - List, - Row, - Spin, - Tabs, - Tag, - theme, - Typography, -} from "antd"; import { ArrowLeftIcon, BotIcon, EditIcon, KeyIcon, LayersIcon, ServerIcon, UsersIcon } from "lucide-react"; import { useState } from "react"; import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag"; +import CopyButton from "@/components/shared/CopyButton"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal"; -const { Title, Text } = Typography; -const { Content } = Layout; - interface AccessGroupDetailProps { accessGroupId: string; onBack: () => void; } +const MAX_PREVIEW = 5; + +function ResourceList({ ids, emptyMessage }: { ids: string[]; emptyMessage: string }) { + if (ids.length === 0) { + return

{emptyMessage}

; + } + return ( +
+ {ids.map((id) => ( + + + {id} + + + ))} +
+ ); +} + export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailProps) { const { data: accessGroup, isLoading } = useAccessGroupDetails(accessGroupId); - const { token } = theme.useToken(); const [isEditModalVisible, setIsEditModalVisible] = useState(false); const [showAllKeys, setShowAllKeys] = useState(false); const [showAllTeams, setShowAllTeams] = useState(false); - const MAX_PREVIEW = 5; - if (isLoading) { return ( - - - - - +
+
+ +
+
); } if (!accessGroup) { return ( - - +

Access group not found

+ ); } @@ -75,224 +70,159 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr const displayedKeys = showAllKeys ? keyIds : keyIds.slice(0, MAX_PREVIEW); const displayedTeams = showAllTeams ? teamIds : teamIds.slice(0, MAX_PREVIEW); - const handleEdit = () => { - setIsEditModalVisible(true); - }; - - const tabItems = [ - { - key: "models", - label: ( - - - Models - {modelIds?.length} - - ), - children: - modelIds?.length > 0 ? ( - ( - - - {id} - - - )} - /> - ) : ( - - ), - }, - { - key: "mcp", - label: ( - - - MCP Servers - {mcpServerIds?.length} - - ), - children: - mcpServerIds?.length > 0 ? ( - ( - - - {id} - - - )} - /> - ) : ( - - ), - }, - { - key: "agents", - label: ( - - - Agents - {agentIds?.length} - - ), - children: - agentIds?.length > 0 ? ( - ( - - - {id} - - - )} - /> - ) : ( - - ), - }, - ]; - return ( - - {/* Header */} -
-
-
- - {accessGroup.access_group_name} - - - ID: {accessGroup.access_group_id} - +

{accessGroup.access_group_name}

+
+ ID: {accessGroup.access_group_id} + +
-
- {/* Group Details */} - - - - {accessGroup.description || "—"} - + + + Group Details + + +
+
Description
+
{accessGroup.description || "—"}
+
Created
+
{new Date(accessGroup.created_at).toLocaleString()} {accessGroup.created_by && ( - -  {"by"}  + <> + by - + )} - - +
+
Last Updated
+
{new Date(accessGroup.updated_at).toLocaleString()} {accessGroup.updated_by && ( - -  {"by"}  + <> + by - + )} - - - - - - {/* Attached Keys & Teams */} - - - - - Attached Keys - {keyIds?.length} - - } - extra={ - keyIds?.length > MAX_PREVIEW ? ( - - ) : null - } - > - {keyIds?.length > 0 ? ( - - {displayedKeys.map((id) => ( - - - {id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id} - - - ))} - - ) : ( - - )} - - - - - - Attached Teams - {teamIds?.length} - - } - extra={ - teamIds?.length > MAX_PREVIEW ? ( - - ) : null - } - > - {teamIds?.length > 0 ? ( - - {displayedTeams.map((id) => ( - - - {id} - - - ))} - - ) : ( - - )} - - - - - {/* Resources Tabs */} - - +
+
+
+
+ +
+ + + + + Attached Keys + {keyIds.length} + + {keyIds.length > MAX_PREVIEW && ( + + + + )} + + + {keyIds.length > 0 ? ( +
+ {displayedKeys.map((id) => ( + + {id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id} + + ))} +
+ ) : ( +

No keys attached

+ )} +
+
+ + + + + + Attached Teams + {teamIds.length} + + {teamIds.length > MAX_PREVIEW && ( + + + + )} + + + {teamIds.length > 0 ? ( +
+ {displayedTeams.map((id) => ( + + {id} + + ))} +
+ ) : ( +

No teams attached

+ )} +
+
+
+ + + + + + + + Models + {modelIds.length} + + + + MCP Servers + {mcpServerIds.length} + + + + Agents + {agentIds.length} + + + + + + + + + + + + + - {/* Edit Modal */} setIsEditModalVisible(false)} /> -
+ ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx index 0de6596f57c..f37acb3d85a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx @@ -1,10 +1,11 @@ import { AccessGroupResponse, useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; import { useDeleteAccessGroup } from "@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup"; -import { PlusOutlined } from "@ant-design/icons"; -import { Button, Flex, Input, Layout, Space, theme, Typography } from "antd"; -import { SearchIcon } from "lucide-react"; +import { Plus, SearchIcon, X } from "lucide-react"; import { useMemo, useState } from "react"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; +import { PageHeader } from "@/components/shared/PageHeader"; +import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; import { AccessGroupDetail } from "./AccessGroupsDetailsPage"; import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal"; import { AccessGroupsTable } from "./AccessGroupsTable"; @@ -12,9 +13,6 @@ import { AccessGroup } from "./types"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { isProxyAdminRole } from "@/utils/roles"; -const { Title, Text } = Typography; -const { Content } = Layout; - function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup { return { id: r.access_group_id, @@ -33,7 +31,6 @@ function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup { } export function AccessGroupsPage() { - const { token } = theme.useToken(); const { userRole } = useAuthorized(); // Admin Viewer follows the read-parity rule: see access groups, no writes. const canModify = isProxyAdminRole(userRole ?? ""); @@ -62,31 +59,41 @@ export function AccessGroupsPage() { } return ( - - - - - Access Groups - - Manage resource permissions for your organization - - {canModify && ( - - )} - - - - } - placeholder="Search groups by name, ID, or description..." - style={{ maxWidth: 400 }} - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - allowClear +
+
+ setIsCreateModalVisible(true)}> + + Create Access Group + + ) : undefined + } /> - +
+ +
+ + + + + setSearchText(e.target.value)} + /> + {searchText && ( + + setSearchText("")}> + + + + )} + +
- +
); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx index 6d5c0c7be08..2cf2a4c06ec 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx @@ -3,9 +3,10 @@ * */ -import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text } from "@tremor/react"; import React, { useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { useBudgets, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; @@ -73,76 +74,82 @@ const BudgetPanel: React.FC = ({ accessToken }) => { return (
{canModify && ( - )} - - - Budgets - Examples - - - -
- - {selectedBudget && ( - - )} - Create a budget to assign to customers. - + + + Budgets + + + Examples + + + +
+ + {selectedBudget && ( + - -
- - -
- How to use budget id - - - Assign Budget to Customer - Test it (Curl) - Test it (OpenAI SDK) - - - - {CREATE_END_USER_CURL_COMMAND} - - - {CHAT_COMPLETIONS_CURL_COMMAND} - - - {OPENAI_SDK_PYTHON_CODE} - - - -
-
- - + )} +

Create a budget to assign to customers.

+ + +
+ + +
+

How to use budget id

+ + + + Assign Budget to Customer + + + Test it (Curl) + + + Test it (OpenAI SDK) + + + + {CREATE_END_USER_CURL_COMMAND} + + + {CHAT_COMPLETIONS_CURL_COMMAND} + + + {OPENAI_SDK_PYTHON_CODE} + + +
+
+
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx index 13472a3d1df..e1d02e9352d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx @@ -40,14 +40,23 @@ const renderDashboard = () => , ); +const REQUESTS_CHART_TITLE = "Cache Hits vs API Requests"; +const TOKENS_CHART_TITLE = "Cached Completion Tokens vs Generated Completion Tokens"; + +// Anchored on each chart's own title rather than on a global card count, so +// adding cards elsewhere on the page cannot silently repoint these assertions. +const cardTitled = (title: string): HTMLElement => { + const card = screen.getByText(title).closest('[data-slot="card"]'); + expect(card).not.toBeNull(); + return card as HTMLElement; +}; + const findChartCards = async () => { - await screen.findByText("Cache Hits vs API Requests"); + await screen.findByText(REQUESTS_CHART_TITLE); await waitFor(() => { expect(document.querySelectorAll("path.recharts-rectangle").length).toBeGreaterThan(0); }); - const cards = Array.from(document.querySelectorAll('[data-slot="card"]')); - expect(cards).toHaveLength(2); - return { requestsCard: cards[0] as HTMLElement, tokensCard: cards[1] as HTMLElement }; + return { requestsCard: cardTitled(REQUESTS_CHART_TITLE), tokensCard: cardTitled(TOKENS_CHART_TITLE) }; }; const barFills = (card: HTMLElement) => diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx index 51c0b85cedb..95b73d1aacb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx @@ -1,25 +1,24 @@ -import { - Card, - Col, - DateRangePickerValue, - Grid, - Icon, - MultiSelect, - MultiSelectItem, - Tab, - TabGroup, - TabList, - TabPanel, - TabPanels, - Text, -} from "@tremor/react"; +import { DateRangePickerValue } from "@tremor/react"; import React, { useEffect, useState } from "react"; import NotificationsManager from "@/components/molecules/notifications_manager"; import UsageDatePicker from "@/components/shared/usage_date_picker"; import { BarChart } from "@/components/shared/charts"; -import { Card as ChartCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, +} from "@/components/ui/combobox"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { RefreshIcon } from "@heroicons/react/outline"; +import { RefreshCw } from "lucide-react"; import { adminGlobalCacheActivity, cachingHealthCheckCall } from "@/components/networking"; // Import the new component @@ -258,31 +257,42 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole } }; + const statCards = [ + { label: "Cache Hit Ratio", value: `${cacheHitRatio}%` }, + { label: "Cache Hits", value: cachedResponses }, + { label: "Cached Completion Tokens", value: cachedTokens }, + ]; + return ( - - -
- Cache Analytics - Cache Health - Cache Settings - Coordination Redis -
+ +
+ + + Cache Analytics + + + Cache Health + + + Cache Settings + + + Coordination Redis + +
- {lastRefreshed && Last Refreshed: {lastRefreshed}} - + {lastRefreshed &&

Last Refreshed: {lastRefreshed}

} +
- - - - - +
+ + + + +

Analytics for LiteLLM's{" "} = ({ accessToken, token, userRole {" "} (cached input tokens from Anthropic, OpenAI, etc.) is not shown here; see "Prompt Caching Metrics" on the Usage page or individual requests in the Logs page. - - - - - {uniqueApiKeys.map((key) => ( - - {key} - - ))} - - - - - {uniqueModels.map((model) => ( - - {model} - - ))} - - - - { - setDateValue(value); - updateCachingData(value.from, value.to); - }} - /> - - +

-
- -

- Cache Hit Ratio -

-
-

- {cacheHitRatio}% -

-
-
- -

- Cache Hits -

-
-

- {cachedResponses} -

-
-
+
+ setSelectedApiKeys(keys)} + > + + + {(keys: string[]) => + keys.map((key) => ( + + {key} + + )) + } + + + + + No virtual keys found + + {(key: string) => ( + + {key} + + )} + + + - -

- Cached Completion Tokens -

-
-

- {cachedTokens} -

-
-
+ setSelectedModels(models)} + > + + + {(models: string[]) => + models.map((model) => ( + + {model} + + )) + } + + + + + No models found + + {(model: string) => ( + + {model} + + )} + + + + + { + setDateValue(value); + updateCachingData(value.from, value.to); + }} + />
- +
+ {statCards.map((stat) => ( + + +

{stat.label}

+
+

{stat.value}

+
+
+
+ ))} +
+ + Cache Hits vs API Requests @@ -387,9 +413,9 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole yAxisWidth={48} /> -
+ - + Cached Completion Tokens vs Generated Completion Tokens @@ -406,24 +432,27 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole yAxisWidth={48} /> - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.test.tsx new file mode 100644 index 00000000000..de06ef07ed1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.test.tsx @@ -0,0 +1,148 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import { CacheHealthTab } from "./cache_health"; + +const healthyResponse = { + status: "healthy", + ping_response: true, + set_cache_response: "success", + litellm_cache_params: JSON.stringify({ type: "redis", supported_call_types: ["acompletion"] }), + health_check_cache_params: JSON.stringify({ + redis_version: "7.2.1", + namespace: "litellm-ns", + connection_kwargs: { host: "redis.internal", port: 6379 }, + }), +}; + +const errorPayload = { + message: "Connection refused", + traceback: "Traceback (most recent call last): ...", + litellm_cache_params: { type: "redis" }, + health_check_cache_params: {}, +}; + +const errorResponse = { error: { message: JSON.stringify(errorPayload) } }; + +const renderTab = (overrides: Partial> = {}) => + renderWithProviders( + , + ); + +describe("CacheHealthTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("offers a health check button and no results before one is run", () => { + renderTab(); + + expect(screen.getByRole("button", { name: "Run Health Check" })).toBeInTheDocument(); + expect(screen.queryByText(/Cache Status:/)).not.toBeInTheDocument(); + }); + + it("runs the health check when the button is clicked", async () => { + const runCachingHealthCheck = vi.fn(); + const user = userEvent.setup(); + renderTab({ runCachingHealthCheck }); + + await user.click(screen.getByRole("button", { name: "Run Health Check" })); + + expect(runCachingHealthCheck).toHaveBeenCalledTimes(1); + }); + + it("shows an in-flight label and disables the button while the check runs", async () => { + const runCachingHealthCheck = vi.fn(() => new Promise(() => {})); + const user = userEvent.setup(); + renderTab({ runCachingHealthCheck }); + + await user.click(screen.getByRole("button", { name: "Run Health Check" })); + + const button = await screen.findByRole("button", { name: "Running Health Check..." }); + expect(button).toBeDisabled(); + }); + + it("reports a healthy cache with its ping and set-cache results", async () => { + renderTab({ healthCheckResponse: healthyResponse }); + + expect(await screen.findByText("Cache Status: healthy")).toBeInTheDocument(); + expect(screen.getByText("Cache Details")).toBeInTheDocument(); + expect(screen.getByText("Ping Response")).toBeInTheDocument(); + expect(screen.getByText("Set Cache Response")).toBeInTheDocument(); + expect(screen.getByText("success")).toBeInTheDocument(); + }); + + it("shows the Redis detail rows when the cache type is redis", async () => { + renderTab({ healthCheckResponse: healthyResponse }); + + expect(await screen.findByText("Redis Details")).toBeInTheDocument(); + expect(screen.getByText("Redis Host")).toBeInTheDocument(); + expect(screen.getByText("redis.internal")).toBeInTheDocument(); + expect(screen.getByText("Redis Port")).toBeInTheDocument(); + expect(screen.getByText("Redis Version")).toBeInTheDocument(); + expect(screen.getByText("7.2.1")).toBeInTheDocument(); + expect(screen.getByText("Namespace")).toBeInTheDocument(); + expect(screen.getByText("litellm-ns")).toBeInTheDocument(); + }); + + it("omits the Redis detail rows for a non-redis cache type", async () => { + renderTab({ + healthCheckResponse: { + status: "healthy", + ping_response: true, + litellm_cache_params: JSON.stringify({ type: "local" }), + health_check_cache_params: JSON.stringify({}), + }, + }); + + expect(await screen.findByText("Cache Status: healthy")).toBeInTheDocument(); + expect(screen.queryByText("Redis Details")).not.toBeInTheDocument(); + }); + + it("surfaces the error message and traceback when the check fails", async () => { + renderTab({ healthCheckResponse: errorResponse }); + + expect(await screen.findByText("Error Details")).toBeInTheDocument(); + expect(screen.getByText("Error Message")).toBeInTheDocument(); + expect(screen.getByText("Connection refused")).toBeInTheDocument(); + expect(screen.getByText("Traceback")).toBeInTheDocument(); + expect(screen.getByText("Cache Status: unhealthy")).toBeInTheDocument(); + }); + + it("still shows the cache details section when the check failed", async () => { + renderTab({ healthCheckResponse: errorResponse }); + + expect(await screen.findByText("Cache Details")).toBeInTheDocument(); + }); + + it("truncates a long value and expands it to the full value on click", async () => { + const longMessage = "M".repeat(120); + const user = userEvent.setup(); + renderTab({ + healthCheckResponse: { + error: { message: JSON.stringify({ message: longMessage, traceback: "short" }) }, + }, + }); + + await screen.findByText("Error Message"); + expect(screen.getByText(`${"M".repeat(50)}...`)).toBeInTheDocument(); + expect(screen.queryByText(longMessage)).not.toBeInTheDocument(); + + await user.click(screen.getAllByRole("button", { name: "▶" })[0]); + + await waitFor(() => { + expect(screen.getByText(longMessage)).toBeInTheDocument(); + }); + }); + + it("offers both the summary and raw response views", async () => { + renderTab({ healthCheckResponse: healthyResponse }); + + expect(await screen.findByText("Summary")).toBeInTheDocument(); + expect(screen.getByText("Raw Response")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx index 887c12a4f3d..85649c1cf26 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx @@ -1,6 +1,7 @@ import React from "react"; -import { Text, Button, TabGroup, TabList, Tab, TabPanel, TabPanels } from "@tremor/react"; -import { CheckCircleIcon, XCircleIcon, ClipboardCopyIcon } from "@heroicons/react/outline"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { CheckCircle2, XCircle, ClipboardCopy } from "lucide-react"; import { ResponseTimeIndicator } from "./response_time_indicator"; // Helper function to deep-parse a JSON string if possible @@ -30,22 +31,26 @@ const TableClickableErrorField: React.FC<{ label: string; value: string | null | }; return ( - + -
-
-
-
{label}
-
-                {isExpanded ? safeValue : truncated}
-              
+
{label}
+
{isExpanded ? safeValue : truncated}
-
@@ -151,112 +156,114 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => { }; return ( -
- - - Summary - Raw Response - +
+ + + + Summary + + + Raw Response + + - - -
-
- {response?.status === "healthy" ? ( - - ) : ( - + +
+
+ {response?.status === "healthy" ? ( + + ) : ( + + )} +

+ Cache Status: {response?.status || "unhealthy"} +

+
+ + + + {/* Show error message if present */} + {errorDetails && ( + <> + + + + + + )} - - Cache Status: {response?.status || "unhealthy"} - - -
+ Error Details +
- - {/* Show error message if present */} - {errorDetails && ( - <> - - - - - - - )} + {/* Always show cache details, regardless of error state */} + + + + + + + - {/* Always show cache details, regardless of error state */} - - - - - - - + {/* Redis Details Section */} + {parsedLitellmParams?.type === "redis" && ( + <> + + + + + + + + + + )} + +
- Error Details -
+ Cache Details +
- Cache Details -
+ Redis Details +
+
+
- {/* Redis Details Section */} - {parsedLitellmParams?.type === "redis" && ( - <> - - - Redis Details - - - - - - - - - )} - - -
- - - -
-
-                {(() => {
-                  try {
-                    const data = {
-                      ...response,
-                      litellm_cache_params: parsedLitellmParams,
-                      health_check_cache_params: parsedRedisParams,
-                    };
-                    // First parse any string JSON values
-                    const prettyData = JSON.parse(
-                      JSON.stringify(data, (key, value) => {
-                        if (typeof value === "string") {
-                          try {
-                            return JSON.parse(value);
-                          } catch {
-                            return value;
-                          }
+        
+          
+
+              {(() => {
+                try {
+                  const data = {
+                    ...response,
+                    litellm_cache_params: parsedLitellmParams,
+                    health_check_cache_params: parsedRedisParams,
+                  };
+                  // First parse any string JSON values
+                  const prettyData = JSON.parse(
+                    JSON.stringify(data, (key, value) => {
+                      if (typeof value === "string") {
+                        try {
+                          return JSON.parse(value);
+                        } catch {
+                          return value;
                         }
-                        return value;
-                      }),
-                    );
-                    // Then stringify with proper formatting
-                    return JSON.stringify(prettyData, null, 2);
-                  } catch (e) {
-                    return "Error formatting JSON: " + (e as Error).message;
-                  }
-                })()}
-              
-
- - - + } + return value; + }), + ); + // Then stringify with proper formatting + return JSON.stringify(prettyData, null, 2); + } catch (e) { + return "Error formatting JSON: " + (e as Error).message; + } + })()} +
+
+ +
); }; @@ -282,11 +289,7 @@ export const CacheHealthTab: React.FC<{ return (
- diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx index fbca7ab5a97..40f553e6fde 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Select, SelectItem } from "@tremor/react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; interface RedisTypeSelectorProps { redisType: string; @@ -7,17 +7,30 @@ interface RedisTypeSelectorProps { onTypeChange: (type: string) => void; } +const REDIS_TYPE_LABELS: Readonly> = { + node: "Node (Single Instance)", + cluster: "Cluster", + sentinel: "Sentinel", + semantic: "Semantic", +}; + const RedisTypeSelector: React.FC = ({ redisType, redisTypeDescriptions, onTypeChange }) => { return (
- - value !== null && onTypeChange(value)}> + + {REDIS_TYPE_LABELS[redisType] ?? redisType} + + + {Object.entries(REDIS_TYPE_LABELS).map(([value, label]) => ( + + {label} + + ))} + -

+

{redisTypeDescriptions[redisType] || "Select the type of Redis deployment you're using"}

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx new file mode 100644 index 00000000000..76287e6e724 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import CoordinationRedisTypeSelector from "./CoordinationRedisTypeSelector"; +import { COORDINATION_REDIS_TYPE_DESCRIPTIONS } from "./coordinationRedisFields"; + +describe("CoordinationRedisTypeSelector", () => { + it("labels the control and shows the current selection", () => { + renderWithProviders(); + + expect(screen.getByText("Redis Type")).toBeInTheDocument(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(screen.getByText("Node (Single Instance)")).toBeInTheDocument(); + }); + + it("shows the description for the selected type", () => { + renderWithProviders(); + + expect(screen.getByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.cluster)).toBeInTheDocument(); + }); + + it("switches the description when the selected type changes", () => { + const { rerender } = renderWithProviders(); + expect(screen.getByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.node)).toBeInTheDocument(); + + rerender(); + + expect(screen.getByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.sentinel)).toBeInTheDocument(); + expect(screen.queryByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.node)).not.toBeInTheDocument(); + }); + + it("reports the newly picked type to the caller", async () => { + const onTypeChange = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Cluster")); + + expect(onTypeChange).toHaveBeenCalledTimes(1); + expect(onTypeChange.mock.calls[0][0]).toBe("cluster"); + }); + + it("offers every supported coordination redis type", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("combobox")); + + expect(await screen.findByText("Cluster")).toBeInTheDocument(); + expect(screen.getByText("Sentinel")).toBeInTheDocument(); + expect(screen.getAllByText("Node (Single Instance)").length).toBeGreaterThan(0); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx index daab8505890..e6385e83649 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Select } from "antd"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { COORDINATION_REDIS_TYPES, COORDINATION_REDIS_TYPE_DESCRIPTIONS, @@ -12,21 +12,24 @@ interface CoordinationRedisTypeSelectorProps { onTypeChange: (type: CoordinationRedisType) => void; } -const OPTIONS = COORDINATION_REDIS_TYPES.map((type) => ({ value: type, label: COORDINATION_REDIS_TYPE_LABELS[type] })); - const CoordinationRedisTypeSelector: React.FC = ({ redisType, onTypeChange }) => (
-
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx index bd0ecea9483..4f1cfc49569 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx @@ -102,8 +102,8 @@ const CacheLeakageCard: React.FC = ({ activity }) => { -
-
+
+
Cache leakage by {dimension === "model" ? "model" : "virtual key"}

{subject} sending large volumes of uncached input with a low cache hit rate are likely missing prompt @@ -111,7 +111,9 @@ const CacheLeakageCard: React.FC = ({ activity }) => { {dimension === "model" ? " Limited to Anthropic (Claude) models, which support prompt caching." : ""}

- +
+ +
=> { const { accessToken } = useAuthorized(); return useQuery({ queryKey: keyKeys.list({ page, limit: pageSize, ...options }), queryFn: async () => await keyListCall(accessToken!, page, pageSize, options), - enabled: Boolean(accessToken), + enabled: Boolean(accessToken) && enabled, staleTime: 30000, // 30 seconds placeholderData: keepPreviousData, }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts index 0bcc37d1389..f83ebd2622a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts @@ -3,13 +3,17 @@ import { renderHook, waitFor } from "@testing-library/react"; import React, { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { + isAutoRouterDeployment, + selectAutoRouterModelGroups, useAllProxyModels, + useAutoRouterModelGroups, useInfiniteModelInfo, useModelHub, useModelsInfo, useSelectedTeamModels, useUserModels, type AllProxyModelsResponse, + type AutoRouterCandidateDeployment, type PaginatedModelInfoResponse, type ProxyModel, } from "./useModels"; @@ -918,3 +922,161 @@ describe("useInfiniteModelInfo", () => { expect(modelInfoCall).not.toHaveBeenCalled(); }); }); + +describe("isAutoRouterDeployment", () => { + const cases: [string, string | null | undefined, boolean][] = [ + ["base semantic auto-router", "auto_router/my_router", true], + ["complexity router", "auto_router/complexity_router", true], + ["adaptive router", "auto_router/adaptive_router", true], + ["quality router", "auto_router/quality_router", true], + ["plain provider alias", "anthropic/claude-haiku-4-5", false], + ["wildcard deployment", "openai/*", false], + ["name merely containing the prefix", "openai/auto_router/nope", false], + ["missing model", undefined, false], + ["null model", null, false], + ]; + + it.each(cases)("returns %s -> %s", (_label, litellmParamsModel, expected) => { + expect(isAutoRouterDeployment({ model_name: "some-group", litellm_params: { model: litellmParamsModel } })).toBe( + expected, + ); + }); + + it("returns false when litellm_params is absent", () => { + expect(isAutoRouterDeployment({ model_name: "some-group" })).toBe(false); + }); +}); + +describe("selectAutoRouterModelGroups", () => { + it("keeps only the public model_name of auto-router deployments", () => { + const deployments: AutoRouterCandidateDeployment[] = [ + { model_name: "smart-router", litellm_params: { model: "auto_router/complexity_router" } }, + { model_name: "claude-haiku", litellm_params: { model: "anthropic/claude-haiku-4-5" } }, + { model_name: "claude-sonnet", litellm_params: { model: "anthropic/claude-sonnet-4-5" } }, + { model_name: "cheap-router", litellm_params: { model: "auto_router/adaptive_router" } }, + ]; + + expect(selectAutoRouterModelGroups(deployments)).toEqual(new Set(["smart-router", "cheap-router"])); + }); + + it("drops auto-router deployments that have no public model_name", () => { + expect( + selectAutoRouterModelGroups([{ model_name: "", litellm_params: { model: "auto_router/complexity_router" } }]), + ).toEqual(new Set()); + }); + + it("returns an empty set for an empty model list", () => { + expect(selectAutoRouterModelGroups([])).toEqual(new Set()); + }); +}); + +describe("useAutoRouterModelGroups", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + vi.clearAllMocks(); + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: "test-user-id", + userRole: "Admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("resolves the set of auto-router model groups from the deployment list", async () => { + (modelInfoCall as any).mockResolvedValue({ + data: [ + { model_name: "smart-router", litellm_params: { model: "auto_router/complexity_router" } }, + { model_name: "claude-haiku", litellm_params: { model: "anthropic/claude-haiku-4-5" } }, + ], + total_count: 2, + current_page: 1, + total_pages: 1, + size: 1000, + }); + + const { result } = renderHook(() => useAutoRouterModelGroups(), { wrapper }); + + await waitFor(() => expect(result.current.size).toBe(1)); + expect(result.current.has("smart-router")).toBe(true); + expect(result.current.has("claude-haiku")).toBe(false); + }); + + it("requests a single large page when the proxy reports only one page of deployments", async () => { + (modelInfoCall as any).mockResolvedValue({ + data: [], + total_count: 0, + current_page: 1, + total_pages: 1, + size: 1000, + }); + + renderHook(() => useAutoRouterModelGroups(), { wrapper }); + + await waitFor(() => expect(modelInfoCall).toHaveBeenCalled()); + expect(modelInfoCall).toHaveBeenCalledWith("test-access-token", "test-user-id", "Admin", 1, 1000); + expect(modelInfoCall).toHaveBeenCalledTimes(1); + }); + + it("follows total_pages so an auto-router past the first page is still found", async () => { + (modelInfoCall as any).mockImplementation((_t: string, _u: string, _r: string, page: number) => { + if (page === 1) { + return Promise.resolve({ + data: [{ model_name: "claude-haiku", litellm_params: { model: "anthropic/claude-haiku-4-5" } }], + total_count: 3, + current_page: 1, + total_pages: 3, + size: 1000, + }); + } + if (page === 2) { + return Promise.resolve({ + data: [{ model_name: "claude-sonnet", litellm_params: { model: "anthropic/claude-sonnet-4-5" } }], + total_count: 3, + current_page: 2, + total_pages: 3, + size: 1000, + }); + } + return Promise.resolve({ + data: [{ model_name: "late-router", litellm_params: { model: "auto_router/complexity_router" } }], + total_count: 3, + current_page: 3, + total_pages: 3, + size: 1000, + }); + }); + + const { result } = renderHook(() => useAutoRouterModelGroups(), { wrapper }); + + await waitFor(() => expect(result.current.size).toBe(1)); + expect(result.current.has("late-router")).toBe(true); + expect(modelInfoCall).toHaveBeenCalledTimes(3); + expect(modelInfoCall).toHaveBeenCalledWith("test-access-token", "test-user-id", "Admin", 3, 1000); + }); + + it("returns an empty set before the model list resolves", () => { + (modelInfoCall as any).mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useAutoRouterModelGroups(), { wrapper }); + + expect(result.current.size).toBe(0); + }); + + it("returns an empty set when the model list request fails", async () => { + (modelInfoCall as any).mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useAutoRouterModelGroups(), { wrapper }); + + await waitFor(() => expect(modelInfoCall).toHaveBeenCalled()); + expect(result.current.size).toBe(0); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts index 113d1616e62..ad5e3c91ec3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts @@ -24,6 +24,7 @@ export interface PaginatedModelInfoResponse { const modelKeys = createQueryKeys("models"); const modelHubKeys = createQueryKeys("modelHub"); +const autoRouterKeys = createQueryKeys("autoRouterModelGroups"); const allProxyModelsKeys = createQueryKeys("allProxyModels"); const selectedTeamModelsKeys = createQueryKeys("selectedTeamModels"); const infiniteModelKeys = createQueryKeys("infiniteModels"); @@ -59,6 +60,65 @@ export const useModelsInfo = ( }); }; +const AUTO_ROUTER_MODEL_PREFIX = "auto_router/"; +const AUTO_ROUTER_LOOKUP_PAGE_SIZE = 1000; +const NO_AUTO_ROUTERS: ReadonlySet = new Set(); + +export interface AutoRouterCandidateDeployment { + model_name?: string | null; + litellm_params?: { model?: string | null } | null; +} + +export const isAutoRouterDeployment = (deployment: AutoRouterCandidateDeployment): boolean => + Boolean(deployment?.litellm_params?.model?.startsWith(AUTO_ROUTER_MODEL_PREFIX)); + +export const selectAutoRouterModelGroups = (deployments: AutoRouterCandidateDeployment[]): ReadonlySet => + new Set( + deployments + .filter(isAutoRouterDeployment) + .map((deployment) => deployment.model_name) + .filter((modelName): modelName is string => Boolean(modelName)), + ); + +const fetchAllModelDeployments = async ( + accessToken: string, + userId: string, + userRole: string, +): Promise => { + const firstPage: PaginatedModelInfoResponse = await modelInfoCall( + accessToken, + userId, + userRole, + 1, + AUTO_ROUTER_LOOKUP_PAGE_SIZE, + ); + const totalPages = firstPage?.total_pages ?? 1; + const remainingPages = await Promise.all( + Array.from({ length: Math.max(0, totalPages - 1) }, (_unused, index) => + modelInfoCall(accessToken, userId, userRole, index + 2, AUTO_ROUTER_LOOKUP_PAGE_SIZE), + ), + ); + return [firstPage, ...remainingPages].flatMap( + (page: PaginatedModelInfoResponse) => page?.data ?? [], + ) as AutoRouterCandidateDeployment[]; +}; + +export const useAutoRouterModelGroups = (): ReadonlySet => { + const { accessToken, userId, userRole } = useAuthorized(); + const { data } = useQuery>({ + queryKey: autoRouterKeys.list({ + filters: { + ...(userId && { userId }), + ...(userRole && { userRole }), + }, + }), + queryFn: async () => await fetchAllModelDeployments(accessToken!, userId!, userRole!), + enabled: Boolean(accessToken && userId && userRole), + select: selectAutoRouterModelGroups, + }); + return data ?? NO_AUTO_ROUTERS; +}; + export const useModelHub = () => { const { accessToken } = useAuthorized(); return useQuery({ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx index a67a0dc882d..1cb8ed6f5b5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx @@ -1,6 +1,8 @@ import React from "react"; -import { Input, Tooltip } from "antd"; -import { InfoCircleOutlined, LinkOutlined } from "@ant-design/icons"; +import { Info, Link as LinkIcon } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { cn } from "@/lib/cva.config"; import { Logo } from "@/components/molecules/logo/Logo"; import githubLogo from "../../../../../public/assets/logos/github.svg"; import slackLogo from "../../../../../public/assets/logos/slack.svg"; @@ -61,72 +63,83 @@ const MCPLogoSelector: React.FC = ({ value, onChange }) => }; return ( -
-
- Logo - - - -
- - {/* Preview */} - {value && ( -
- -
-
{value}
-
- + +
+
+ Logo + + } + /> + + Select a well-known logo or paste a URL to any image. The logo is shown on the admin and chat pages. + +
- )} - {/* Well-known logo grid */} -
- {WELL_KNOWN_LOGOS.map((logo) => { - const isSelected = value === logo.url; - return ( - - - - ); - })} + {/* Preview */} + {value && ( +
+ +
+
{value}
+
+ +
+ )} + + {/* Well-known logo grid */} +
+ {WELL_KNOWN_LOGOS.map((logo) => { + const isSelected = value === logo.url; + return ( + + handleSelect(logo.url)} + className={cn( + "flex size-10 cursor-pointer items-center justify-center rounded-lg border p-2 transition-all", + isSelected ? "border-primary bg-accent shadow-xs" : "border-border hover:bg-accent", + )} + > + {logo.name} + + } + /> + {logo.name} + + ); + })} +
+ + {/* Custom URL input */} + + + + + { + const v = e.target.value.trim(); + onChange?.(v || undefined); + }} + /> +
- - {/* Custom URL input */} - } - placeholder="Or paste a custom logo URL..." - value={value && !selectedWellKnown ? value : ""} - onChange={(e) => { - const v = e.target.value.trim(); - onChange?.(v || undefined); - }} - className="rounded-lg" - size="small" - /> -
+
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx new file mode 100644 index 00000000000..9526f5de074 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx @@ -0,0 +1,106 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import MCPNetworkSettings from "./MCPNetworkSettings"; +import { + getGeneralSettingsCall, + updateConfigFieldSetting, + deleteConfigFieldSetting, + fetchMCPClientIp, +} from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + getGeneralSettingsCall: vi.fn(), + updateConfigFieldSetting: vi.fn(), + deleteConfigFieldSetting: vi.fn(), + fetchMCPClientIp: vi.fn(), +})); + +const renderSettings = () => render(); + +describe("MCPNetworkSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getGeneralSettingsCall).mockResolvedValue([]); + vi.mocked(fetchMCPClientIp).mockResolvedValue(null); + vi.mocked(updateConfigFieldSetting).mockResolvedValue(undefined); + vi.mocked(deleteConfigFieldSetting).mockResolvedValue(undefined); + }); + + it("renders the stored private ranges once settings load", async () => { + vi.mocked(getGeneralSettingsCall).mockResolvedValue([ + { field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8", "192.168.0.0/16"] }, + ]); + + renderSettings(); + + expect(await screen.findByText("10.0.0.0/8")).toBeInTheDocument(); + expect(screen.getByText("192.168.0.0/16")).toBeInTheDocument(); + }); + + it("ignores unrelated config fields", async () => { + vi.mocked(getGeneralSettingsCall).mockResolvedValue([ + { field_name: "some_other_setting", field_value: ["should-not-show"] }, + ]); + + renderSettings(); + + await screen.findByText("Private IP Ranges"); + expect(screen.queryByText("should-not-show")).not.toBeInTheDocument(); + }); + + it("suggests the caller's /24 range from the detected client IP", async () => { + vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45"); + + renderSettings(); + + expect(await screen.findByText("203.0.113.45")).toBeInTheDocument(); + expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument(); + }); + + it("exposes the suggested range as a control a keyboard user can reach and activate", async () => { + vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45"); + + renderSettings(); + const suggested = await screen.findByRole("button", { name: /203\.0\.113\.0\/24/ }); + + suggested.focus(); + expect(suggested).toHaveFocus(); + + await userEvent.keyboard("{Enter}"); + + await waitFor(() => expect(screen.queryByText("Suggested range:")).not.toBeInTheDocument()); + }); + + it("adds the suggested range to the list when clicked, and stops suggesting it", async () => { + vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45"); + + renderSettings(); + await userEvent.click(await screen.findByText("203.0.113.0/24")); + + await waitFor(() => expect(screen.queryByText("Suggested range:")).not.toBeInTheDocument()); + expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument(); + }); + + it("saves the configured ranges", async () => { + vi.mocked(getGeneralSettingsCall).mockResolvedValue([ + { field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8"] }, + ]); + + renderSettings(); + await userEvent.click(await screen.findByRole("button", { name: /Save/ })); + + await waitFor(() => + expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges", ["10.0.0.0/8"]), + ); + expect(deleteConfigFieldSetting).not.toHaveBeenCalled(); + }); + + it("clears the setting instead of saving an empty list", async () => { + renderSettings(); + await userEvent.click(await screen.findByRole("button", { name: /Save/ })); + + await waitFor(() => expect(deleteConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges")); + expect(updateConfigFieldSetting).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx index 7ab240389f3..8b4d2a58652 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx @@ -1,6 +1,10 @@ import React, { useState, useEffect } from "react"; -import { Select, Button, Card, Typography, Spin, Tag } from "antd"; -import { SaveOutlined, PlusOutlined } from "@ant-design/icons"; +import { Save, Plus, X } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { DeprecationBanner } from "@/components/DeprecationBanner"; import { getGeneralSettingsCall, @@ -9,8 +13,6 @@ import { fetchMCPClientIp, } from "@/components/networking"; -const { Text } = Typography; - interface MCPNetworkSettingsProps { accessToken: string | null; } @@ -29,6 +31,7 @@ const MCPNetworkSettings: React.FC = ({ accessToken }) const [saving, setSaving] = useState(false); const [privateRanges, setPrivateRanges] = useState([]); const [currentIp, setCurrentIp] = useState(null); + const [rangeDraft, setRangeDraft] = useState(""); useEffect(() => { loadSettings(); @@ -82,10 +85,22 @@ const MCPNetworkSettings: React.FC = ({ accessToken }) } }; + // Commas separate entries, matching the old tokenised input. + const commitDraft = () => { + const added = rangeDraft + .split(",") + .map((r) => r.trim()) + .filter((r) => r !== "" && !privateRanges.includes(r)); + if (added.length > 0) { + setPrivateRanges([...privateRanges, ...added]); + } + setRangeDraft(""); + }; + if (loading) { return (
- +
); } @@ -96,55 +111,76 @@ const MCPNetworkSettings: React.FC = ({ accessToken })
- Private IP Ranges -

+

Private IP Ranges

+

Define which IP ranges are part of your private network. Callers from these IPs can see all MCP servers. Callers from any other IP can only see servers marked "Available on Public Internet".

- + {currentIp && ( -
- +
+

Your current IP: {currentIp} - +

{suggestedRange && !privateRanges.includes(suggestedRange) && ( -
- Suggested range: - } +
+

Suggested range:

+
)}
)} -
- Your Private Network Ranges +
+

Your Private Network Ranges

- setRangeDraft(e.target.value)} + onBlur={commitDraft} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + commitDraft(); + } + }} /> -

+

Enter CIDR ranges (e.g., 10.0.0.0/8). When empty, standard private IP ranges are used.

-
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx index c7dd6e47f76..de53d34c3ac 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx @@ -1,19 +1,20 @@ import { type FC, type KeyboardEvent, type MouseEvent } from "react"; -import { Dropdown, Tooltip, Typography, Tag } from "antd"; -import type { MenuProps } from "antd"; +import { Check, CircleAlert, Ellipsis, Trash2, Zap } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { - CheckOutlined, - DeleteOutlined, - ExclamationCircleFilled, - MoreOutlined, - ThunderboltOutlined, -} from "@ant-design/icons"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/cva.config"; import { AUTH_TYPE, type MCPServer } from "@/components/mcp_tools/types"; import { Logo } from "@/components/molecules/logo/Logo"; import { getMaskedAndFullUrl } from "./utils"; -const { Text } = Typography; - interface MCPServerCardProps { server: MCPServer; // Per-user env-var fields this user still needs to fill in for this server. @@ -73,8 +74,8 @@ const MCPServerCard: FC = ({ const needsAttention = missing.length > 0; const cardClass = needsAttention - ? "border-2 border-red-300 bg-red-50/40 hover:border-red-400 hover:shadow-md" - : "border border-gray-200 bg-white hover:border-gray-300 hover:shadow-md"; + ? "border-2 border-destructive/40 bg-destructive/5 hover:border-destructive/60 hover:shadow-md" + : "border border-border bg-card hover:shadow-md"; const url = server.url || ""; const { maskedUrl } = url ? getMaskedAndFullUrl(url) : { maskedUrl: "" }; @@ -105,174 +106,198 @@ const MCPServerCard: FC = ({ } }; - const menuItems: MenuProps["items"] = []; - if (onRecheckHealth) { - menuItems.push({ - key: "test-connection", - label: "Test Connection", - icon: , - disabled: isRechecking, - onClick: ({ domEvent }) => { - domEvent.stopPropagation(); - onRecheckHealth(); - }, - }); - } - if (onDelete) { - if (menuItems.length > 0) { - menuItems.push({ key: "divider", type: "divider" }); - } - menuItems.push({ - key: "delete", - label: "Delete", - icon: , - danger: true, - onClick: ({ domEvent }) => { - domEvent.stopPropagation(); - onDelete(); - }, - }); - } + const hasMenu = !!onRecheckHealth || !!onDelete; // Card uses role="button" + nested + } + /> + + {onRecheckHealth && ( + { + stop(e); + onRecheckHealth(); + }} + > + + Test Connection + + )} + {onRecheckHealth && onDelete && } + {onDelete && ( + { + stop(e); + onDelete(); + }} + > + + Delete + + )} + + + )}
- {menuItems.length > 0 && ( - - - - )} -
- {subtitle ? ( - - - {subtitle} - - - ) : ( - // Defensive placeholder: keep the row even when no identifier is - // available so the tag row stays vertically aligned across the grid. -
- )} - -
- - {displayTransport.toUpperCase()} - {authType} - {oauthFlowUnset && ( - - - - - OAuth flow not set - - + {subtitle ? ( + + {subtitle}

} /> + {subtitleTooltip}
+ ) : ( + // Defensive placeholder: keep the row even when no identifier is + // available so the badge row stays vertically aligned across the grid. +
)} - - - + +
+ + {displayTransport.toUpperCase()} + {authType} + {oauthFlowUnset && ( + + + + OAuth flow not set + + } + /> + + This OAuth server has no flow set (Machine-to-Machine vs Interactive). Open it and choose an OAuth Flow + Type so LiteLLM authenticates it as you intend. + + + )} + + {isPublic ? "Public" : "Internal"} - - - {accessGroups.slice(0, 2).map((g) => ( - - {g} - - ))} - {accessGroups.length > 2 && ( - - +{accessGroups.length - 2} - - )} -
+ + {accessGroups.slice(0, 2).map((g) => ( + + + {g} + + } + /> + {g} + + ))} + {accessGroups.length > 2 && ( + + +{accessGroups.length - 2}} /> + {accessGroups.slice(2).join(", ")} + + )} +
- {(server.is_byok || needsAttention) && ( -
- {server.is_byok && } - {needsAttention && ( -
- -
Missing user fields:
+ {(server.is_byok || needsAttention) && ( +
+ {server.is_byok && } + {needsAttention && ( +
+ + + + {missing.length} user field + {missing.length === 1 ? "" : "s"} missing + + } + /> + +
Missing user fields:
    {missing.map((m) => (
  • • {m}
  • ))}
-
- } - > - - - {missing.length} user field - {missing.length === 1 ? "" : "s"} missing - - - {onOpenFillFields && ( - - )} -
- )} -
- )} -
+ +
+ {onOpenFillFields && ( + + )} +
+ )} +
+ )} +
+ ); }; @@ -297,46 +322,45 @@ const HealthChip: FC = ({ }) => { if (isLoadingHealth || isRechecking) { return ( - - - - Checking - - + + + Checking + ); } - const tooltip = ( -
-
Health: {status}
- {lastCheck &&
Last check: {new Date(lastCheck).toLocaleString()}
} - {error && ( -
-
Error
-
{error}
-
- )} - {!lastCheck && !error &&
No health data
} - {onRecheck &&
Click to recheck
} -
- ); return ( - - { - e.stopPropagation(); - onRecheck(); - } - : undefined + + { + e.stopPropagation(); + onRecheck(); + } + : undefined + } + > + + {status.charAt(0).toUpperCase() + status.slice(1)} + } - > - - - {status.charAt(0).toUpperCase() + status.slice(1)} - - + /> + +
Health: {status}
+ {lastCheck &&
Last check: {new Date(lastCheck).toLocaleString()}
} + {error && ( +
+
Error
+
{error}
+
+ )} + {!lastCheck && !error &&
No health data
} + {onRecheck &&
Click to recheck
} +
); }; @@ -350,22 +374,22 @@ const ByokRow: FC = ({ connected, onConnect }) => { if (connected) { return (
- BYOK credential + BYOK credential
- - Connected - + + Connected + {onConnect && ( - + )}
@@ -373,20 +397,19 @@ const ByokRow: FC = ({ connected, onConnect }) => { } return (
- BYOK credential + BYOK credential {onConnect ? ( - + ) : ( - + )}
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx new file mode 100644 index 00000000000..f6091f06376 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import OpenAPIQuickPicker, { type OpenAPIRegistryEntry } from "./OpenAPIQuickPicker"; +import { fetchOpenAPIRegistry } from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + fetchOpenAPIRegistry: vi.fn(), +})); + +const stripe: OpenAPIRegistryEntry = { + name: "stripe", + title: "Stripe", + description: "Payments API", + icon_url: "https://cdn.example.com/stripe.svg", + spec_url: "https://example.com/stripe.json", +}; + +const github: OpenAPIRegistryEntry = { + name: "github", + title: "GitHub", + description: "Code hosting API", + icon_url: "https://cdn.example.com/github.svg", + spec_url: "https://example.com/github.json", +}; + +describe("OpenAPIQuickPicker", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders one selectable entry per registry API", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] }); + + render(); + + expect(await screen.findByRole("button", { name: /Stripe/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /GitHub/ })).toBeInTheDocument(); + expect(screen.getByText("Popular APIs")).toBeInTheDocument(); + }); + + it("passes the whole registry entry to onSelect when one is clicked", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] }); + const onSelect = vi.fn(); + + render(); + await userEvent.click(await screen.findByRole("button", { name: /Stripe/ })); + + expect(onSelect).toHaveBeenCalledWith(stripe); + }); + + it("renders nothing when the registry is empty", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [] }); + + const { container } = render(); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it("renders nothing when the registry fetch fails", async () => { + vi.mocked(fetchOpenAPIRegistry).mockRejectedValue(new Error("boom")); + + const { container } = render(); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it("does not fetch without an access token", () => { + render(); + + expect(fetchOpenAPIRegistry).not.toHaveBeenCalled(); + }); + + it("falls back to a letter avatar when the icon fails to load", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe] }); + + render(); + + fireEvent.error(await screen.findByAltText("Stripe")); + + await waitFor(() => expect(screen.queryByAltText("Stripe")).not.toBeInTheDocument()); + expect(screen.getByText("S")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx index 0aec81fdf4b..d8e153a9699 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react"; -import { Spin } from "antd"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { cn } from "@/lib/cva.config"; import { fetchOpenAPIRegistry } from "@/components/networking"; export interface OpenAPIKeyTool { @@ -49,9 +50,9 @@ const OpenAPIQuickPicker: React.FC = ({ accessToken, se if (loading) { return (
- Popular APIs + Popular APIs
- +
); @@ -61,7 +62,7 @@ const OpenAPIQuickPicker: React.FC = ({ accessToken, se return (
- Popular APIs + Popular APIs
{apis.map((api) => { @@ -73,32 +74,30 @@ const OpenAPIQuickPicker: React.FC = ({ accessToken, se type="button" title={api.description} onClick={() => onSelect(api)} - className={`flex flex-col items-center gap-1.5 p-3 rounded-lg border transition-all cursor-pointer - ${ - isSelected - ? "border-blue-500 bg-blue-50 shadow-xs" - : "border-gray-200 hover:border-blue-300 hover:bg-gray-50" - }`} + className={cn( + "flex cursor-pointer flex-col items-center gap-1.5 rounded-lg border p-3 transition-all", + isSelected ? "border-primary bg-accent shadow-xs" : "border-border hover:bg-accent", + )} > {imgFailed ? ( - + {api.title.charAt(0)} ) : ( {api.title} handleImgError(api.name)} /> )} - {api.title} + {api.title} ); })}
-

+

Select an API to pre-fill the spec URL and OAuth 2.0 settings, or enter your own spec URL below.

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx new file mode 100644 index 00000000000..18a32a384c5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import TruePassthroughWarning from "./TruePassthroughWarning"; +import { AUTH_TYPE } from "@/components/mcp_tools/types"; + +describe("TruePassthroughWarning", () => { + it("warns when auth type is true_passthrough", () => { + render(); + + expect(screen.getByText("True Passthrough disables LiteLLM authentication for this server")).toBeInTheDocument(); + expect(screen.getByText(/Anyone who can reach the gateway can call this server/)).toBeInTheDocument(); + }); + + it("renders nothing for any other auth type", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when no auth type is set", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx index 9c57cbd7d14..b017fc4f1b4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { Alert } from "antd"; +import { TriangleAlert } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { AUTH_TYPE } from "@/components/mcp_tools/types"; /** @@ -10,12 +11,15 @@ import { AUTH_TYPE } from "@/components/mcp_tools/types"; export default function TruePassthroughWarning({ authType }: { authType?: string | null }) { if (authType !== AUTH_TYPE.TRUE_PASSTHROUGH) return null; return ( - + + + True Passthrough disables LiteLLM authentication for this server + + Anyone who can reach the gateway can call this server without a LiteLLM key. The caller's Authorization + header is forwarded to the upstream verbatim, per-key and per-team rate limits and spend tracking do not apply, + and the upstream is fully responsible for authenticating callers. Choose OAuth Delegate instead if callers + should still authenticate to LiteLLM. + + ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx index 469f372409c..cb02fedab66 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx @@ -1,7 +1,10 @@ import React from "react"; -import { Button, Spin, Alert, Collapse } from "antd"; -import { CheckCircleOutlined, ExclamationCircleOutlined, ReloadOutlined, ToolOutlined } from "@ant-design/icons"; -import { Card, Title, Text } from "@tremor/react"; +import { CircleCheck, CircleAlert, RefreshCw, Wrench, Info } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; interface MCPConnectionStatusProps { formValues: Record; @@ -31,27 +34,26 @@ const MCPConnectionStatus: React.FC = ({ } return ( - +
- - Connection Status + +

Connection Status

{!canFetchTools && (formValues.url || formValues.spec_path) && ( -
- - Complete required fields to test connection -
- Fill in URL, Transport, and Authentication to test MCP server connection +
+ +

Complete required fields to test connection

+

Fill in URL, Transport, and Authentication to test MCP server connection

)} {canFetchTools && (
-
+
- +

{isLoadingTools ? "Testing connection to MCP server..." : tools.length > 0 @@ -61,97 +63,84 @@ const MCPConnectionStatus: React.FC = ({ ? "Ready to submit" : "Connection failed" : "Ready to test connection"} - -
- Server: {formValues.url || formValues.spec_path} +

+

Server: {formValues.url || formValues.spec_path}

{isLoadingTools && ( -
- - Connecting... +
+ +

Connecting...

)} {!isLoadingTools && !toolsError && tools.length > 0 && ( -
- - Connected +
+ +

Connected

)} {toolsError && !isPreviewForbidden && ( -
- - Failed +
+ +

Failed

)}
{isLoadingTools && ( -
- - Testing connection and loading tools... +
+ +

Testing connection and loading tools...

)} {toolsError && isPreviewForbidden && ( - + + + Tool preview unavailable + {toolsError} + )} {toolsError && !isPreviewForbidden && ( - -
{toolsError}
- {toolsErrorStackTrace && ( - - {toolsErrorStackTrace} - - ), - }, - ]} - style={{ marginTop: "12px" }} + + + Connection Failed + +
{toolsError}
+ {toolsErrorStackTrace && ( + + + Stack Trace + + } /> - )} -
- } - type="error" - showIcon - action={ - - } - /> +
+ )} {!isLoadingTools && tools.length === 0 && !toolsError && ( -
- - Connection successful! -
- No tools found for this MCP server +
+ +

Connection successful!

+

No tools found for this MCP server

)}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx new file mode 100644 index 00000000000..4e2456ab7a4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx @@ -0,0 +1,118 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import MCPDiscovery from "./mcp_discovery"; +import { fetchDiscoverableMCPServers } from "@/components/networking"; +import type { DiscoverableMCPServer } from "@/components/mcp_tools/types"; + +vi.mock("@/components/networking", () => ({ + fetchDiscoverableMCPServers: vi.fn(), +})); + +const githubServer = { + name: "github", + title: "GitHub", + description: "Code hosting", + category: "Developer Tools", + icon_url: "", +} as DiscoverableMCPServer; + +const slackServer = { + name: "slack", + title: "Slack", + description: "Team chat", + category: "Communication", + icon_url: "", +} as DiscoverableMCPServer; + +const defaultProps = { + isVisible: true, + onClose: vi.fn(), + onSelectServer: vi.fn(), + onCustomServer: vi.fn(), + accessToken: "tok", +}; + +describe("MCPDiscovery", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({ + servers: [githubServer, slackServer], + categories: ["Developer Tools", "Communication"], + }); + }); + + // Each category name renders twice: once as a filter pill (a button) and once + // as the heading of its group. Only the heading is not a button. + const groupHeading = (category: string) => screen.getAllByText(category).filter((el) => el.tagName !== "BUTTON"); + + it("lists every discoverable server grouped under its category", async () => { + render(); + + expect(await screen.findByText("GitHub")).toBeInTheDocument(); + expect(screen.getByText("Slack")).toBeInTheDocument(); + expect(groupHeading("Developer Tools")).toHaveLength(1); + expect(groupHeading("Communication")).toHaveLength(1); + expect(screen.getByText("Add MCP Server")).toBeInTheDocument(); + }); + + it("filters the list down to the chosen category", async () => { + render(); + await screen.findByText("GitHub"); + + await userEvent.click(screen.getByRole("button", { name: "Communication" })); + + await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument()); + expect(screen.getByText("Slack")).toBeInTheDocument(); + }); + + it("filters the list by the search term", async () => { + render(); + await screen.findByText("GitHub"); + + await userEvent.type(screen.getByPlaceholderText("Search servers..."), "chat"); + + await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument()); + expect(screen.getByText("Slack")).toBeInTheDocument(); + }); + + it("hands the picked server back to the caller", async () => { + const onSelectServer = vi.fn(); + render(); + + await userEvent.click(await screen.findByText("GitHub")); + + expect(onSelectServer).toHaveBeenCalledWith(githubServer); + }); + + it("offers a custom-server escape hatch", async () => { + const onCustomServer = vi.fn(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "+ Custom Server" })); + + expect(onCustomServer).toHaveBeenCalled(); + }); + + it("surfaces a fetch failure", async () => { + vi.mocked(fetchDiscoverableMCPServers).mockRejectedValue(new Error("registry down")); + + render(); + + expect(await screen.findByText(/Failed to load servers: registry down/)).toBeInTheDocument(); + }); + + it("offers the custom-server link when nothing matches", async () => { + vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({ servers: [], categories: [] }); + + render(); + + expect(await screen.findByText(/No servers found/)).toBeInTheDocument(); + }); + + it("does not fetch while hidden", () => { + render(); + + expect(fetchDiscoverableMCPServers).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx index 6fcff011ba6..b2c181a9112 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx @@ -1,13 +1,15 @@ import React, { useState, useMemo, useEffect } from "react"; -import { Modal, Input, Typography } from "antd"; +import { Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/cva.config"; import { fetchDiscoverableMCPServers } from "@/components/networking"; import { DiscoverableMCPServer, DiscoverMCPServersResponse } from "@/components/mcp_tools/types"; import { mcpLogoImg } from "./create_mcp_server"; import { resolveLogoSrc } from "@/lib/assetPaths"; -const { Search } = Input; -const { Text } = Typography; - interface MCPDiscoveryProps { isVisible: boolean; onClose: () => void; @@ -16,12 +18,21 @@ interface MCPDiscoveryProps { accessToken: string | null; } -const INITIAL_COLORS = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]; +const INITIAL_COLORS = [ + "bg-blue-500", + "bg-emerald-500", + "bg-amber-500", + "bg-red-500", + "bg-violet-500", + "bg-pink-500", + "bg-cyan-500", + "bg-lime-500", +]; function getInitialAvatar(name: string) { const initial = name.charAt(0).toUpperCase(); const colorIndex = name.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0) % INITIAL_COLORS.length; - return { initial, backgroundColor: INITIAL_COLORS[colorIndex] }; + return { initial, backgroundClass: INITIAL_COLORS[colorIndex] }; } const MCPDiscovery: React.FC = ({ @@ -91,214 +102,126 @@ const MCPDiscovery: React.FC = ({ }, [filteredServers]); return ( - -
- MCP Logo -

Add MCP Server

-
- -
- } - open={isVisible} - onCancel={onClose} - footer={null} - width={1000} - className="top-8" - styles={{ - body: { padding: "24px", maxHeight: "70vh", overflowY: "auto" }, - header: { padding: "24px 24px 0 24px", border: "none" }, - }} - > - {/* Filter pills */} -
- {["All", ...categories].map((cat) => { - const isSelected = selectedCategory === cat; - return ( - - ); - })} -
- - {/* Search */} - setSearchQuery(e.target.value)} - style={{ marginBottom: 16 }} - allowClear - /> - - {/* Loading skeleton */} - {loading && ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
- ))} -
- )} - - {error && ( -
- Failed to load servers: {error} -
- )} - - {!loading && !error && filteredServers.length === 0 && ( -
- - No servers found.{" "} - - Add a custom server - - -
- )} - - {/* Server list grouped by category — 2 columns */} - {!loading && - !error && - Object.entries(groupedServers).map(([category, categoryServers]) => ( -
-
- {category} -
-
- {categoryServers.map((server) => { - const avatar = getInitialAvatar(server.title || server.name); - return ( -
onSelectServer(server)} - style={{ - display: "flex", - alignItems: "center", - padding: "8px 10px", - borderRadius: 6, - cursor: "pointer", - transition: "background 0.1s ease", - }} - onMouseEnter={(e) => { - e.currentTarget.style.background = "#f9fafb"; - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = "transparent"; - }} - > - {server.icon_url ? ( - {server.title} { - const target = e.currentTarget; - target.style.display = "none"; - const next = target.nextElementSibling as HTMLElement; - if (next) next.style.display = "flex"; - }} - /> - ) : null} -
- {avatar.initial} -
- - {server.title || server.name} - - -
- ); - })} + !open && onClose()}> + + +
+
+ MCP Logo + Add MCP Server
+
- ))} - +
+ +
+ {/* Filter pills */} +
+ {["All", ...categories].map((cat) => { + const isSelected = selectedCategory === cat; + return ( + + ); + })} +
+ + {/* Search */} + + + + + setSearchQuery(e.target.value)} + /> + + + {/* Loading skeleton */} + {loading && ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ )} + + {error && ( +
+

Failed to load servers: {error}

+
+ )} + + {!loading && !error && filteredServers.length === 0 && ( +
+

+ No servers found.{" "} + +

+
+ )} + + {/* Server list grouped by category — 2 columns */} + {!loading && + !error && + Object.entries(groupedServers).map(([category, categoryServers]) => ( +
+
+ {category} +
+
+ {categoryServers.map((server) => { + const avatar = getInitialAvatar(server.title || server.name); + return ( +
onSelectServer(server)} + className="flex cursor-pointer items-center rounded-md px-2.5 py-2 transition-colors hover:bg-accent" + > + {server.icon_url ? ( + {server.title} { + const target = e.currentTarget; + target.style.display = "none"; + const next = target.nextElementSibling as HTMLElement; + if (next) next.style.display = "flex"; + }} + /> + ) : null} +
+ {avatar.initial} +
+ {server.title || server.name} + +
+ ); + })} +
+
+ ))} +
+
+
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.test.tsx new file mode 100644 index 00000000000..a4547e4923f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import MCPServerCostConfig from "./mcp_server_cost_config"; + +const tools = [ + { name: "search", description: "Search the index" }, + { name: "fetch", description: "Fetch a document" }, +]; + +describe("MCPServerCostConfig", () => { + it("renders the default cost field with the current value", () => { + render(); + + expect(screen.getByText("Cost Configuration")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("0.0000")).toHaveValue("0.0200"); + }); + + it("reports the edited default cost as a number", async () => { + const onChange = vi.fn(); + render(); + + await userEvent.type(screen.getByPlaceholderText("0.0000"), "0.5"); + + expect(onChange).toHaveBeenLastCalledWith({ default_cost_per_query: 0.5 }); + }); + + it("disables the default cost field when disabled", () => { + render(); + + expect(screen.getByPlaceholderText("0.0000")).toBeDisabled(); + }); + + it("hides the per-tool section when the server exposes no tools", () => { + render(); + + expect(screen.queryByText("Available Tools")).not.toBeInTheDocument(); + }); + + it("offers a per-tool override for every tool once tools are loaded", async () => { + render(); + + await userEvent.click(screen.getByText("Available Tools")); + + expect(screen.getByText("search")).toBeInTheDocument(); + expect(screen.getByText("Search the index")).toBeInTheDocument(); + expect(screen.getByText("fetch")).toBeInTheDocument(); + expect(screen.getAllByPlaceholderText("Use default")).toHaveLength(2); + }); + + it("merges a per-tool override into the existing cost map", async () => { + const onChange = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByText("Available Tools")); + await userEvent.type(screen.getAllByPlaceholderText("Use default")[0], "3"); + + expect(onChange).toHaveBeenLastCalledWith({ + default_cost_per_query: 0.01, + tool_name_to_cost_per_query: { fetch: 0.2, search: 3 }, + }); + }); + + it("summarises the configured costs", () => { + render( + , + ); + + expect(screen.getByText("• Default cost: $0.0100 per query")).toBeInTheDocument(); + expect(screen.getByText("• search: $0.2500 per query")).toBeInTheDocument(); + }); + + it("shows no summary when nothing is configured", () => { + render(); + + expect(screen.queryByText("Cost Summary:")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx index 89c41693a4c..e0d44718ec5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx @@ -1,7 +1,10 @@ -import React from "react"; -import { Tooltip, InputNumber, Collapse, Badge } from "antd"; -import { InfoCircleOutlined, DollarOutlined, ToolOutlined } from "@ant-design/icons"; -import { Card, Title, Text } from "@tremor/react"; +import React, { useState } from "react"; +import { Info, DollarSign, Wrench } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { MCPServerCostInfo } from "@/components/mcp_tools/types"; interface MCPServerCostConfigProps { @@ -11,6 +14,47 @@ interface MCPServerCostConfigProps { disabled?: boolean; } +interface CostInputProps { + value: number | null | undefined; + placeholder: string; + disabled?: boolean; + className?: string; + onChange: (cost: number | null) => void; +} + +/** + * Costs are shown to four decimal places when idle, but the field keeps the raw + * keystrokes while it is being edited so partial input like "0." survives. + */ +const CostInput: React.FC = ({ value, placeholder, disabled, className, onChange }) => { + const [draft, setDraft] = useState(null); + const display = draft ?? (value === null || value === undefined ? "" : value.toFixed(4)); + + const handleChange = (next: string) => { + setDraft(next); + const parsed = Number(next); + onChange(next.trim() === "" || Number.isNaN(parsed) ? null : parsed); + }; + + return ( + + + $ + + setDraft(value === null || value === undefined ? "" : String(value))} + onBlur={() => setDraft(null)} + onChange={(e) => handleChange(e.target.value)} + /> + + ); +}; + const MCPServerCostConfig: React.FC = ({ value = {}, onChange, @@ -37,124 +81,126 @@ const MCPServerCostConfig: React.FC = ({ }; return ( - -
-
- - Cost Configuration - - - -
- -
-
- - - - Set a default cost for all tool calls to this server - + + +
+
+ +

Cost Configuration

+ + } + /> + + Configure costs for this MCP server's tool calls. Set a default rate and per-tool overrides. + +
- {tools.length > 0 && ( -
-
+ + {(value.default_cost_per_query || + (value.tool_name_to_cost_per_query && Object.keys(value.tool_name_to_cost_per_query).length > 0)) && ( +
+

Cost Summary:

+
+ {value.default_cost_per_query && ( +

+ • Default cost: ${value.default_cost_per_query.toFixed(4)} per query +

+ )} + {value.tool_name_to_cost_per_query && + Object.entries(value.tool_name_to_cost_per_query).map( + ([toolName, cost]) => + cost !== null && + cost !== undefined && ( +

+ • {toolName}: ${cost.toFixed(4)} per query +

+ ), + )} +
)}
- - {(value.default_cost_per_query || - (value.tool_name_to_cost_per_query && Object.keys(value.tool_name_to_cost_per_query).length > 0)) && ( -
- Cost Summary: -
- {value.default_cost_per_query && ( - - • Default cost: ${value.default_cost_per_query.toFixed(4)} per query - - )} - {value.tool_name_to_cost_per_query && - Object.entries(value.tool_name_to_cost_per_query).map( - ([toolName, cost]) => - cost !== null && - cost !== undefined && ( - - • {toolName}: ${cost.toFixed(4)} per query - - ), - )} -
-
- )} -
- + + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx new file mode 100644 index 00000000000..466341405c8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import MCPServerCostDisplay from "./mcp_server_cost_display"; + +describe("MCPServerCostDisplay", () => { + it("explains that calls are free when no cost config exists", () => { + render(); + + expect( + screen.getByText("No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call."), + ).toBeInTheDocument(); + }); + + it("treats a config with only a null default cost as unconfigured", () => { + render(); + + expect(screen.getByText(/No cost configuration set for this server/)).toBeInTheDocument(); + }); + + it("shows a zero default cost rather than falling back to the empty state", () => { + render(); + + expect(screen.getByText("Default Cost per Query")).toBeInTheDocument(); + expect(screen.getByText("$0.0000")).toBeInTheDocument(); + }); + + it("renders the default cost to four decimal places and summarises it", () => { + render(); + + expect(screen.getByText("$0.0125")).toBeInTheDocument(); + expect(screen.getByText("• Default cost: $0.0125 per query")).toBeInTheDocument(); + }); + + it("lists each tool-specific cost and counts them in the summary", () => { + render( + , + ); + + expect(screen.getByText("search")).toBeInTheDocument(); + expect(screen.getByText("$0.5000 per query")).toBeInTheDocument(); + expect(screen.getByText("fetch")).toBeInTheDocument(); + expect(screen.getByText("$0.2500 per query")).toBeInTheDocument(); + expect(screen.queryByText("skipped")).not.toBeInTheDocument(); + expect(screen.getByText("• 3 tool(s) with custom pricing")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx index f26f7ba2320..30fd269447e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { Text } from "@tremor/react"; import { MCPServerCostInfo } from "@/components/mcp_tools/types"; interface MCPServerCostDisplayProps { @@ -15,12 +14,12 @@ const MCPServerCostDisplay: React.FC = ({ costConfig if (!hasCostConfig) { return ( -
+
-
- +
+

No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call. - +

@@ -28,28 +27,28 @@ const MCPServerCostDisplay: React.FC = ({ costConfig } return ( -
+
{hasDefaultCost && costConfig?.default_cost_per_query !== undefined && costConfig?.default_cost_per_query !== null && (
- Default Cost per Query -
${costConfig.default_cost_per_query.toFixed(4)}
+

Default Cost per Query

+
${costConfig.default_cost_per_query.toFixed(4)}
)} {hasToolCosts && costConfig?.tool_name_to_cost_per_query && (
- Tool-Specific Costs +

Tool-Specific Costs

{Object.entries(costConfig.tool_name_to_cost_per_query).map( ([toolName, cost]) => cost !== null && cost !== undefined && ( -
- {toolName} - ${cost.toFixed(4)} per query +
+

{toolName}

+

${cost.toFixed(4)} per query

), )} @@ -57,20 +56,20 @@ const MCPServerCostDisplay: React.FC = ({ costConfig
)} -
- Cost Summary: +
+

Cost Summary:

{hasDefaultCost && costConfig?.default_cost_per_query !== undefined && costConfig?.default_cost_per_query !== null && ( - +

• Default cost: ${costConfig.default_cost_per_query.toFixed(4)} per query - +

)} {hasToolCosts && costConfig?.tool_name_to_cost_per_query && ( - +

• {Object.keys(costConfig.tool_name_to_cost_per_query).length} tool(s) with custom pricing - +

)}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx new file mode 100644 index 00000000000..02d168bf7f4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx @@ -0,0 +1,152 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MCPServerView } from "./mcp_server_view"; +import type { MCPServer } from "@/components/mcp_tools/types"; + +vi.mock(".", () => ({ + MCPToolsViewer: () =>
tools viewer
, +})); + +vi.mock("./mcp_server_edit", () => ({ + default: () =>
edit form
, + EDIT_OAUTH_UI_STATE_KEY: "litellm-mcp-oauth-edit-state", +})); + +const baseServer = { + server_id: "srv-1", + server_name: "demo server", + alias: "demo_alias", + description: "A demo MCP server", + transport: "http", + url: "https://example.com/mcp", + auth_type: "api_key", +} as MCPServer; + +const renderView = (overrides: Partial = {}, props: Record = {}) => + render( + , + ); + +describe("MCPServerView", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Name, alias and description each label the header and a Settings row, so + // only the server id is unique to the header. + it("shows the server identity in the header", () => { + renderView(); + + expect(screen.getByText("srv-1")).toBeInTheDocument(); + expect(screen.getAllByText("demo server").length).toBeGreaterThan(0); + expect(screen.getAllByText("A demo MCP server").length).toBeGreaterThan(0); + expect(screen.getAllByText("demo_alias").length).toBeGreaterThan(0); + }); + + it("falls back to a placeholder name when the server has neither name nor alias", () => { + renderView({ server_name: undefined, alias: undefined }); + + expect(screen.getByText("Unnamed Server")).toBeInTheDocument(); + }); + + // "Transport" and "Authentication" label both an Overview card and a Settings + // row, so only Overview-exclusive labels identify the Overview panel. + it("summarises the connection on the Overview tab", () => { + renderView(); + + expect(screen.getByText("Host URL")).toBeInTheDocument(); + expect(screen.getByText("Cost Configuration")).toBeInTheDocument(); + expect(screen.getAllByText("HTTP").length).toBeGreaterThan(0); + expect(screen.getAllByText("https://example.com/mcp").length).toBeGreaterThan(0); + }); + + it("offers a Settings tab to proxy admins only", () => { + renderView(); + expect(screen.getByRole("tab", { name: "Settings" })).toBeInTheDocument(); + }); + + it("hides the Settings tab from non-admins", () => { + renderView({}, { isProxyAdmin: false }); + expect(screen.queryByRole("tab", { name: "Settings" })).not.toBeInTheDocument(); + }); + + it("opens the tools viewer on the MCP Tools tab", async () => { + renderView(); + + await userEvent.click(screen.getByRole("tab", { name: "MCP Tools" })); + + expect(await screen.findByText("tools viewer")).toBeInTheDocument(); + }); + + it("shows the read-only settings summary before editing", async () => { + renderView({ allow_all_keys: true, available_on_public_internet: false }); + + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("MCP Server Settings")).toBeInTheDocument(); + expect(screen.getByText("Allow All Keys")).toBeInTheDocument(); + expect(screen.getByText("Enabled")).toBeInTheDocument(); + expect(screen.getByText("Internal only")).toBeInTheDocument(); + expect(screen.queryByText("edit form")).not.toBeInTheDocument(); + }); + + it("swaps in the edit form when Edit Settings is pressed", async () => { + renderView(); + + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + await userEvent.click(await screen.findByRole("button", { name: "Edit Settings" })); + + expect(await screen.findByText("edit form")).toBeInTheDocument(); + }); + + it("opens straight into the edit form when isEditing is set", async () => { + renderView({}, { isEditing: true }); + + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("edit form")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument(); + }); + + it("opens on the tab named by initialTabIndex", async () => { + renderView({}, { initialTabIndex: 1 }); + + expect(await screen.findByText("tools viewer")).toBeInTheDocument(); + }); + + it("returns to the server list when Back is pressed", async () => { + const onBack = vi.fn(); + renderView({}, { onBack }); + + await userEvent.click(screen.getByRole("button", { name: /Back to All Servers/ })); + + expect(onBack).toHaveBeenCalled(); + }); + + it("lists the allowed tools, or says all tools are enabled", async () => { + renderView({ allowed_tools: ["search", "fetch"] }); + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("search")).toBeInTheDocument(); + expect(screen.getByText("fetch")).toBeInTheDocument(); + expect(screen.queryByText("All tools enabled")).not.toBeInTheDocument(); + }); + + it("says all tools are enabled when no allowlist is stored", async () => { + renderView({ allowed_tools: [] }); + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("All tools enabled")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx index 49df8206c8f..736327409d6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx @@ -1,6 +1,9 @@ import React, { useState } from "react"; -import { ArrowLeftIcon, EyeIcon, EyeOffIcon } from "@heroicons/react/outline"; -import { Title, Card, Button, Text, Grid, TabGroup, TabList, TabPanel, TabPanels, Tab, Icon } from "@tremor/react"; +import { ArrowLeft, Eye, EyeOff } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { MCPServer, handleTransport, handleAuth } from "@/components/mcp_tools/types"; // TODO: Move Tools viewer from index file @@ -11,7 +14,6 @@ import MCPServerCostDisplay from "./mcp_server_cost_display"; import { getMaskedAndFullUrl } from "./utils"; import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; import { CheckIcon, CopyIcon } from "lucide-react"; -import { Button as AntdButton } from "antd"; interface MCPServerViewProps { mcpServer: MCPServer; @@ -86,335 +88,306 @@ export const MCPServerView: React.FC = ({ } }; - const getTransportBadge = (transport: string) => { - const label = transport.toUpperCase(); - return ( - - {label} - - ); - }; + const getTransportBadge = (transport: string) => {transport.toUpperCase()}; - const getAuthBadge = (authType: string) => { - return ( - - {authType} - - ); - }; + const getAuthBadge = (authType: string) => {authType}; return ( -
+
-
- {mcpServer.server_name || mcpServer.alias || "Unnamed Server"} - : } +

{mcpServer.server_name || mcpServer.alias || "Unnamed Server"}

+ {mcpServer.alias && mcpServer.server_name && mcpServer.alias !== mcpServer.server_name && ( - + {mcpServer.alias} - + )}
-
- {mcpServer.server_id} - : } +
+

{mcpServer.server_id}

+
- {mcpServer.description && {mcpServer.description}} + {mcpServer.description &&

{mcpServer.description}

}
- {/* TODO: magic number for index */} - - - {[ - Overview, - MCP Tools, - ...(isProxyAdmin ? [Settings] : []), - ]} - + setSelectedTabIndex(Number(v))}> + + + Overview + + + MCP Tools + + {isProxyAdmin && ( + + Settings + + )} + - - {/* Overview Panel */} - - - - Transport -
- {getTransportBadge( - handleTransport(mcpServer.transport ?? undefined, mcpServer.spec_path ?? undefined), - )} -
-
- - - Authentication -
{getAuthBadge(handleAuth(mcpServer.auth_type ?? undefined))}
-
- - - Host URL -
- - {renderUrlWithToggle(mcpServer.url, showFullUrl)} - - {/* Only proxy admins may reveal the raw URL — non-admins - receive a sanitized server object from the backend - with `url=null`, but hide the toggle anyway as - defense-in-depth in case the URL ever leaks back - into the response. */} - {hasToken && isProxyAdmin && ( - - )} -
-
-
- - Cost Configuration + {/* Overview Panel */} + +
+ +

Transport

- + {getTransportBadge(handleTransport(mcpServer.transport ?? undefined, mcpServer.spec_path ?? undefined))}
- - {/* Tool Panel */} - - - + +

Authentication

+
{getAuthBadge(handleAuth(mcpServer.auth_type ?? undefined))}
+
- {/* Settings Panel */} - - -
- MCP Server Settings - {editing ? null : ( - )}
- {editing ? ( - setEditing(false)} - onSuccess={handleSuccess} - availableAccessGroups={availableAccessGroups} - /> - ) : ( -
-
- Server Name -
- {mcpServer.server_name || } -
+ +
+ +

Cost Configuration

+
+ +
+
+ + + {/* Tool Panel */} + + + + + {/* Settings Panel */} + + +
+

MCP Server Settings

+ {editing ? null : ( + + )} +
+ {editing ? ( + setEditing(false)} + onSuccess={handleSuccess} + availableAccessGroups={availableAccessGroups} + /> + ) : ( +
+
+

Server Name

+
+ {mcpServer.server_name || }
-
- Alias -
- {mcpServer.alias || } -
+
+
+

Alias

+
+ {mcpServer.alias || }
-
- Description -
- {mcpServer.description || } -
+
+
+

Description

+
+ {mcpServer.description || }
-
- URL -
- {renderUrlWithToggle(mcpServer.url, showFullUrl)} - {hasToken && ( - - )} -
+
+
+

URL

+
+ {renderUrlWithToggle(mcpServer.url, showFullUrl)} + {hasToken && ( + + )}
-
- Transport +
+
+

Transport

+
+ {getTransportBadge(handleTransport(mcpServer.transport, mcpServer.spec_path))} +
+
+
+

Authentication

+
{getAuthBadge(handleAuth(mcpServer.auth_type))}
+
+
+

Extra Headers

+
+ {mcpServer.extra_headers && mcpServer.extra_headers.length > 0 ? ( + mcpServer.extra_headers.join(", ") + ) : ( + + )} +
+
+
+

Allow All Keys

+
+ {mcpServer.allow_all_keys ? ( + + + Enabled + + ) : ( + Disabled + )} +
+
+
+

Network Access

+
+ {mcpServer.available_on_public_internet ? ( + + + Public + + ) : ( + + + Internal only + + )} +
+
+ {handleAuth(mcpServer.auth_type) === "oauth2" && ( +
+

Delegate Auth to Upstream

- {getTransportBadge(handleTransport(mcpServer.transport, mcpServer.spec_path))} -
-
-
- Authentication -
{getAuthBadge(handleAuth(mcpServer.auth_type))}
-
-
- Extra Headers -
- {mcpServer.extra_headers && mcpServer.extra_headers.length > 0 ? ( - mcpServer.extra_headers.join(", ") + {mcpServer.delegate_auth_to_upstream ? ( + + + Enabled (PKCE passthrough) + ) : ( - + Disabled )}
-
- Allow All Keys -
- {mcpServer.allow_all_keys ? ( - - - Enabled - - ) : ( - - Disabled - - )} -
-
-
- Network Access -
- {mcpServer.available_on_public_internet ? ( - - - Public - - ) : ( - - - Internal only - - )} -
-
- {handleAuth(mcpServer.auth_type) === "oauth2" && ( -
- Delegate Auth to Upstream + )} + {handleAuth(mcpServer.auth_type) !== "oauth2" && + Array.isArray(mcpServer.extra_headers) && + mcpServer.extra_headers.some((h) => typeof h === "string" && h.toLowerCase() === "authorization") && ( +
+

OAuth Pass-through

- {mcpServer.delegate_auth_to_upstream ? ( - - - Enabled (PKCE passthrough) - + {mcpServer.oauth_passthrough ? ( + + + Enabled + ) : ( - - Disabled - + Disabled )}
)} - {handleAuth(mcpServer.auth_type) !== "oauth2" && - Array.isArray(mcpServer.extra_headers) && - mcpServer.extra_headers.some( - (h) => typeof h === "string" && h.toLowerCase() === "authorization", - ) && ( -
- OAuth Pass-through -
- {mcpServer.oauth_passthrough ? ( - - - Enabled - - ) : ( - - Disabled - - )} -
+
+

Access Groups

+
+ {mcpServer.mcp_access_groups && mcpServer.mcp_access_groups.length > 0 ? ( +
+ {mcpServer.mcp_access_groups.map((group: any, index: number) => ( + + {typeof group === "string" ? group : group?.name ?? ""} + + ))}
+ ) : ( + )} -
- Access Groups -
- {mcpServer.mcp_access_groups && mcpServer.mcp_access_groups.length > 0 ? ( -
- {mcpServer.mcp_access_groups.map((group: any, index: number) => ( - - {typeof group === "string" ? group : group?.name ?? ""} - - ))} -
- ) : ( - - )} -
-
-
- Allowed Tools -
- {mcpServer.allowed_tools && mcpServer.allowed_tools.length > 0 ? ( -
- {mcpServer.allowed_tools.map((tool: string, index: number) => ( - - {tool} - - ))} -
- ) : ( - - All tools enabled - - )} -
-
-
- Cost -
- -
- )} - - - - +
+

Allowed Tools

+
+ {mcpServer.allowed_tools && mcpServer.allowed_tools.length > 0 ? ( +
+ {mcpServer.allowed_tools.map((tool: string, index: number) => ( + + {tool} + + ))} +
+ ) : ( + All tools enabled + )} +
+
+
+

Cost

+
+ +
+
+
+ )} + + +
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx index d61bc23c757..f9f3d20ca15 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { render, waitFor, screen, fireEvent, act } from "@testing-library/react"; +import { render, waitFor, screen, act, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import MCPServers from "./mcp_servers"; @@ -307,36 +308,15 @@ describe("MCPServers", () => { expect(screen.getByText("Team B Server")).toBeInTheDocument(); expect(screen.getByText("Team A Server 2")).toBeInTheDocument(); - // Find the team select dropdown by looking for the "Team" label + // Find the team select by its "Team" label, then the combobox it labels const teamLabel = screen.getByText("Team"); - const teamSelectContainer = teamLabel.closest("div")?.querySelector(".ant-select"); - expect(teamSelectContainer).toBeTruthy(); + const teamSelect = within(teamLabel.parentElement!).getByRole("combobox"); - // Open the dropdown by clicking on the selector - const selectSelector = teamSelectContainer?.querySelector(".ant-select-selector"); - expect(selectSelector).toBeTruthy(); + await userEvent.click(teamSelect); - act(() => { - fireEvent.mouseDown(selectSelector!); - }); - - // Wait for dropdown to open - await waitFor( - () => { - const dropdownOptions = document.querySelectorAll(".ant-select-item-option"); - expect(dropdownOptions.length).toBeGreaterThan(0); - }, - { timeout: 5000 }, - ); - - // Find and click on "Team A" option - const dropdownOptions = document.querySelectorAll(".ant-select-item-option"); - const teamAOption = Array.from(dropdownOptions).find((option) => option.textContent?.includes("Team A")); - expect(teamAOption).toBeTruthy(); - - act(() => { - fireEvent.click(teamAOption!); - }); + // Pick the "Team A" option once the listbox opens + const teamAOption = await screen.findByText("Team A"); + await userEvent.click(teamAOption); // Wait for filtering to complete await waitFor(() => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx index f186fef22da..79bc6a9bb37 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx @@ -1,8 +1,21 @@ import { isAdminRole } from "@/utils/roles"; -import { QuestionCircleOutlined, SearchOutlined } from "@ant-design/icons"; -import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react"; +import { CircleHelp, Search } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import NewBadge from "@/components/common_components/NewBadge"; -import { Descriptions, Empty, Input, Modal, Select, Spin, Tooltip, Typography } from "antd"; import React, { useEffect, useState, useMemo, useCallback } from "react"; import { useQuery } from "@tanstack/react-query"; import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; @@ -75,7 +88,6 @@ const compareServers = (a: MCPServer, b: MCPServer, sort: SortKey): number => { } }; -const { Text: AntdText, Title: AntdTitle } = Typography; const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state"; // Server id stashed by the Tools tab before an OBO OAuth redirect, read once at @@ -95,8 +107,6 @@ const readToolsOAuthServerId = (): string | null => { } }; -const { Option } = Select; - const MCPServers: React.FC = ({ accessToken, userRole, userID }) => { const { data: mcpServers, isLoading: isLoadingServers, refetch } = useMCPServers(); @@ -240,6 +250,15 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) }, [serversWithHealth]); // Get unique MCP access groups from all servers + const teamSelectItems = React.useMemo( + () => ({ + all: isInternalUser ? "All Available Servers" : "All Servers", + personal: "Personal", + ...Object.fromEntries(uniqueTeams.map((team) => [team.team_id, team.team_alias || team.team_id])), + }), + [isInternalUser, uniqueTeams], + ); + const uniqueMcpAccessGroups = React.useMemo(() => { if (!serversWithHealth) return []; return Array.from( @@ -251,6 +270,14 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) ); }, [serversWithHealth]); + const accessGroupSelectItems = React.useMemo( + () => ({ + all: "All Access Groups", + ...Object.fromEntries(uniqueMcpAccessGroups.map((group) => [group, group])), + }), + [uniqueMcpAccessGroups], + ); + // Filtering logic for both team and access group const filterServers = useCallback( (teamId: string, group: string) => { @@ -390,131 +417,135 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) } return ( -
- -
- - This action is permanent and cannot be undone. All associated configurations will be removed. - + +
+ !open && cancelDelete()}> + + + Delete MCP Server? + +
+

+ This action is permanent and cannot be undone. All associated configurations will be removed. +

- {serverToDelete && ( -
- - {serverToDelete.server_name && ( - Name}> - - {serverToDelete.server_name} - - - )} - ID}> - - {serverToDelete.server_id} - - - {serverToDelete.url && ( - URL}> - - {serverToDelete.url} - - - )} - + {serverToDelete && ( +
+ {serverToDelete.server_name && ( +
+
Name
+
{serverToDelete.server_name}
+
+ )} +
+
ID
+
{serverToDelete.server_id}
+
+ {serverToDelete.url && ( +
+
URL
+
{serverToDelete.url}
+
+ )} +
+ )}
- )} -
- - { - setModalVisible(false); - setPrefillData(null); - setDiscoveryVisible(true); - }} - /> -
-
-
- MCP Servers - {filteredServers.length > 0 && ( - - {filteredServers.length} - + + Cancel + + + + + { + setModalVisible(false); + setPrefillData(null); + setDiscoveryVisible(true); + }} + /> +
+
+
+

MCP Servers

+ {filteredServers.length > 0 && {filteredServers.length}} +
+

Configure and manage your MCP servers

+
+
+ {isAdminRole(userRole) && ( + + )} + {!isAdminRole(userRole) && ( + )}
- Configure and manage your MCP servers
-
- {isAdminRole(userRole) && ( - - )} - {!isAdminRole(userRole) && ( - - )} -
-
- setDiscoveryVisible(false)} - onSelectServer={(server: DiscoverableMCPServer) => { - setPrefillData(server); - setDiscoveryVisible(false); - setModalVisible(true); - }} - onCustomServer={() => { - setPrefillData(null); - setDiscoveryVisible(false); - setModalVisible(true); - }} - accessToken={accessToken} - /> - - -
- All Servers - Toolsets - Connect - {isAdminRole(userRole) && Semantic Filter} - {isAdminRole(userRole) && Network Settings} + setDiscoveryVisible(false)} + onSelectServer={(server: DiscoverableMCPServer) => { + setPrefillData(server); + setDiscoveryVisible(false); + setModalVisible(true); + }} + onCustomServer={() => { + setPrefillData(null); + setDiscoveryVisible(false); + setModalVisible(true); + }} + accessToken={accessToken} + /> + + + + All Servers + + + Toolsets + + + Connect + {isAdminRole(userRole) && ( - + + Semantic Filter + + )} + {isAdminRole(userRole) && ( + + Network Settings + + )} + {isAdminRole(userRole) && ( + Submitted MCPs - + )} -
-
- - + + {selectedServerId ? ( = ({ accessToken, userRole, userID })
-
+
- Team - handleTeamChange(v ?? "all")} + > + + + + + {isInternalUser ? "All Available Servers" : "All Servers"} - - - - {uniqueTeams.map((team) => ( - - ))} + + Personal + {uniqueTeams.map((team) => ( + + {team.team_alias || team.team_id} + + ))} +
-
+
- +

Access Group - - + + + } + /> + + An MCP Access Group is a set of users or teams that have permission to access specific MCP + servers. Use access groups to control and organize who can connect to which servers. + - +

- } - placeholder="Search by name, alias, URL, or ID" - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - style={{ maxWidth: 320 }} - /> + + + + + setSearchQuery(e.target.value)} + /> +
- Sort +

Sort

-
+
{displayedServers.length} of {filteredServers.length} servers
{isLoadingServers ? ( -
- +
+ +

Loading MCP servers...

) : displayedServers.length === 0 ? ( -
- +
+

+ {filteredServers.length === 0 + ? "No MCP servers configured. Click '+ Add New MCP Server' to get started." + : "No servers match the current filters or search."} +

) : (
= ({ accessToken, userRole, userID })
)} - - + + - - + + - + {isAdminRole(userRole) && ( - + - + )} {isAdminRole(userRole) && ( - + - + )} {isAdminRole(userRole) && ( - + - + )} - - + - {byokModalServer && ( - setByokModalServer(null)} - onSuccess={(_serverId) => { - refetch(); - setByokModalServer(null); + {byokModalServer && ( + setByokModalServer(null)} + onSuccess={(_serverId) => { + refetch(); + setByokModalServer(null); + }} + /> + )} + + {/* Per-user env-var fill modal — backed by /v1/mcp/server/{id}/user-env-vars */} + { + setEnvVarsModalServer(null); + setDeepLinkServerId(null); + }} + onSaved={() => { + // Refresh the bulk status so the red "N user fields missing" footer + // on each card clears once the user has filled in their values. + refetchEnvVarStatus(); }} /> - )} - - {/* Per-user env-var fill modal — backed by /v1/mcp/server/{id}/user-env-vars */} - { - setEnvVarsModalServer(null); - setDeepLinkServerId(null); - }} - onSaved={() => { - // Refresh the bulk status so the red "N user fields missing" footer - // on each card clears once the user has filled in their values. - refetchEnvVarStatus(); - }} - /> -
+
+ ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx index 60c4c264c3c..274bdf63e32 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx @@ -1,7 +1,14 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Card, Title, Text } from "@tremor/react"; -import { ToolOutlined, CheckCircleOutlined, SearchOutlined, EditOutlined } from "@ant-design/icons"; -import { Badge, Spin, Checkbox, Input, Radio } from "antd"; +import { Wrench, CircleCheck, Search, Pencil } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { Textarea } from "@/components/ui/textarea"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { cn } from "@/lib/cva.config"; import McpCrudPermissionPanel from "@/components/mcp_tools/McpCrudPermissionPanel"; import { TOOL_DISPLAY_NAME_PATTERN } from "./utils"; @@ -67,86 +74,74 @@ const ToolRow: React.FC = ({ return (
-
onToggle(tool.name)}> +
onToggle(tool.name)}>
- onToggle(tool.name)} /> + onToggle(tool.name)} />
- {toolNameToDisplayName[tool.name] || tool.name} - - {isEnabled ? "Enabled" : "Disabled"} - - {toolNameToDisplayName[tool.name] && ( - - Custom name - - )} +

{toolNameToDisplayName[tool.name] || tool.name}

+ {isEnabled ? "Enabled" : "Disabled"} + {toolNameToDisplayName[tool.name] && Custom name}
{(toolNameToDescription[tool.name] || tool.description) && ( - +

{toolNameToDescription[tool.name] || tool.description} - +

)} - +

{isEnabled ? "✓ Users can call this tool" : "✗ Users cannot call this tool"} - +

- + +
{isEditExpanded && (
e.stopPropagation()} >
- Display Name +

Display Name

onDisplayNameChange(tool.name, e.target.value)} - status={isDisplayNameInvalid ? "error" : undefined} + aria-invalid={isDisplayNameInvalid || undefined} /> {isDisplayNameInvalid ? ( - +

Only letters, digits, underscores, and hyphens are allowed (no spaces). - +

) : ( - +

Override how this tool's name appears to users. Leave blank to use original. - +

)}
- Description - Description

+