From 8d945c86b7ea67bfea6c8d556f7f1109b9dd3154 Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Thu, 9 Apr 2026 06:22:03 +0200 Subject: [PATCH 01/95] fix(proxy): set key_alias=user_id in JWT auth for Prometheus metrics (#25340) --- litellm/proxy/auth/user_api_key_auth.py | 2 + .../proxy/auth/test_handle_jwt.py | 181 ++++++++++++++++++ 2 files changed, 183 insertions(+) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 61c618eeb18..ffca4d533be 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -807,6 +807,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 api_key=None, user_role=LitellmUserRoles.PROXY_ADMIN, user_id=user_id, + key_alias=user_id, team_id=team_id, team_alias=( team_object.team_alias @@ -826,6 +827,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 valid_token = UserAPIKeyAuth( api_key=None, + key_alias=user_id, team_id=team_id, team_alias=( team_object.team_alias if team_object is not None else None diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py index 5303da6fbcf..bd9fb517cdf 100644 --- a/tests/test_litellm/proxy/auth/test_handle_jwt.py +++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py @@ -2029,3 +2029,184 @@ async def test_find_and_validate_specific_team_id_no_hint_for_valid_field(): error_msg = str(exc_info.value) assert "Hint" not in error_msg + + +@pytest.mark.asyncio +async def test_jwt_auth_sets_key_alias_to_user_id_admin(): + """ + Verify that JWT standard auth populates key_alias with user_id + on the admin path so Prometheus api_key_alias label is non-empty. + """ + import json + + from starlette.datastructures import URL + + import litellm + import litellm.proxy.proxy_server + from litellm.proxy.auth.user_api_key_auth import _user_api_key_auth_builder + from litellm.proxy.utils import ProxyLogging + from litellm.caching.dual_cache import DualCache + + proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache()) + + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth() + + # Wire proxy server globals + setattr(litellm.proxy.proxy_server, "premium_user", True) + setattr(litellm.proxy.proxy_server, "general_settings", {"enable_jwt_auth": True}) + setattr(litellm.proxy.proxy_server, "jwt_handler", jwt_handler) + setattr(litellm.proxy.proxy_server, "prisma_client", None) + setattr(litellm.proxy.proxy_server, "master_key", None) + setattr(litellm.proxy.proxy_server, "llm_router", None) + setattr(litellm.proxy.proxy_server, "llm_model_list", None) + setattr(litellm.proxy.proxy_server, "proxy_logging_obj", proxy_logging_obj) + setattr(litellm.proxy.proxy_server, "open_telemetry_logger", None) + setattr(litellm.proxy.proxy_server, "model_max_budget_limiter", None) + setattr(litellm.proxy.proxy_server, "litellm_proxy_admin_name", "admin") + + auth_builder_result = { + "is_proxy_admin": True, + "team_id": "team_123", + "team_object": LiteLLM_TeamTable(team_id="team_123"), + "user_id": "test_user_1", + "user_object": LiteLLM_UserTable( + user_id="test_user_1", user_role=LitellmUserRoles.PROXY_ADMIN + ), + "end_user_id": None, + "end_user_object": None, + "org_id": None, + "token": "fake_jwt_token", + "team_membership": None, + "jwt_claims": {"sub": "test_user_1"}, + } + + from fastapi import Request + + request = Request(scope={"type": "http", "headers": []}) + request._url = URL(url="/chat/completions") + + async def return_body(): + return json.dumps({"model": "gpt-4"}).encode("utf-8") + + request.body = return_body + + with patch.object( + jwt_handler, "is_jwt", return_value=True + ), patch.object( + JWTAuthManager, + "auth_builder", + new_callable=AsyncMock, + return_value=auth_builder_result, + ), patch( + "litellm.proxy.auth.user_api_key_auth.get_global_proxy_spend", + new_callable=AsyncMock, + return_value=0.0, + ): + result = await _user_api_key_auth_builder( + request=request, + api_key="Bearer fake_jwt_token", + azure_api_key_header="", + anthropic_api_key_header=None, + google_ai_studio_api_key_header=None, + azure_apim_header=None, + request_data={"model": "gpt-4"}, + ) + + assert result.key_alias == "test_user_1" + assert result.user_id == "test_user_1" + assert result.user_role == LitellmUserRoles.PROXY_ADMIN + + +@pytest.mark.asyncio +async def test_jwt_auth_sets_key_alias_to_user_id_non_admin(): + """ + Verify that JWT standard auth populates key_alias with user_id + on the non-admin path so Prometheus api_key_alias label is non-empty. + """ + import json + + from starlette.datastructures import URL + + import litellm + import litellm.proxy.proxy_server + from litellm.proxy.auth.user_api_key_auth import _user_api_key_auth_builder + from litellm.proxy.utils import ProxyLogging + from litellm.caching.dual_cache import DualCache + + proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache()) + + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth() + + # Wire proxy server globals + setattr(litellm.proxy.proxy_server, "premium_user", True) + setattr(litellm.proxy.proxy_server, "general_settings", {"enable_jwt_auth": True}) + setattr(litellm.proxy.proxy_server, "jwt_handler", jwt_handler) + setattr(litellm.proxy.proxy_server, "prisma_client", None) + setattr(litellm.proxy.proxy_server, "master_key", None) + setattr(litellm.proxy.proxy_server, "llm_router", None) + setattr(litellm.proxy.proxy_server, "llm_model_list", None) + setattr(litellm.proxy.proxy_server, "proxy_logging_obj", proxy_logging_obj) + setattr(litellm.proxy.proxy_server, "open_telemetry_logger", None) + setattr(litellm.proxy.proxy_server, "model_max_budget_limiter", None) + setattr(litellm.proxy.proxy_server, "litellm_proxy_admin_name", "admin") + + team_object = LiteLLM_TeamTable(team_id="team_123") + user_object = LiteLLM_UserTable( + user_id="test_user_1", user_role=LitellmUserRoles.INTERNAL_USER + ) + + auth_builder_result = { + "is_proxy_admin": False, + "team_id": "team_123", + "team_object": team_object, + "user_id": "test_user_1", + "user_object": user_object, + "end_user_id": None, + "end_user_object": None, + "org_id": None, + "token": "fake_jwt_token", + "team_membership": None, + "jwt_claims": {"sub": "test_user_1"}, + } + + from fastapi import Request + + request = Request(scope={"type": "http", "headers": []}) + request._url = URL(url="/chat/completions") + + async def return_body(): + return json.dumps({"model": "gpt-4"}).encode("utf-8") + + request.body = return_body + + with patch.object( + jwt_handler, "is_jwt", return_value=True + ), patch.object( + JWTAuthManager, + "auth_builder", + new_callable=AsyncMock, + return_value=auth_builder_result, + ), patch( + "litellm.proxy.auth.user_api_key_auth.get_global_proxy_spend", + new_callable=AsyncMock, + return_value=0.0, + ), patch( + "litellm.proxy.auth.user_api_key_auth.common_checks", + new_callable=AsyncMock, + return_value=True, + ): + result = await _user_api_key_auth_builder( + request=request, + api_key="Bearer fake_jwt_token", + azure_api_key_header="", + anthropic_api_key_header=None, + google_ai_studio_api_key_header=None, + azure_apim_header=None, + request_data={"model": "gpt-4"}, + ) + + assert result.key_alias == "test_user_1" + assert result.user_id == "test_user_1" + assert result.user_role == LitellmUserRoles.INTERNAL_USER From e6746270af120faa57cd06f938e477b99199eebd Mon Sep 17 00:00:00 2001 From: abhyudayareddy <54602866+abhyudayareddy@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:24:38 -0400 Subject: [PATCH 02/95] =?UTF-8?q?fix(vertex=5Fai):=20normalize=20Gemini=20?= =?UTF-8?q?finish=5Freason=20enum=20through=20map=5Ffinis=E2=80=A6=20(#253?= =?UTF-8?q?37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(vertex_ai): normalize Gemini finish_reason enum through map_finish_reason in streaming handler In the legacy vertex_ai SDK streaming path, the raw Gemini finish_reason enum name (e.g. "STOP", "MAX_TOKENS") was stored directly into self.received_finish_reason without being mapped to OpenAI-compatible values. The finish_reason_handler then compared against lowercase "stop", causing the case mismatch to prevent the tool_call override from ever firing. This fix applies map_finish_reason() so all Gemini enum names are normalized before storage.Refactor finish reason handling to use map_finish_reason function. * refactor: use module-level map_finish_reason import; drop redundant inline import map_finish_reason is already imported at module scope (line 49) via `from .core_helpers import map_finish_reason, process_response_headers`. The inline import added in the previous commit was redundant. Addressed Greptile review feedback.Removed unnecessary import of map_finish_reason from core_helpers. * test: add unit tests for Gemini legacy vertex finish_reason normalisation Added tests to ensure finish_reason normalization for Gemini legacy vertex tool calls and stop reasons. --- .../litellm_core_utils/streaming_handler.py | 6 +- .../test_streaming_handler.py | 70 +++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index e402023d240..ad3aaddaf01 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -1282,9 +1282,9 @@ class CustomStreamWrapper: and chunk.candidates[0].finish_reason.name # type: ignore != "FINISH_REASON_UNSPECIFIED" ): # every non-final chunk in vertex ai has this - self.received_finish_reason = chunk.candidates[ # type: ignore - 0 - ].finish_reason.name + self.received_finish_reason = map_finish_reason( # type: ignore + chunk.candidates[0].finish_reason.name + ) except Exception: if chunk.candidates[0].finish_reason.name == "SAFETY": # type: ignore raise Exception( diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py index aad3de306c7..cdf38d71137 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py @@ -1826,3 +1826,73 @@ async def test_custom_stream_wrapper_anext_exhaustion_raises_stop_async_iteratio pass # expected clean termination except RuntimeError as e: pytest.fail(f"PEP 479 regression: StopIteration leaked as RuntimeError: {e}") + + +def test_gemini_legacy_vertex_stop_finish_reason_normalised(): + """ + The legacy vertex_ai SDK streaming path sets finish_reason from a proto enum + whose .name attribute is an uppercase string (e.g. "STOP", "MAX_TOKENS"). + Before the fix, received_finish_reason was stored as "STOP" which never + matched "stop" in finish_reason_handler, silently breaking the tool_calls + override. After the fix, map_finish_reason() is applied so the value is + always an OpenAI-normalised lowercase string. + """ + wrapper = CustomStreamWrapper( + completion_stream=None, + model="gemini-1.5-pro", + logging_obj=MagicMock(), + custom_llm_provider="vertex_ai", + ) + + # Simulate a proto-like chunk: .candidates[0].finish_reason.name == "STOP" + mock_finish_reason = MagicMock() + mock_finish_reason.name = "STOP" + mock_candidate = MagicMock() + mock_candidate.finish_reason = mock_finish_reason + mock_chunk = MagicMock() + mock_chunk.candidates = [mock_candidate] + # Ensure the chunk is not treated as a ModelResponseStream + mock_chunk.__class__ = type("FakeProtoChunk", (), {}) + + with patch("litellm.litellm_core_utils.streaming_handler.proto", create=True): + wrapper.chunk_creator(chunk=mock_chunk) + + assert wrapper.received_finish_reason == "stop", ( + f"Expected 'stop' but got {wrapper.received_finish_reason!r}. " + "map_finish_reason() was not applied to the Gemini enum name." + ) + + +def test_gemini_legacy_vertex_tool_calls_finish_reason_with_stop_enum(): + """ + When Gemini emits finish_reason STOP alongside tool-call content, the final + chunk must report finish_reason='tool_calls'. This requires that the raw + "STOP" enum name is first normalised to lowercase "stop" by map_finish_reason() + so that finish_reason_handler's equality check fires correctly. + """ + wrapper = CustomStreamWrapper( + completion_stream=None, + model="gemini-1.5-pro", + logging_obj=MagicMock(), + custom_llm_provider="vertex_ai", + ) + + mock_finish_reason = MagicMock() + mock_finish_reason.name = "STOP" + mock_candidate = MagicMock() + mock_candidate.finish_reason = mock_finish_reason + mock_chunk = MagicMock() + mock_chunk.candidates = [mock_candidate] + mock_chunk.__class__ = type("FakeProtoChunk", (), {}) + + with patch("litellm.litellm_core_utils.streaming_handler.proto", create=True): + wrapper.chunk_creator(chunk=mock_chunk) + + # Signal that tool_calls were present in the stream + wrapper.tool_call = True + + final = wrapper.finish_reason_handler() + assert final.choices[0].finish_reason == "tool_calls", ( + f"Expected 'tool_calls' but got {final.choices[0].finish_reason!r}. " + "STOP enum was not normalised through map_finish_reason()." + ) From e0a578fbdda772b89d037c9c4a590179e4c3e4d7 Mon Sep 17 00:00:00 2001 From: milan-berri Date: Thu, 9 Apr 2026 07:30:38 +0300 Subject: [PATCH 03/95] fix: remove leading space from license public_key.pem (#25339) * fix: remove leading space from license public_key.pem PEM must begin with -----BEGIN; a leading ASCII space breaks cryptography.load_pem_public_key on older cryptography (e.g. 41.x), causing OpenSSL no start line / deserialize errors. Made-with: Cursor * test: assert license public_key.pem loads as valid PEM Regression guard for leading whitespace before -----BEGIN, which breaks load_pem_public_key on older cryptography (e.g. 41.x). Made-with: Cursor --- litellm/proxy/auth/public_key.pem | 2 +- tests/test_litellm/proxy/auth/test_litellm_license.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/auth/public_key.pem b/litellm/proxy/auth/public_key.pem index 0962794ac91..437befbf08f 100644 --- a/litellm/proxy/auth/public_key.pem +++ b/litellm/proxy/auth/public_key.pem @@ -1,4 +1,4 @@ - -----BEGIN PUBLIC KEY----- +-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwcNBabWBZzrDhFAuA4Fh FhIcA3rF7vrLb8+1yhF2U62AghQp9nStyuJRjxMUuldWgJ1yRJ2s7UffVw5r8DeA dqXPD+w+3LCNwqJGaIKN08QGJXNArM3QtMaN0RTzAyQ4iibN1r6609W5muK9wGp0 diff --git a/tests/test_litellm/proxy/auth/test_litellm_license.py b/tests/test_litellm/proxy/auth/test_litellm_license.py index dfb17d77f71..687f3eb4017 100644 --- a/tests/test_litellm/proxy/auth/test_litellm_license.py +++ b/tests/test_litellm/proxy/auth/test_litellm_license.py @@ -11,6 +11,14 @@ sys.path.insert( from litellm.proxy.auth.litellm_license import LicenseCheck +def test_read_public_key_loads_successfully(): + """Ensure public_key.pem is valid PEM with no leading whitespace.""" + license_check = LicenseCheck() + assert license_check.public_key is not None, ( + "public_key.pem could not be loaded — check for leading whitespace or malformed PEM header" + ) + + def test_is_over_limit(): license_check = LicenseCheck() license_check.airgapped_license_data = {"max_users": 100} From 4e32479e7d9578864c453578c5f3061ba36cf535 Mon Sep 17 00:00:00 2001 From: kejunleng <33445544+silencedoctor@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:32:04 +0800 Subject: [PATCH 04/95] feat(dashscope): preserve cache_control for explicit prompt caching (#25331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DashScope inherits OpenAIGPTConfig which strips cache_control from messages and tools by default. Override remove_cache_control_flag_from_messages_and_tools() to preserve cache_control, following the same pattern used by ZAI, MiniMax, and Databricks. Verified through 10-round multi-turn conversation tests: - Explicit caching works correctly: cached_tokens grows each round from R4 onwards, with cache_creation_tokens reported on first cache build. - Implicit caching is not affected: models that rely on implicit prefix-matching caching produce identical cached_tokens with and without this change, confirmed by comparing results against both the reverted codebase and direct API calls bypassing litellm. - No errors or regressions observed on any model, including those that do not support explicit caching — the DashScope API silently ignores unrecognized cache_control fields. Fixes #25330 Co-authored-by: Claude Opus 4.6 (1M context) --- litellm/llms/dashscope/chat/transformation.py | 14 ++++++ .../test_dashscope_chat_transformation.py | 44 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/litellm/llms/dashscope/chat/transformation.py b/litellm/llms/dashscope/chat/transformation.py index cc5cf991826..d022f9da210 100644 --- a/litellm/llms/dashscope/chat/transformation.py +++ b/litellm/llms/dashscope/chat/transformation.py @@ -4,6 +4,8 @@ Translates from OpenAI's `/v1/chat/completions` to DashScope's `/v1/chat/complet from typing import Any, Coroutine, List, Literal, Optional, Tuple, Union, overload +from litellm.types.llms.openai import ChatCompletionToolParam + from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues @@ -11,6 +13,18 @@ from ...openai.chat.gpt_transformation import OpenAIGPTConfig class DashScopeChatConfig(OpenAIGPTConfig): + def remove_cache_control_flag_from_messages_and_tools( + self, + model: str, + messages: List[AllMessageValues], + tools: Optional[List[ChatCompletionToolParam]] = None, + ) -> Tuple[List[AllMessageValues], Optional[List[ChatCompletionToolParam]]]: + """ + Override to preserve cache_control for DashScope. + DashScope supports cache_control - don't strip it. + """ + return messages, tools + @overload def _transform_messages( self, messages: List[AllMessageValues], model: str, is_async: Literal[True] diff --git a/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py b/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py index b5f656c71f8..e99c3c3b31c 100644 --- a/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py +++ b/tests/test_litellm/llms/dashscope/test_dashscope_chat_transformation.py @@ -144,3 +144,47 @@ class TestDashScopeConfig: assert transformed_messages[0]["content"][0]["text"] == "Hello" assert transformed_messages[0]["content"][1]["type"] == "text" assert transformed_messages[0]["content"][1]["text"] == "World" + + def test_dashscope_preserves_cache_control_in_messages(self): + """DashScope should NOT strip cache_control from messages.""" + config = DashScopeChatConfig() + + messages = [ + { + "role": "system", + "content": "You are a helpful assistant.", + "cache_control": {"type": "ephemeral"}, + }, + { + "role": "user", + "content": "Hello, world!", + }, + ] + + transformed_messages, _ = config.remove_cache_control_flag_from_messages_and_tools( + model="dashscope/qwen-turbo", messages=messages + ) + + assert transformed_messages[0].get("cache_control") == {"type": "ephemeral"} + + def test_dashscope_preserves_cache_control_in_tools(self): + """DashScope should NOT strip cache_control from tools.""" + config = DashScopeChatConfig() + + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather information", + "parameters": {"type": "object", "properties": {}}, + }, + "cache_control": {"type": "ephemeral"}, + } + ] + + _, transformed_tools = config.remove_cache_control_flag_from_messages_and_tools( + model="dashscope/qwen-turbo", messages=[], tools=tools + ) + + assert transformed_tools[0].get("cache_control") == {"type": "ephemeral"} From 541e81de2fefb3581f4f7eef61db5beb58264657 Mon Sep 17 00:00:00 2001 From: Austin Varga <64624232+avarga1@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:34:03 -0600 Subject: [PATCH 05/95] fix: expose reasoning effort fields in get_model_info + add together_ai/gpt-oss-120b (#25263) * fix: expose reasoning effort fields in get_model_info and add together_ai/gpt-oss-120b - litellm/utils.py: pass supports_none_reasoning_effort and supports_xhigh_reasoning_effort through _get_model_info_helper so get_model_info() returns them (previously silently dropped). Fixes #25096. - model_prices_and_context_window.json: add together_ai/openai/gpt-oss-120b with supports_reasoning: true so reasoning_effort is accepted for this model without requiring drop_params. Fixes #25132. Co-Authored-By: Claude Sonnet 4.6 * fix: consolidate duplicate together_ai/openai/gpt-oss-120b entry and sync backup file * fix: link commit to GitHub account for CLA verification --------- Co-authored-by: Austin Varga Co-authored-by: Claude Sonnet 4.6 --- litellm/model_prices_and_context_window_backup.json | 5 ++++- litellm/utils.py | 2 ++ model_prices_and_context_window.json | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d781c91992d..22368f7a2f7 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -28551,12 +28551,15 @@ "together_ai/openai/gpt-oss-120b": { "input_cost_per_token": 1.5e-07, "litellm_provider": "together_ai", - "max_input_tokens": 128000, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6e-07, "source": "https://www.together.ai/models/gpt-oss-120b", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, diff --git a/litellm/utils.py b/litellm/utils.py index f902644e760..3b5abbbddad 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5872,6 +5872,8 @@ def _get_model_info_helper( # noqa: PLR0915 supports_web_search=_model_info.get("supports_web_search", None), supports_url_context=_model_info.get("supports_url_context", None), supports_reasoning=_model_info.get("supports_reasoning", None), + supports_none_reasoning_effort=_model_info.get("supports_none_reasoning_effort", None), + supports_xhigh_reasoning_effort=_model_info.get("supports_xhigh_reasoning_effort", None), supports_computer_use=_model_info.get("supports_computer_use", None), search_context_cost_per_query=_model_info.get( "search_context_cost_per_query", None diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index cfdb2911fdf..334aa157fa2 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -28536,12 +28536,15 @@ "together_ai/openai/gpt-oss-120b": { "input_cost_per_token": 1.5e-07, "litellm_provider": "together_ai", - "max_input_tokens": 128000, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6e-07, "source": "https://www.together.ai/models/gpt-oss-120b", "supports_function_calling": true, "supports_parallel_function_calling": true, + "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, From 6fd7a3ec766278e962f8512694bf1951cf861e37 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Apr 2026 17:13:50 -0700 Subject: [PATCH 06/95] [Feature] UI - Teams: Add router settings to team Settings tab Add RouterSettingsAccordion to the team edit form and a read-only display of router settings (routing strategy, retries, fallbacks, cooldown, timeout, tag filtering) in the Settings tab. --- .../src/components/team/TeamInfo.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 6308be65860..722c5218ce6 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -41,6 +41,7 @@ import ObjectPermissionsView from "../object_permissions_view"; import NumericalInput from "../shared/numerical_input"; import VectorStoreSelector from "../vector_store_management/VectorStoreSelector"; import EditLoggingSettings from "./EditLoggingSettings"; +import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "../common_components/RouterSettingsAccordion"; import MemberModal from "./EditMembership"; import MemberPermissions from "./member_permissions"; import { @@ -98,6 +99,7 @@ export interface TeamData { access_group_models?: string[]; access_group_mcp_server_ids?: string[]; access_group_agent_ids?: string[]; + router_settings?: Record; guardrails?: string[]; policies?: string[]; object_permission?: { @@ -187,6 +189,7 @@ const TeamInfoView: React.FC = ({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [isTeamSaving, setIsTeamSaving] = useState(false); + const [routerSettings, setRouterSettings] = useState(null); const [organization, setOrganization] = useState(null); const { userRole, userId } = useAuthorized(); const { data: userOrganizations = [] } = useOrganizations(); @@ -588,10 +591,21 @@ const TeamInfoView: React.FC = ({ updateData.access_group_ids = values.access_group_ids; } + // Handle router_settings + if (routerSettings?.router_settings) { + const hasValues = Object.values(routerSettings.router_settings).some( + (value) => value !== null && value !== undefined && value !== "", + ); + if (hasValues) { + updateData.router_settings = routerSettings.router_settings; + } + } + const response = await teamUpdateCall(accessToken, updateData); NotificationsManager.success("Team settings updated successfully"); setIsEditing(false); + setRouterSettings(null); fetchTeamInfo(); } catch (error) { console.error("Error updating team:", error); @@ -1086,6 +1100,15 @@ const TeamInfoView: React.FC = ({ + + 0 ? { data: userModels.map((model) => ({ model_name: model })) } : undefined} + /> + + @@ -1373,6 +1396,44 @@ const TeamInfoView: React.FC = ({
TPM Limit: {info.team_member_budget_table?.tpm_limit || "No Limit"}
RPM Limit: {info.team_member_budget_table?.rpm_limit || "No Limit"}
+
+ Router Settings + {info.router_settings && Object.values(info.router_settings).some( + (v) => v !== null && v !== undefined && v !== "" && !(Array.isArray(v) && v.length === 0) + ) ? ( +
+ {info.router_settings.routing_strategy && ( +
+ Routing Strategy:{" "} + {info.router_settings.routing_strategy} +
+ )} + {info.router_settings.num_retries != null && ( +
Number of Retries: {info.router_settings.num_retries}
+ )} + {info.router_settings.allowed_fails != null && ( +
Allowed Failures: {info.router_settings.allowed_fails}
+ )} + {info.router_settings.cooldown_time != null && ( +
Cooldown Time: {info.router_settings.cooldown_time}s
+ )} + {info.router_settings.timeout != null && ( +
Timeout: {info.router_settings.timeout}s
+ )} + {info.router_settings.retry_after != null && ( +
Retry After: {info.router_settings.retry_after}s
+ )} + {info.router_settings.fallbacks && Array.isArray(info.router_settings.fallbacks) && info.router_settings.fallbacks.length > 0 && ( +
Fallbacks: {info.router_settings.fallbacks.length} configured
+ )} + {info.router_settings.enable_tag_filtering && ( +
Tag Filtering: Enabled
+ )} +
+ ) : ( +
No router settings configured
+ )} +
Organization ID
{info.organization_id}
From a449cf801f6b2976cc3e4a9e29cc01a91e41ac60 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Apr 2026 17:17:45 -0700 Subject: [PATCH 07/95] fix: reset router settings state on cancel to prevent stale data --- ui/litellm-dashboard/src/components/team/TeamInfo.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 722c5218ce6..1459f323a64 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -1308,7 +1308,7 @@ const TeamInfoView: React.FC = ({
- + + ); +}; + +// ── tests ───────────────────────────────────────────────────────────────────── + +describe("OAuthFormFields", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── visibility by flow type ───────────────────────────────────────────────── + + describe("interactive mode (isM2M=false)", () => { + it("renders Token Validation Rules field", () => { + render( + + + , + ); + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + }); + + it("renders Token Storage TTL field", () => { + render( + + + , + ); + expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument(); + }); + + it("renders standard interactive fields alongside the new fields", () => { + render( + + + , + ); + expect(screen.getByText("Authorization URL (optional)")).toBeInTheDocument(); + expect(screen.getByText("Registration URL (optional)")).toBeInTheDocument(); + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument(); + }); + }); + + describe("M2M mode (isM2M=true)", () => { + it("does NOT render Token Validation Rules field", () => { + render( + + + , + ); + expect(screen.queryByText("Token Validation Rules (optional)")).not.toBeInTheDocument(); + }); + + it("does NOT render Token Storage TTL field", () => { + render( + + + , + ); + expect(screen.queryByText("Token Storage TTL (seconds, optional)")).not.toBeInTheDocument(); + }); + + it("still renders M2M-specific fields", () => { + render( + + + , + ); + expect(screen.getByText("Client ID")).toBeInTheDocument(); + expect(screen.getByText("Token URL")).toBeInTheDocument(); + }); + }); + + // ── token_validation_json inline JSON validator ────────────────────────────── + + describe("token_validation_json validation", () => { + it("accepts empty value without error", async () => { + const onFinish = vi.fn(); + render( + + + , + ); + + // Leave the textarea empty and submit + const submitBtn = screen.getByRole("button", { name: "Submit" }); + await act(async () => { + fireEvent.click(submitBtn); + }); + + await waitFor(() => { + expect(screen.queryByText("Must be valid JSON")).not.toBeInTheDocument(); + }); + }); + + it("accepts a valid JSON object without error", async () => { + render( + + + , + ); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: '{"organization": "my-org"}' } }); + }); + + const submitBtn = screen.getByRole("button", { name: "Submit" }); + await act(async () => { + fireEvent.click(submitBtn); + }); + + await waitFor(() => { + expect(screen.queryByText("Must be valid JSON")).not.toBeInTheDocument(); + }); + }); + + it("shows 'Must be valid JSON' error for malformed JSON", async () => { + render( + + + , + ); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "not-valid-json{" } }); + }); + + const submitBtn = screen.getByRole("button", { name: "Submit" }); + await act(async () => { + fireEvent.click(submitBtn); + }); + + await waitFor(() => { + expect(screen.getByText("Must be valid JSON")).toBeInTheDocument(); + }); + }); + + it("shows error for a plain string value (not a JSON object)", async () => { + render( + + + , + ); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + // A bare string is valid JSON but we still want to accept it; only truly + // unparseable text should fail. Bare "hello" is actually invalid JSON + // (no quotes), so it should fail. + fireEvent.change(textarea, { target: { value: "hello" } }); + }); + + const submitBtn = screen.getByRole("button", { name: "Submit" }); + await act(async () => { + fireEvent.click(submitBtn); + }); + + await waitFor(() => { + expect(screen.getByText("Must be valid JSON")).toBeInTheDocument(); + }); + }); + + it("whitespace-only value is treated as empty and passes validation", async () => { + const onFinish = vi.fn(); + render( + + + , + ); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: " " } }); + }); + + const submitBtn = screen.getByRole("button", { name: "Submit" }); + await act(async () => { + fireEvent.click(submitBtn); + }); + + await waitFor(() => { + expect(screen.queryByText("Must be valid JSON")).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx index 85487a8a479..4a808ca489d 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Form, Select, Tooltip } from "antd"; +import { Form, Input, InputNumber, Select, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, TextInput } from "@tremor/react"; import { OAUTH_FLOW } from "./types"; @@ -151,6 +151,50 @@ const OAuthFormFields: React.FC = ({ > + + } + name="token_validation_json" + rules={[ + { + validator: (_: any, value: string) => { + if (!value || value.trim() === "") return Promise.resolve(); + try { + JSON.parse(value); + return Promise.resolve(); + } catch { + return Promise.reject(new Error("Must be valid JSON")); + } + }, + }, + ]} + > + + + + } + name="token_storage_ttl_seconds" + > + + {oauthFlow && (

diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx index d49c49446bb..c92956b430f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx @@ -353,6 +353,147 @@ describe("CreateMCPServer", () => { ); }); + describe("when OAuth interactive auth is selected", () => { + /** Select HTTP transport + OAuth auth, then wait for the OAuth form to appear. */ + async function setupOAuthInteractive() { + render(); + await selectAntOption("Transport Type", "Streamable HTTP"); + + await waitFor(() => { + expect(screen.getByPlaceholderText("https://your-mcp-server.com")).toBeInTheDocument(); + }); + + await selectAntOption("Authentication", "OAuth"); + + // Wait for OAuthFormFields to render (OAuth Flow Type selector is the sentinel) + await waitFor(() => { + expect(screen.getByText("OAuth Flow Type")).toBeInTheDocument(); + }); + + // OAuthFormFields defaults to INTERACTIVE, so the new fields should appear + await waitFor(() => { + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument(); + }); + } + + it("shows Token Validation Rules and Token Storage TTL fields", async () => { + await setupOAuthInteractive(); + // Asserted in setupOAuthInteractive + }); + + it("includes token_validation in payload when token_validation_json is filled with valid JSON", async () => { + vi.mocked(networking.createMCPServer).mockResolvedValue({ + server_id: "new-server-oauth", + server_name: "OAuth_Server", + alias: "OAuth_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: "oauth2", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }); + + await setupOAuthInteractive(); + + // Fill required form fields + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "OAuth_Server" } }); + }); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://example.com/mcp" } }); + }); + + // Fill in the token_validation_json textarea + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: '{"organization": "my-org", "team.id": "42"}' } }); + }); + + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(networking.createMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + expect(payload.token_validation).toEqual({ organization: "my-org", "team.id": "42" }); + }); + + it("omits token_validation from payload when token_validation_json is empty", async () => { + vi.mocked(networking.createMCPServer).mockResolvedValue({ + server_id: "new-server-oauth", + server_name: "OAuth_Server", + alias: "OAuth_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: "oauth2", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }); + + await setupOAuthInteractive(); + + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "OAuth_Server" } }); + }); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://example.com/mcp" } }); + }); + + // Leave token_validation_json empty + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(networking.createMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + expect(payload.token_validation).toBeUndefined(); + }); + + it("does not submit and shows validation error for invalid JSON in token_validation_json", async () => { + await setupOAuthInteractive(); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "not-valid-json{" } }); + }); + + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "OAuth_Server" } }); + }); + + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + // Either the inline form validation message or the notification fires — + // both indicate the submit was blocked. + await waitFor(() => { + const inlineError = screen.queryByText("Must be valid JSON"); + const notCalled = !vi.mocked(networking.createMCPServer).mock.calls.length; + expect(inlineError !== null || notCalled).toBe(true); + }); + }); + }); + describe("when modal is cancelled", () => { it("should call setModalVisible(false) when cancel is clicked", async () => { render(); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 4c824fcee0b..45556bc18b1 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -284,6 +284,7 @@ const CreateMCPServer: React.FC = ({ credentials: credentialValues, allow_all_keys: allowAllKeysRaw, available_on_public_internet: availableOnPublicInternetRaw, + token_validation_json: rawTokenValidationJson, ...restValues } = values; @@ -356,6 +357,18 @@ const CreateMCPServer: React.FC = ({ restValues.transport = "http"; } + // Parse token_validation JSON if provided + let tokenValidation: Record | null = null; + if (rawTokenValidationJson && rawTokenValidationJson.trim() !== "") { + try { + tokenValidation = JSON.parse(rawTokenValidationJson); + } catch { + NotificationsManager.fromBackend("Invalid JSON in Token Validation Rules"); + setIsLoading(false); + return; + } + } + // Prepare the payload with cost configuration and allowed tools const payload: Record = { ...restValues, @@ -376,6 +389,7 @@ const CreateMCPServer: React.FC = ({ allow_all_keys: Boolean(allowAllKeysRaw), available_on_public_internet: Boolean(availableOnPublicInternetRaw), static_headers: staticHeaders, + ...(tokenValidation !== null && { token_validation: tokenValidation }), }; payload.static_headers = staticHeaders; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx index e33e2fff491..aba2a3d9222 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; import MCPServerEdit from "./mcp_server_edit"; import * as networking from "../networking"; +import NotificationsManager from "../molecules/notifications_manager"; vi.mock("../networking", () => ({ updateMCPServer: vi.fn(), @@ -37,6 +38,29 @@ vi.mock("./mcp_tool_configuration", () => ({ default: () =>

, })); +// ── fixtures ────────────────────────────────────────────────────────────────── + +const interactiveOAuthServer = { + server_id: "oauth_server_1", + server_name: "OAuthServer", + alias: "oauth_server", // underscores: hyphens fail validateMCPServerName + description: "Interactive OAuth MCP server", + transport: "http", + url: "https://example.com/mcp", + auth_type: "oauth2", + // No token_url → edit form defaults to INTERACTIVE flow + token_url: null, + authorization_url: null, + registration_url: null, + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + mcp_access_groups: [], +}; + +// ── test suites ─────────────────────────────────────────────────────────────── + describe("MCPServerEdit (stdio)", () => { beforeEach(() => { vi.clearAllMocks(); @@ -152,3 +176,228 @@ describe("MCPServerEdit (stdio)", () => { expect(payload.env).toEqual({ CIRCLECI_TOKEN: "new-token", CIRCLECI_BASE_URL: "https://circleci.com" }); }); }); + +describe("MCPServerEdit (interactive OAuth)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders Token Validation Rules and Token Storage TTL fields for interactive OAuth server", async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument(); + }); + }); + + // Note: The M2M flow hiding logic is tested via OAuthFormFields.test.tsx (isM2M prop directly), + // since Form.useWatch doesn't synchronously reflect initialValues in jsdom. + + it("pre-populates token_validation_json from existing server token_validation", async () => { + const tokenValidation = { organization: "my-org", "team.id": "123" }; + + render( + , + ); + + await waitFor(() => { + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + expect(textarea).not.toBeNull(); + const parsed = JSON.parse(textarea.value); + expect(parsed).toEqual(tokenValidation); + }); + }); + + it("includes token_validation in update payload when token_validation_json is filled", async () => { + const onSuccess = vi.fn(); + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + token_validation: { organization: "my-org" }, + }); + + render( + , + ); + + // Wait for the form to mount and the token_validation_json field to appear + await waitFor(() => { + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + }); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: '{"organization": "my-org"}' } }); + }); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect(payload.token_validation).toEqual({ organization: "my-org" }); + }); + + it("does not include token_validation in payload when field is empty and server had none", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue(interactiveOAuthServer); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + }); + + // Leave token_validation_json empty + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect(payload.token_validation).toBeUndefined(); + }); + + it("sends token_validation: null to clear an existing value when textarea is cleared", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + token_validation: null, + }); + + render( + , + ); + + await waitFor(() => { + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + expect(textarea?.value).toContain("old-org"); + }); + + // Clear the textarea + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "" } }); + }); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + // null signals the backend to clear the existing validation rules + expect(payload.token_validation).toBeNull(); + }); + + it("shows inline validation error and does not submit on invalid JSON in token_validation_json", async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + }); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "{ bad json" } }); + }); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + // The Form.Item inline validator intercepts invalid JSON before handleSave runs, + // so the inline error message appears and updateMCPServer is never called. + await waitFor(() => { + expect(screen.getByText("Must be valid JSON")).toBeInTheDocument(); + }); + expect(networking.updateMCPServer).not.toHaveBeenCalled(); + }); + + it("includes token_storage_ttl_seconds in payload when set", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + token_storage_ttl_seconds: 7200, + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument(); + }); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect(payload.token_storage_ttl_seconds).toBe(7200); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index e81d2f3960e..1a3e30cb15d 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { Form, Select, Button as AntdButton, Tooltip, Input } from "antd"; +import { Form, Select, Button as AntdButton, Tooltip, Input, InputNumber } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; import { AUTH_TYPE, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types"; @@ -190,6 +190,9 @@ const MCPServerEdit: React.FC = ({ transport: effectiveTransport, static_headers: initialStaticHeaders, oauth_flow_type: mcpServer.token_url ? OAUTH_FLOW.M2M : OAUTH_FLOW.INTERACTIVE, + token_validation_json: mcpServer.token_validation + ? JSON.stringify(mcpServer.token_validation, null, 2) + : undefined, }), [mcpServer, effectiveTransport, initialStaticHeaders, initialEnvJson], ); @@ -400,6 +403,7 @@ const MCPServerEdit: React.FC = ({ args: rawArgs, allow_all_keys: allowAllKeysRaw, available_on_public_internet: availableOnPublicInternetRaw, + token_validation_json: rawTokenValidationJson, ...restValues } = values; @@ -522,6 +526,17 @@ const MCPServerEdit: React.FC = ({ restValues.transport = "http"; } + // Parse token_validation JSON if provided + let tokenValidation: Record | null = null; + if (rawTokenValidationJson && rawTokenValidationJson.trim() !== "") { + try { + tokenValidation = JSON.parse(rawTokenValidationJson); + } catch { + NotificationsManager.fromBackend("Invalid JSON in Token Validation Rules"); + return; + } + } + // Prepare the payload with cost configuration and permission fields const mcpInfoServerName = restValues.server_name || @@ -556,6 +571,10 @@ const MCPServerEdit: React.FC = ({ static_headers: staticHeaders, allow_all_keys: Boolean(allowAllKeysRaw ?? mcpServer.allow_all_keys), available_on_public_internet: Boolean(availableOnPublicInternetRaw ?? mcpServer.available_on_public_internet), + // Include token_validation when it is set (non-null) or when clearing an existing value + ...(tokenValidation !== null || mcpServer.token_validation + ? { token_validation: tokenValidation } + : {}), }; const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type); @@ -863,6 +882,58 @@ const MCPServerEdit: React.FC = ({ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" /> + {!isM2MFlow && ( + <> + + Token Validation Rules (optional) + + + + + } + name="token_validation_json" + rules={[ + { + validator: (_: any, value: string) => { + if (!value || value.trim() === "") return Promise.resolve(); + try { + JSON.parse(value); + return Promise.resolve(); + } catch { + return Promise.reject(new Error("Must be valid JSON")); + } + }, + }, + ]} + > + + + + Token Storage TTL (seconds, optional) + + + + + } + name="token_storage_ttl_seconds" + > + + + + )}

Use OAuth to fetch a fresh access token and temporarily save it in the session as the authentication value.

- +
diff --git a/ui/litellm-dashboard/src/components/add_model/litellm_model_name.tsx b/ui/litellm-dashboard/src/components/add_model/litellm_model_name.tsx index 521fed7f7b5..56ecfcd3d76 100644 --- a/ui/litellm-dashboard/src/components/add_model/litellm_model_name.tsx +++ b/ui/litellm-dashboard/src/components/add_model/litellm_model_name.tsx @@ -126,6 +126,7 @@ const LiteLLMModelNameField: React.FC = ({ ) : providerModels.length > 0 ? (
- + Connection to {modelName} successful!
@@ -190,7 +190,7 @@ ${formattedBody}
- + Connection to {modelName} failed
From 5e07c1cbc9131e25be07a4476ddba7de64d04e1b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 11 Apr 2026 20:21:37 -0700 Subject: [PATCH 93/95] address greptile review feedback (greploop iteration 1) Add cleanup helper to delete models created during tests, preventing stale data accumulation across repeated test runs. --- .../tests/modelsPage/addModel.spec.ts | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts index 3c056a25811..c07fd827b39 100644 --- a/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts +++ b/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts @@ -4,6 +4,34 @@ import { Role, users } from "../../fixtures/users"; import { navigateToPage } from "../../helpers/navigation"; import { Page } from "../../fixtures/pages"; +/** + * Helper to delete a model by searching for it via the API and deleting matching entries. + * Accepts a partial model name to match against. + */ +async function cleanupModels(request: any, searchTerm: string) { + try { + const response = await request.get("/v2/model/info?include_team_models=true&page=1&size=100", { + headers: { Authorization: "Bearer sk-1234" }, + }); + const data = await response.json(); + const models = data?.data || []; + for (const model of models) { + const name = model.model_name || ""; + if (name.includes(searchTerm)) { + await request.post("/model/delete", { + headers: { + Authorization: "Bearer sk-1234", + "Content-Type": "application/json", + }, + data: { id: model.model_info?.id }, + }); + } + } + } catch { + // Best-effort cleanup; don't fail the test + } +} + test.describe("Add Model", () => { test.use({ storageState: ADMIN_STORAGE_PATH }); @@ -110,7 +138,9 @@ test.describe("Add Model", () => { await expect(page.getByTestId("connection-failure-msg")).toContainText("failed"); }); - test("Add specific model and verify it appears in All Models", async ({ page }) => { + test("Add specific model and verify it appears in All Models", async ({ page, request }) => { + // Clean up any leftover models from previous runs + await cleanupModels(request, "claude-haiku-4-5"); await navigateToPage(page, Page.Models); await page.getByRole("tab", { name: "Add Model" }).click(); @@ -155,7 +185,9 @@ test.describe("Add Model", () => { await expect(tableBody.getByText("claude-haiku-4-5").first()).toBeVisible({ timeout: 15_000 }); }); - test("Add wildcard route and verify it appears in All Models", async ({ page }) => { + test("Add wildcard route and verify it appears in All Models", async ({ page, request }) => { + // Clean up any leftover models from previous runs + await cleanupModels(request, "cohere/"); await navigateToPage(page, Page.Models); await page.getByRole("tab", { name: "Add Model" }).click(); From cce716334806d0c1f554bf0d206958c739af6872 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 11 Apr 2026 20:34:50 -0700 Subject: [PATCH 94/95] fix CI: replace data-testid selectors with text/role-based selectors The data-testid attributes added to React components are not present in the CI-built UI output. Switch to using getByRole and getByText selectors which work with the rendered DOM regardless of build cache. --- .../tests/modelsPage/addModel.spec.ts | 71 ++++++++----------- 1 file changed, 29 insertions(+), 42 deletions(-) diff --git a/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts index c07fd827b39..1a37caf62fd 100644 --- a/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts +++ b/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts @@ -32,6 +32,17 @@ async function cleanupModels(request: any, searchTerm: string) { } } +/** + * Helper to select a provider from the Add Model form dropdown. + */ +async function selectProvider(page: any, providerName: string) { + const providerDropdown = page.getByRole("combobox", { name: /Provider/i }); + await providerDropdown.fill(providerName); + await page.waitForTimeout(1000); + await providerDropdown.press("Enter"); + await page.waitForTimeout(2000); +} + test.describe("Add Model", () => { test.use({ storageState: ADMIN_STORAGE_PATH }); @@ -39,19 +50,13 @@ test.describe("Add Model", () => { await navigateToPage(page, Page.Models); await page.getByRole("tab", { name: "Add Model" }).click(); - const providerDropdown = page.getByRole("combobox", { name: /Provider/i }); - await providerDropdown.fill("Anthropic"); - await page.waitForTimeout(1000); - await providerDropdown.press("Enter"); - await page.waitForTimeout(2000); + await selectProvider(page, "Anthropic"); - // The model field should be a multi-select dropdown (not a text input) - const modelSelect = page.getByTestId("model-name-select"); - await expect(modelSelect).toBeVisible({ timeout: 10_000 }); - - // Click to open the dropdown and verify provider-specific models are listed + // The model field should be a multi-select dropdown; click to open it const modelDropdown = page.locator(".ant-select-selection-overflow").first(); await modelDropdown.click(); + + // Verify provider-specific models are listed await expect(page.getByTitle("claude-haiku-4-5", { exact: true })).toBeVisible(); }); @@ -110,12 +115,7 @@ test.describe("Add Model", () => { await navigateToPage(page, Page.Models); await page.getByRole("tab", { name: "Add Model" }).click(); - // Select provider: Anthropic - const providerDropdown = page.getByRole("combobox", { name: /Provider/i }); - await providerDropdown.fill("Anthropic"); - await page.waitForTimeout(1000); - await providerDropdown.press("Enter"); - await page.waitForTimeout(2000); + await selectProvider(page, "Anthropic"); // Select model: claude-haiku-4-5 const modelDropdown = page.locator(".ant-select-selection-overflow").first(); @@ -127,15 +127,14 @@ test.describe("Add Model", () => { const apiKeyInput = page.locator('input[type="password"]').first(); await apiKeyInput.fill("sk-bad-key-12345"); - // Click Test Connect - await page.getByTestId("test-connect-btn").click(); + // Click Test Connect button by its text + await page.getByRole("button", { name: "Test Connect" }).click(); // Wait for modal to appear and connection test to complete await expect(page.getByText("Connection Test Results")).toBeVisible({ timeout: 10_000 }); // Verify failure message appears (the test makes a real API call, so it will fail with bad creds) - await expect(page.getByTestId("connection-failure-msg")).toBeVisible({ timeout: 30_000 }); - await expect(page.getByTestId("connection-failure-msg")).toContainText("failed"); + await expect(page.getByText(/Connection to .* failed/)).toBeVisible({ timeout: 30_000 }); }); test("Add specific model and verify it appears in All Models", async ({ page, request }) => { @@ -144,12 +143,7 @@ test.describe("Add Model", () => { await navigateToPage(page, Page.Models); await page.getByRole("tab", { name: "Add Model" }).click(); - // Select provider: Anthropic - const providerDropdown = page.getByRole("combobox", { name: /Provider/i }); - await providerDropdown.fill("Anthropic"); - await page.waitForTimeout(1000); - await providerDropdown.press("Enter"); - await page.waitForTimeout(2000); + await selectProvider(page, "Anthropic"); // Select model: claude-haiku-4-5 const modelDropdown = page.locator(".ant-select-selection-overflow").first(); @@ -161,8 +155,8 @@ test.describe("Add Model", () => { const apiKeyInput = page.locator('input[type="password"]').first(); await apiKeyInput.fill("sk-any-key-for-add-test"); - // Click Add Model - await page.getByTestId("add-model-btn").click(); + // Click Add Model button by its text + await page.getByRole("button", { name: "Add Model" }).last().click(); // Wait for success notification await expect(page.getByText("created successfully")).toBeVisible({ timeout: 15_000 }); @@ -173,12 +167,11 @@ test.describe("Add Model", () => { await page.waitForTimeout(2000); // Search for the model we just added - await page.getByTestId("model-search-input").fill("claude-haiku-4-5"); + await page.locator('input[placeholder="Search model names..."]').fill("claude-haiku-4-5"); await page.waitForTimeout(1000); // Verify the model appears in the results count (not "Showing 0 results") - const resultsCount = page.getByTestId("models-results-count"); - await expect(resultsCount).not.toHaveText("Showing 0 results", { timeout: 15_000 }); + await expect(page.getByText(/Showing \d+ - \d+ of \d+ results/)).toBeVisible({ timeout: 15_000 }); // Verify the model name appears in the table body const tableBody = page.locator("table tbody"); @@ -191,12 +184,7 @@ test.describe("Add Model", () => { await navigateToPage(page, Page.Models); await page.getByRole("tab", { name: "Add Model" }).click(); - // Select provider: Cohere - const providerDropdown = page.getByRole("combobox", { name: /Provider/i }); - await providerDropdown.fill("Cohere"); - await page.waitForTimeout(1000); - await providerDropdown.press("Enter"); - await page.waitForTimeout(2000); + await selectProvider(page, "Cohere"); // Select All Cohere Models (Wildcard) const modelDropdown = page.locator(".ant-select-selection-overflow").first(); @@ -209,8 +197,8 @@ test.describe("Add Model", () => { const apiKeyInput = page.locator('input[type="password"]').first(); await apiKeyInput.fill("sk-any-key-for-wildcard-test"); - // Click Add Model - await page.getByTestId("add-model-btn").click(); + // Click Add Model button by its text + await page.getByRole("button", { name: "Add Model" }).last().click(); // Wait for success notification await expect(page.getByText("created successfully")).toBeVisible({ timeout: 15_000 }); @@ -221,12 +209,11 @@ test.describe("Add Model", () => { await page.waitForTimeout(2000); // Search for the wildcard model - await page.getByTestId("model-search-input").fill("cohere"); + await page.locator('input[placeholder="Search model names..."]').fill("cohere"); await page.waitForTimeout(1000); // Verify the model appears in the results count (not "Showing 0 results") - const resultsCount = page.getByTestId("models-results-count"); - await expect(resultsCount).not.toHaveText("Showing 0 results", { timeout: 15_000 }); + await expect(page.getByText(/Showing \d+ - \d+ of \d+ results/)).toBeVisible({ timeout: 15_000 }); // Verify the wildcard model appears in the table body (wildcard models show as "cohere/*") const tableBody = page.locator("table tbody"); From 9b74ff3ef7f9fe924b69e3be6eb961a3e777ed4b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 11 Apr 2026 20:50:34 -0700 Subject: [PATCH 95/95] remove unnecessary cleanup helper The database is freshly seeded on every test run via seed.sql, so per-test cleanup is not needed. --- .../tests/modelsPage/addModel.spec.ts | 36 ++----------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts index 1a37caf62fd..8834724f76b 100644 --- a/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts +++ b/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts @@ -4,34 +4,6 @@ import { Role, users } from "../../fixtures/users"; import { navigateToPage } from "../../helpers/navigation"; import { Page } from "../../fixtures/pages"; -/** - * Helper to delete a model by searching for it via the API and deleting matching entries. - * Accepts a partial model name to match against. - */ -async function cleanupModels(request: any, searchTerm: string) { - try { - const response = await request.get("/v2/model/info?include_team_models=true&page=1&size=100", { - headers: { Authorization: "Bearer sk-1234" }, - }); - const data = await response.json(); - const models = data?.data || []; - for (const model of models) { - const name = model.model_name || ""; - if (name.includes(searchTerm)) { - await request.post("/model/delete", { - headers: { - Authorization: "Bearer sk-1234", - "Content-Type": "application/json", - }, - data: { id: model.model_info?.id }, - }); - } - } - } catch { - // Best-effort cleanup; don't fail the test - } -} - /** * Helper to select a provider from the Add Model form dropdown. */ @@ -137,9 +109,7 @@ test.describe("Add Model", () => { await expect(page.getByText(/Connection to .* failed/)).toBeVisible({ timeout: 30_000 }); }); - test("Add specific model and verify it appears in All Models", async ({ page, request }) => { - // Clean up any leftover models from previous runs - await cleanupModels(request, "claude-haiku-4-5"); + test("Add specific model and verify it appears in All Models", async ({ page }) => { await navigateToPage(page, Page.Models); await page.getByRole("tab", { name: "Add Model" }).click(); @@ -178,9 +148,7 @@ test.describe("Add Model", () => { await expect(tableBody.getByText("claude-haiku-4-5").first()).toBeVisible({ timeout: 15_000 }); }); - test("Add wildcard route and verify it appears in All Models", async ({ page, request }) => { - // Clean up any leftover models from previous runs - await cleanupModels(request, "cohere/"); + test("Add wildcard route and verify it appears in All Models", async ({ page }) => { await navigateToPage(page, Page.Models); await page.getByRole("tab", { name: "Add Model" }).click();