From ebb0f7e4cf1bfde5f720d89b76c4311176851878 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 15 Jul 2026 18:40:32 +0000 Subject: [PATCH 01/19] fix(responses): preserve reasoning through prompt hooks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/responses/main.py | 18 +++- litellm/responses/utils.py | 50 ++++++++++ .../test_responses_prompt_management.py | 94 ++++++++++++++++--- 3 files changed, 148 insertions(+), 14 deletions(-) diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 12f9be970c7..453676937fc 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -494,7 +494,14 @@ async def aresponses( prompt_label=kwargs.get("prompt_label", None), prompt_version=kwargs.get("prompt_version", None), ) - input = cast(Union[str, ResponseInputParam], merged_input) + input = cast( + Union[str, ResponseInputParam], + ResponsesAPIRequestUtils.merge_prompt_management_input( + original_input=input, + client_input=client_input, + merged_input=merged_input, + ), + ) if model != original_model: _, custom_llm_provider, _, _ = litellm.get_llm_provider(model=model) kwargs.pop("prompt_id", None) @@ -609,7 +616,14 @@ def _apply_prompt_management_to_responses_call( prompt_label=kwargs.get("prompt_label", None), prompt_version=kwargs.get("prompt_version", None), ) - input = cast(Union[str, ResponseInputParam], merged_input) + input = cast( + Union[str, ResponseInputParam], + ResponsesAPIRequestUtils.merge_prompt_management_input( + original_input=input, + client_input=client_input, + merged_input=merged_input, + ), + ) local_vars["input"] = input local_vars["model"] = model if model != original_model: diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 234eb777aca..6d42e33a268 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -19,7 +19,9 @@ import litellm from litellm._logging import verbose_logger from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig from litellm.types.llms.openai import ( + AllMessageValues, ResponseAPIUsage, + ResponseInputParam, ResponsesAPIOptionalRequestParams, ResponsesAPIResponse, ResponseText, @@ -36,6 +38,54 @@ from litellm.types.utils import ( class ResponsesAPIRequestUtils: """Helper utils for constructing ResponseAPI requests""" + @staticmethod + def merge_prompt_management_input( + original_input: str | ResponseInputParam, + client_input: list[AllMessageValues], + merged_input: list[AllMessageValues], + ) -> list[object]: + if isinstance(original_input, str): + return [*merged_input] + + original_items = tuple(original_input) + client_item_ids = frozenset(id(item) for item in client_input) + message_positions = tuple(index for index, item in enumerate(original_items) if id(item) in client_item_ids) + + if len(message_positions) == len(original_items): + return [*merged_input] + if not message_positions: + return [*merged_input, *original_items] + + corresponding_messages = len(client_input) == len(merged_input) and all( + original.get("role") == merged.get("role") + and (not isinstance(original.get("id"), str) or original.get("id") == merged.get("id")) + for original, merged in zip(client_input, merged_input) + ) + if corresponding_messages: + merged_by_position = dict(zip(message_positions, merged_input)) + return [ + merged_by_position[index] if index in merged_by_position else item + for index, item in enumerate(original_items) + ] + + all_messages_preserved = all(any(original is merged for merged in merged_input) for original in client_input) + if all_messages_preserved: + prefixes = { + id(original_items[position]): original_items[ + message_positions[index - 1] + 1 if index else 0 : position + ] + for index, position in enumerate(message_positions) + } + trailing_items = original_items[message_positions[-1] + 1 :] + return [item for merged in merged_input for item in (*prefixes.get(id(merged), ()), merged)] + list( + trailing_items + ) + + verbose_logger.warning( + "Prompt management hook replaced Responses API messages; non-message input items were dropped" + ) + return [*merged_input] + @staticmethod def _check_valid_arg( supported_params: Optional[List[str]], diff --git a/tests/test_litellm/responses/test_responses_prompt_management.py b/tests/test_litellm/responses/test_responses_prompt_management.py index 84e98390268..e4207b292da 100644 --- a/tests/test_litellm/responses/test_responses_prompt_management.py +++ b/tests/test_litellm/responses/test_responses_prompt_management.py @@ -14,13 +14,19 @@ Covers: """ import asyncio -from typing import List +from typing import List, cast from unittest.mock import AsyncMock, MagicMock, patch import pytest +from litellm.integrations.anthropic_cache_control_hook import ( + AnthropicCacheControlHook, +) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj -from litellm.types.llms.openai import AllMessageValues +from litellm.types.llms.openai import ( + AllMessageValues, + ResponseInputParam, +) # --------------------------------------------------------------------------- # Helpers @@ -54,18 +60,15 @@ def _patch_responses_dispatch(): return_value=("gpt-4o", "openai", None, None), ), patch( - "litellm.responses.mcp.litellm_proxy_mcp_handler." - "LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway", + "litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway", return_value=False, ), patch( - "litellm.responses.main.ProviderConfigManager" - ".get_provider_responses_api_config", + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config", return_value=None, ), patch( - "litellm.responses.main.litellm_completion_transformation_handler" - ".response_api_handler", + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler", return_value=MagicMock(), ), ] @@ -77,7 +80,6 @@ def _patch_responses_dispatch(): class TestResponsesAPIPromptManagement: - def test_str_input_coerced_and_merged(self): """[A] str input is wrapped into a message list before being passed to the hook.""" template_messages: List[AllMessageValues] = [ @@ -108,9 +110,7 @@ class TestResponsesAPIPromptManagement: logging_obj.get_chat_completion_prompt.assert_called_once() call_kwargs = logging_obj.get_chat_completion_prompt.call_args.kwargs # str was coerced to a single user message before being passed to the hook - assert call_kwargs["messages"] == [ - {"role": "user", "content": "Tell me about AI."} - ] + assert call_kwargs["messages"] == [{"role": "user", "content": "Tell me about AI."}] assert call_kwargs["prompt_id"] == "summariser-prompt" def test_list_input_merged_with_template(self): @@ -256,6 +256,76 @@ class TestResponsesAPIPromptManagement: assert all(isinstance(m, dict) and "role" in m for m in passed_messages) assert len(passed_messages) == 1 + def test_cache_control_hook_preserves_reasoning_items(self): + system_message = cast( + AllMessageValues, + {"role": "system", "content": "Analyze the request"}, + ) + assistant_message = cast( + AllMessageValues, + { + "type": "message", + "id": "msg_1", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "The code has a bug", + "annotations": [], + } + ], + }, + ) + user_message = cast( + AllMessageValues, + {"role": "user", "content": "Check for security issues"}, + ) + reasoning_item = { + "type": "reasoning", + "id": "rs_1", + "summary": [], + "encrypted_content": "encrypted", + } + original_input = cast( + ResponseInputParam, + [system_message, reasoning_item, assistant_message, user_message], + ) + _, merged_messages, _ = AnthropicCacheControlHook().get_chat_completion_prompt( + model="azure/gpt-5-codex", + messages=[system_message, assistant_message, user_message], + non_default_params={"cache_control_injection_points": [{"location": "message", "role": "system"}]}, + prompt_id=None, + prompt_variables=None, + dynamic_callback_params={}, + ) + logging_obj = _make_logging_obj( + merged_model="azure/gpt-5-codex", + merged_messages=merged_messages, + ) + + patches = _patch_responses_dispatch() + with patches[0], patches[1], patches[2], patches[3] as mock_handler: + import litellm + + litellm.responses( + input=original_input, + model="azure/gpt-5-codex", + litellm_logging_obj=logging_obj, + cache_control_injection_points=[{"location": "message", "role": "system"}], + ) + + sent_input = mock_handler.call_args.kwargs["input"] + assert [item.get("type") for item in sent_input] == [ + None, + "reasoning", + "message", + None, + ] + assert sent_input[0]["cache_control"] == {"type": "ephemeral"} + assert sent_input[1] == reasoning_item + assert sent_input[2]["id"] == "msg_1" + def test_model_override_re_resolves_provider(self): """[G] When the prompt template overrides the model to a different provider, custom_llm_provider is re-resolved so downstream routing uses the correct provider. From 4baee71bdd1e82db5225122ad5d9a1a7ae925af2 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 15 Jul 2026 18:41:02 +0000 Subject: [PATCH 02/19] chore(responses): minimize regression test diff Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../responses/test_responses_prompt_management.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_litellm/responses/test_responses_prompt_management.py b/tests/test_litellm/responses/test_responses_prompt_management.py index e4207b292da..b3ba81ee2e8 100644 --- a/tests/test_litellm/responses/test_responses_prompt_management.py +++ b/tests/test_litellm/responses/test_responses_prompt_management.py @@ -60,15 +60,18 @@ def _patch_responses_dispatch(): return_value=("gpt-4o", "openai", None, None), ), patch( - "litellm.responses.mcp.litellm_proxy_mcp_handler.LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway", + "litellm.responses.mcp.litellm_proxy_mcp_handler." + "LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway", return_value=False, ), patch( - "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config", + "litellm.responses.main.ProviderConfigManager" + ".get_provider_responses_api_config", return_value=None, ), patch( - "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler", + "litellm.responses.main.litellm_completion_transformation_handler" + ".response_api_handler", return_value=MagicMock(), ), ] @@ -80,6 +83,7 @@ def _patch_responses_dispatch(): class TestResponsesAPIPromptManagement: + def test_str_input_coerced_and_merged(self): """[A] str input is wrapped into a message list before being passed to the hook.""" template_messages: List[AllMessageValues] = [ @@ -110,7 +114,9 @@ class TestResponsesAPIPromptManagement: logging_obj.get_chat_completion_prompt.assert_called_once() call_kwargs = logging_obj.get_chat_completion_prompt.call_args.kwargs # str was coerced to a single user message before being passed to the hook - assert call_kwargs["messages"] == [{"role": "user", "content": "Tell me about AI."}] + assert call_kwargs["messages"] == [ + {"role": "user", "content": "Tell me about AI."} + ] assert call_kwargs["prompt_id"] == "summariser-prompt" def test_list_input_merged_with_template(self): From 4db0bdf465c9f07e8561136cf055ca9e6854f086 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 15 Jul 2026 18:51:07 +0000 Subject: [PATCH 03/19] fix(responses): handle non-message-only prompt input Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/responses/utils.py | 5 +- .../test_responses_prompt_management.py | 154 +++++++++++++----- 2 files changed, 116 insertions(+), 43 deletions(-) diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 6d42e33a268..a5203c4ee6a 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -54,7 +54,10 @@ class ResponsesAPIRequestUtils: if len(message_positions) == len(original_items): return [*merged_input] if not message_positions: - return [*merged_input, *original_items] + verbose_logger.warning( + "Prompt management hook returned messages without Responses API input messages; merged messages were ignored" + ) + return [*original_items] corresponding_messages = len(client_input) == len(merged_input) and all( original.get("role") == merged.get("role") diff --git a/tests/test_litellm/responses/test_responses_prompt_management.py b/tests/test_litellm/responses/test_responses_prompt_management.py index b3ba81ee2e8..7044d8384f8 100644 --- a/tests/test_litellm/responses/test_responses_prompt_management.py +++ b/tests/test_litellm/responses/test_responses_prompt_management.py @@ -77,6 +77,56 @@ def _patch_responses_dispatch(): ] +def _make_cache_control_case() -> tuple[ + ResponseInputParam, + list[AllMessageValues], + dict[str, object], +]: + system_message = cast( + AllMessageValues, + {"role": "system", "content": "Analyze the request"}, + ) + assistant_message = cast( + AllMessageValues, + { + "type": "message", + "id": "msg_1", + "role": "assistant", + "status": "completed", + "content": [ + { + "type": "output_text", + "text": "The code has a bug", + "annotations": [], + } + ], + }, + ) + user_message = cast( + AllMessageValues, + {"role": "user", "content": "Check for security issues"}, + ) + reasoning_item = { + "type": "reasoning", + "id": "rs_1", + "summary": [], + "encrypted_content": "encrypted", + } + original_input = cast( + ResponseInputParam, + [system_message, reasoning_item, assistant_message, user_message], + ) + _, merged_messages, _ = AnthropicCacheControlHook().get_chat_completion_prompt( + model="azure/gpt-5-codex", + messages=[system_message, assistant_message, user_message], + non_default_params={"cache_control_injection_points": [{"location": "message", "role": "system"}]}, + prompt_id=None, + prompt_variables=None, + dynamic_callback_params={}, + ) + return original_input, merged_messages, reasoning_item + + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -263,48 +313,7 @@ class TestResponsesAPIPromptManagement: assert len(passed_messages) == 1 def test_cache_control_hook_preserves_reasoning_items(self): - system_message = cast( - AllMessageValues, - {"role": "system", "content": "Analyze the request"}, - ) - assistant_message = cast( - AllMessageValues, - { - "type": "message", - "id": "msg_1", - "role": "assistant", - "status": "completed", - "content": [ - { - "type": "output_text", - "text": "The code has a bug", - "annotations": [], - } - ], - }, - ) - user_message = cast( - AllMessageValues, - {"role": "user", "content": "Check for security issues"}, - ) - reasoning_item = { - "type": "reasoning", - "id": "rs_1", - "summary": [], - "encrypted_content": "encrypted", - } - original_input = cast( - ResponseInputParam, - [system_message, reasoning_item, assistant_message, user_message], - ) - _, merged_messages, _ = AnthropicCacheControlHook().get_chat_completion_prompt( - model="azure/gpt-5-codex", - messages=[system_message, assistant_message, user_message], - non_default_params={"cache_control_injection_points": [{"location": "message", "role": "system"}]}, - prompt_id=None, - prompt_variables=None, - dynamic_callback_params={}, - ) + original_input, merged_messages, reasoning_item = _make_cache_control_case() logging_obj = _make_logging_obj( merged_model="azure/gpt-5-codex", merged_messages=merged_messages, @@ -332,6 +341,37 @@ class TestResponsesAPIPromptManagement: assert sent_input[1] == reasoning_item assert sent_input[2]["id"] == "msg_1" + def test_all_non_message_input_items_remain_unchanged(self): + reasoning_item = { + "type": "reasoning", + "id": "rs_1", + "summary": [], + "encrypted_content": "encrypted", + } + original_input = cast(ResponseInputParam, [reasoning_item]) + logging_obj = _make_logging_obj( + merged_model="openai/gpt-4o", + merged_messages=[ + cast( + AllMessageValues, + {"role": "system", "content": "Analyze the request"}, + ) + ], + ) + + patches = _patch_responses_dispatch() + with patches[0], patches[1], patches[2], patches[3] as mock_handler: + import litellm + + litellm.responses( + input=original_input, + model="gpt-4o", + prompt_id="all-non-message", + litellm_logging_obj=logging_obj, + ) + + assert mock_handler.call_args.kwargs["input"] == original_input + def test_model_override_re_resolves_provider(self): """[G] When the prompt template overrides the model to a different provider, custom_llm_provider is re-resolved so downstream routing uses the correct provider. @@ -469,3 +509,33 @@ class TestAsyncResponsesAPIPromptManagement: passed_messages = call_kwargs["messages"] assert all(isinstance(m, dict) and "role" in m for m in passed_messages) assert len(passed_messages) == 1 + + @pytest.mark.asyncio + async def test_async_cache_control_hook_preserves_reasoning_items(self): + original_input, merged_messages, reasoning_item = _make_cache_control_case() + logging_obj = _make_logging_obj( + merged_model="azure/gpt-5-codex", + merged_messages=merged_messages, + ) + + patches = _patch_responses_dispatch() + with patches[0], patches[1], patches[2], patches[3] as mock_handler: + import litellm + + await litellm.aresponses( + input=original_input, + model="azure/gpt-5-codex", + litellm_logging_obj=logging_obj, + cache_control_injection_points=[{"location": "message", "role": "system"}], + ) + + sent_input = mock_handler.call_args.kwargs["input"] + assert [item.get("type") for item in sent_input] == [ + None, + "reasoning", + "message", + None, + ] + assert sent_input[0]["cache_control"] == {"type": "ephemeral"} + assert sent_input[1] == reasoning_item + assert sent_input[2]["id"] == "msg_1" From e2e51f055d9a66fd363dd9e56dd32d180e8539a6 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 22 Jul 2026 16:31:03 -0700 Subject: [PATCH 04/19] refactor(proxy): type the PATCH /team/{team_id} request body (#34195) * feat(ui): add react-hook-form + zod form infrastructure Introduce the shared form layer the dashboard's antd forms will migrate onto, with no user-visible change yet. - pin react-hook-form, @hookform/resolvers, and zod (kept on 3.25.76 and imported via the zod/v4 entrypoint so openai's optional zod ^3 peer still resolves and npm ci stays clean) - vendor the base-vega Field family into components/shared/form as forwardRef components on the repo's cva.config, since base-vega ships no form primitive and its field source imports class-variance-authority and is React 19 style - add a FormField bridge that binds a react-hook-form Controller to the Field layer and wires label, description, and error ids into aria attributes - add pickDirty, which narrows a submitted body to the top-level keys the user actually touched so a partial update stops re-sending untouched fields pickDirty reads dirtiness at the top level because react-hook-form tracks it per leaf, so an edited array arrives as [true, false] and a cleared list as an empty array that still carries its default-length dirty markers; the falsy clear tokens (null, [], {}, 0, false) all survive. Tests cover the Field primitives, the FormField aria wiring against a live zod resolver, and pickDirty both as a unit and driven through a real react-hook-form instance. * test(ui): lock pickDirty behavior on a pure field-array reorder react-hook-form compares each array element to its default positionally by value, so useFieldArray move/swap and a reordered scalar array all mark the moved indices dirty and pickDirty sends the whole array; a swap of two equal elements is a value-level no-op and is correctly omitted. Covers the reorder case a review flagged as untested. * feat(proxy): publish a typed request body for PATCH /team/{team_id} The route validated its body into UpdateTeamRequest but read it off the raw request, so the OpenAPI spec carried no requestBody and the dashboard's generated client could not type the call at all. - add PatchTeamRequest, UpdateTeamRequest with an optional team_id, since PATCH takes the id from the path; a body team_id is still accepted when it matches - validate the body through PatchTeamRequest before delegating to update_team - declare the request body on the route and regenerate schema.d.ts The handler keeps reading the raw body rather than declaring a typed parameter. FastAPI validates a declared body before the handler runs, which would replace the 400 for a non-object body with a 422 and move absent-vs-null out of reach of the RFC 7386 metadata merge; those are pinned by existing tests, so the schema is declared on the route instead and every error path is unchanged. Validation is shape-preserving: the body is dumped with exclude_unset so an omitted field never reaches the write, an explicit null still clears, and a partial object_permission does not gain sibling sub-keys, which would wipe them given the column merges rather than replaces. Tests extend the existing patch harness rather than replacing it. * refactor(proxy): declare the PATCH /team/{team_id} body as a typed parameter Replaces the hand-written OpenAPI declaration added earlier in this branch. The route now takes data: PatchTeamRequest, so FastAPI generates the request body itself and emits a $ref to the model instead of an inlined copy that would go stale as fields are added. The earlier approach was a workaround built on a wrong premise. Declaring the body does not cost absent-vs-null: model_fields_set preserves it, which is how POST /team/update already gets its tri-state, and a nested null inside metadata survives validation untouched, so the RFC 7386 merge is unaffected. The one real change is the status code for a malformed body. The route answered 400 for a non-object body and 500 for a wrongly typed field, reporting a caller mistake as a server fault; both are now 422, matching POST /team/update and the other typed management endpoints. The two tests that pinned the old parse-level errors are replaced by one that pins the 422 through the ASGI stack, and the handler drops its manual parsing entirely. --- litellm/proxy/_types.py | 11 ++ .../management_endpoints/team_endpoints.py | 33 ++-- .../test_team_endpoints.py | 151 +++++++++++++++--- ui/litellm-dashboard/src/lib/http/schema.d.ts | 113 ++++++++++++- 4 files changed, 262 insertions(+), 46 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 7df725bf965..20dc14aa5a3 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1856,6 +1856,17 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase): default_team_member_models: Optional[List[str]] = None # default allowed_models seeded onto new team members +class PatchTeamRequest(UpdateTeamRequest): + """ + Body of PATCH /team/{team_id}. + + Identical to UpdateTeamRequest except team_id is optional, because PATCH takes it + from the path. A team_id in the body is still accepted when it matches the path. + """ + + team_id: str | None = None + + class ResetTeamBudgetRequest(LiteLLMPydanticObjectBase): """ internal type used to reset the budget on a team diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 3a7e89aa20d..59b0cbc4ae7 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -47,6 +47,7 @@ from litellm.proxy._types import ( Member, NewTeamRequest, OrgMember, + PatchTeamRequest, ProxyErrorTypes, ProxyException, SpecialManagementEndpointEnums, @@ -1956,6 +1957,7 @@ async def update_team( ) async def patch_team( team_id: str, + data: PatchTeamRequest, http_request: Request, user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], litellm_changed_by: Annotated[ @@ -1968,11 +1970,12 @@ async def patch_team( """ Partially update a team using RFC 7386 JSON Merge Patch semantics. - `team_id` is taken from the path. `metadata` is merged with the team's stored - metadata rather than replacing it: an omitted key is preserved, `key: null` - deletes it, and any other value overwrites (recursing into nested objects). - Every other field behaves exactly like `POST /team/update` (omitted preserves, - a value overwrites). Returns the full updated team. + `team_id` is taken from the path; a `team_id` in the body is accepted only when it + matches. `metadata` is merged with the team's stored metadata rather than replacing + it: an omitted key is preserved, `key: null` deletes it, and any other value + overwrites (recursing into nested objects). Every other field behaves exactly like + `POST /team/update` (omitted preserves, a value overwrites). Returns the full + updated team. ``` curl --location --request PATCH 'http://0.0.0.0:4000/team/8d916b1c-510d-4894-a334-1c16a93344f5' \ @@ -1992,21 +1995,15 @@ async def patch_team( detail={"error": CommonProxyErrors.db_not_connected_error.value}, ) - try: - body = await http_request.json() - except (json.JSONDecodeError, ValueError): - raise HTTPException(status_code=400, detail={"error": "Request body must be a JSON object"}) - if not isinstance(body, dict): - raise HTTPException(status_code=400, detail={"error": "Request body must be a JSON object"}) - - body_team_id = body.pop("team_id", None) - if body_team_id is not None and body_team_id != team_id: + if data.team_id is not None and data.team_id != team_id: raise HTTPException( status_code=400, - detail={"error": f"team_id in body ({body_team_id}) does not match team_id in path ({team_id})"}, + detail={"error": f"team_id in body ({data.team_id}) does not match team_id in path ({team_id})"}, ) - if "metadata" in body: + patch_fields = data.model_dump(exclude_unset=True, exclude={"team_id"}) + + if "metadata" in patch_fields: existing_team_row = await TeamRepository(prisma_client).table.find_unique(where={"team_id": team_id}) if existing_team_row is None: raise HTTPException( @@ -2014,9 +2011,9 @@ async def patch_team( detail={"error": f"Team not found, passed team_id={team_id}"}, ) existing_metadata = existing_team_row.metadata if isinstance(existing_team_row.metadata, dict) else {} - body["metadata"] = apply_json_merge_patch(existing_metadata, body["metadata"]) + patch_fields["metadata"] = apply_json_merge_patch(existing_metadata, patch_fields["metadata"]) - update_request = UpdateTeamRequest(team_id=team_id, **body) + update_request = UpdateTeamRequest(team_id=team_id, **patch_fields) result = await update_team( data=update_request, diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index d5d61341c93..5202c8cbfc0 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -9813,7 +9813,6 @@ async def _drive_team_write( raw_body=None, user=None, find_returns_none=False, - json_side_effect=None, ): """Drive POST ``update_team`` or PATCH ``patch_team`` against a mocked team. @@ -9828,6 +9827,7 @@ async def _drive_team_write( from litellm.proxy._types import ( LiteLLM_TeamTable, LitellmUserRoles, + PatchTeamRequest, UpdateTeamRequest, UserAPIKeyAuth, ) @@ -9873,14 +9873,10 @@ async def _drive_team_write( litellm_changed_by=None, ) else: - if json_side_effect is not None: - req.json = AsyncMock(side_effect=json_side_effect) - else: - req.json = AsyncMock( - return_value=raw_body if raw_body is not None else dict(payload or {}) - ) + body = raw_body if raw_body is not None else dict(payload or {}) result = await patch_team( team_id=_PATCH_TEAM_ID, + data=PatchTeamRequest.model_validate(body), http_request=req, user_api_key_dict=auth, litellm_changed_by=None, @@ -10028,25 +10024,36 @@ async def test_patch_strips_system_managed_metadata_key_like_post(): assert patch_meta == {"cost_center": "9999"} -@pytest.mark.asyncio -@pytest.mark.parametrize("raw_body", [["not", "an", "object"], "a-string", 42, True]) -async def test_patch_rejects_non_object_body(raw_body): - from litellm.proxy._types import ProxyException +@pytest.mark.parametrize( + "kwargs", + [ + {"json": ["not", "an", "object"]}, + {"json": "a-string"}, + {"json": 42}, + {"content": b"{not json"}, + {"json": {"tpm_limit": "not-an-int"}}, + ], + ids=["list", "string", "number", "malformed-json", "wrong-field-type"], +) +def test_patch_rejects_a_malformed_body_with_422(kwargs): + """The body is a declared parameter, so FastAPI rejects a malformed one before the + handler runs. This is the same 422 POST /team/update already returns; the route + previously answered 400 here and 500 for a wrongly typed field, reporting a caller + mistake as a server fault.""" + from fastapi import FastAPI + from fastapi.testclient import TestClient - with pytest.raises(ProxyException) as exc: - await _drive_team_write("patch", existing_metadata={"a": 1}, raw_body=raw_body) - assert exc.value.code == "400" or exc.value.code == 400 + from litellm.proxy._types import PatchTeamRequest + app = FastAPI() -@pytest.mark.asyncio -async def test_patch_rejects_invalid_json_body(): - from litellm.proxy._types import ProxyException + @app.patch("/team/{team_id}") + async def _route(team_id: str, data: PatchTeamRequest): # pragma: no cover - schema only + return {} - with pytest.raises(ProxyException) as exc: - await _drive_team_write( - "patch", existing_metadata={"a": 1}, json_side_effect=ValueError("no body") - ) - assert exc.value.code == "400" or exc.value.code == 400 + response = TestClient(app).patch("/team/abc", **kwargs) + + assert response.status_code == 422 @pytest.mark.asyncio @@ -10116,3 +10123,103 @@ async def test_patch_returns_full_team_object_not_wrapper(): ) assert isinstance(result, LiteLLM_TeamTable) assert result.team_id == _PATCH_TEAM_ID + + +# --------------------------------------------------------------------------- +# PATCH body is validated through PatchTeamRequest before it is handed to +# update_team. The write below must stay byte-identical to what the untyped +# **body construction produced, or a partial update starts writing columns the +# caller never mentioned. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_patch_writes_only_the_keys_the_caller_sent(): + """An omitted field must not reach the DB write at all. If validation ever + materialises defaults, every unmentioned column gets overwritten with null.""" + _, update_mock = await _drive_team_write("patch", raw_body={"tpm_limit": 5}) + written = update_mock.call_args.kwargs["data"] + + assert written["tpm_limit"] == 5 + for untouched in ("rpm_limit", "max_budget", "models", "blocked", "budget_duration"): + assert untouched not in written, f"{untouched} was written despite not being sent" + + +@pytest.mark.asyncio +async def test_patch_preserves_explicit_null_as_a_clear(): + """null is a clear, not an omission: it has to survive validation and reach the write.""" + _, update_mock = await _drive_team_write("patch", raw_body={"max_budget": None}) + written = update_mock.call_args.kwargs["data"] + + assert "max_budget" in written + assert written["max_budget"] is None + + +def _patch_body_to_update_request(body: dict): + """The exact reshaping patch_team performs between the raw body and update_team.""" + from litellm.proxy._types import PatchTeamRequest, UpdateTeamRequest + + parsed = PatchTeamRequest.model_validate(body) + return UpdateTeamRequest( + team_id=_PATCH_TEAM_ID, + **parsed.model_dump(exclude_unset=True, exclude={"team_id"}), + ) + + +@pytest.mark.parametrize( + "body", + [ + {"tpm_limit": 5}, + {"max_budget": None}, + {"object_permission": {"vector_stores": []}}, + {"metadata": {"a": 1, "b": None}}, + {"models": ["gpt-4"], "blocked": False}, + ], + ids=["scalar", "explicit-null", "partial-nested", "metadata-with-null", "list-and-false"], +) +def test_patch_body_reshaping_adds_no_keys_the_caller_did_not_send(body): + """Validating through PatchTeamRequest must be shape-preserving. If it ever + materialises defaults, a partial update silently overwrites untouched columns, + and for the merge-only object_permission it would wipe sibling sub-keys.""" + reshaped = _patch_body_to_update_request(body) + dumped = reshaped.model_dump(exclude_unset=True, exclude={"team_id"}) + + assert dumped == body + assert reshaped.model_fields_set == set(body) | {"team_id"} + + +@pytest.mark.asyncio +async def test_patch_ignores_unknown_body_keys(): + """Unknown keys were silently dropped by the previous construction; keep that.""" + _, update_mock = await _drive_team_write( + "patch", raw_body={"tpm_limit": 5, "not_a_team_field": "x"} + ) + written = update_mock.call_args.kwargs["data"] + + assert written["tpm_limit"] == 5 + assert "not_a_team_field" not in written + + +def test_patch_team_request_makes_team_id_optional(): + """PATCH takes team_id from the path, so the body model must not require it, + while still inheriting every UpdateTeamRequest field.""" + from litellm.proxy._types import PatchTeamRequest, UpdateTeamRequest + + parsed = PatchTeamRequest.model_validate({"tpm_limit": 5}) + + assert parsed.team_id is None + assert parsed.model_fields_set == {"tpm_limit"} + assert set(UpdateTeamRequest.model_fields).issubset(set(PatchTeamRequest.model_fields)) + + +def test_patch_team_route_publishes_its_request_body_schema(): + """The dashboard's generated client types this call off the OpenAPI spec, which + FastAPI can only emit because the body is a declared parameter.""" + from litellm.proxy.proxy_server import app + + operation = app.openapi()["paths"]["/team/{team_id}"]["patch"] + schema = operation["requestBody"]["content"]["application/json"]["schema"] + + assert schema == {"$ref": "#/components/schemas/PatchTeamRequest"} + properties = app.openapi()["components"]["schemas"]["PatchTeamRequest"]["properties"] + assert "tpm_limit" in properties and "metadata" in properties diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index d109fc1783d..e612bddaca1 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -13860,11 +13860,12 @@ export interface paths { * Patch Team * @description Partially update a team using RFC 7386 JSON Merge Patch semantics. * - * `team_id` is taken from the path. `metadata` is merged with the team's stored - * metadata rather than replacing it: an omitted key is preserved, `key: null` - * deletes it, and any other value overwrites (recursing into nested objects). - * Every other field behaves exactly like `POST /team/update` (omitted preserves, - * a value overwrites). Returns the full updated team. + * `team_id` is taken from the path; a `team_id` in the body is accepted only when it + * matches. `metadata` is merged with the team's stored metadata rather than replacing + * it: an omitted key is preserved, `key: null` deletes it, and any other value + * overwrites (recursing into nested objects). Every other field behaves exactly like + * `POST /team/update` (omitted preserves, a value overwrites). Returns the full + * updated team. * * ``` * curl --location --request PATCH 'http://0.0.0.0:4000/team/8d916b1c-510d-4894-a334-1c16a93344f5' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data-raw '{ @@ -28807,6 +28808,102 @@ export interface components { litellm_params?: components["schemas"]["PromptLiteLLMParams"] | null; prompt_info?: components["schemas"]["PromptInfo"] | null; }; + /** + * PatchTeamRequest + * @description Body of PATCH /team/{team_id}. + * + * Identical to UpdateTeamRequest except team_id is optional, because PATCH takes it + * from the path. A team_id in the body is still accepted when it matches the path. + */ + PatchTeamRequest: { + /** Access Group Ids */ + access_group_ids?: string[] | null; + /** Allowed Passthrough Routes */ + allowed_passthrough_routes?: unknown[] | null; + /** Allowed Vector Store Indexes */ + allowed_vector_store_indexes?: components["schemas"]["AllowedVectorStoreIndexItem"][] | null; + /** Blocked */ + blocked?: boolean | null; + /** Budget Duration */ + budget_duration?: string | null; + /** Budget Limits */ + budget_limits?: components["schemas"]["BudgetLimitEntry"][] | null; + /** Default Team Member Models */ + default_team_member_models?: string[] | null; + /** Disable Global Guardrails */ + disable_global_guardrails?: boolean | null; + /** Enforced Batch Output Expires After */ + enforced_batch_output_expires_after?: { + [key: string]: unknown; + } | null; + /** Enforced File Expires After */ + enforced_file_expires_after?: { + [key: string]: unknown; + } | null; + /** Guardrails */ + guardrails?: string[] | null; + /** Max Budget */ + max_budget?: number | null; + /** Mcp Rpm Limit */ + mcp_rpm_limit?: { + [key: string]: number; + } | null; + /** Metadata */ + metadata?: { + [key: string]: unknown; + } | null; + /** Model Aliases */ + model_aliases?: { + [key: string]: unknown; + } | null; + /** Model Rpm Limit */ + model_rpm_limit?: { + [key: string]: number; + } | null; + /** Model Tpm Limit */ + model_tpm_limit?: { + [key: string]: number; + } | null; + /** Models */ + models?: unknown[] | null; + object_permission?: components["schemas"]["LiteLLM_ObjectPermissionBase"] | null; + /** Organization Id */ + organization_id?: string | null; + /** Policies */ + policies?: string[] | null; + /** Prompts */ + prompts?: string[] | null; + /** Router Settings */ + router_settings?: { + [key: string]: unknown; + } | null; + /** Rpm Limit */ + rpm_limit?: number | null; + /** Secret Manager Settings */ + secret_manager_settings?: { + [key: string]: unknown; + } | null; + /** Soft Budget */ + soft_budget?: number | null; + /** Tags */ + tags?: unknown[] | null; + /** Team Alias */ + team_alias?: string | null; + /** Team Id */ + team_id?: string | null; + /** Team Member Budget */ + team_member_budget?: number | null; + /** Team Member Budget Duration */ + team_member_budget_duration?: string | null; + /** Team Member Key Duration */ + team_member_key_duration?: string | null; + /** Team Member Rpm Limit */ + team_member_rpm_limit?: number | null; + /** Team Member Tpm Limit */ + team_member_tpm_limit?: number | null; + /** Tpm Limit */ + tpm_limit?: number | null; + }; /** * PerTestingCriteriaResult * @description Results for a specific testing criteria @@ -50613,7 +50710,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["PatchTeamRequest"]; + }; + }; responses: { /** @description Successful Response */ 200: { From 1ae406953cfd254b5fcbf601a15ff4f8cdd2babf Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:31:33 -0700 Subject: [PATCH 05/19] feat(ui): edit fallback chains from router settings (#32841) * feat(ui): edit fallback chains from router settings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(ui): address review nits on edit fallbacks modal Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(ui): fetch models via react-query in edit fallbacks modal Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Mubashir Osmani Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../Fallbacks/EditFallbacks.test.tsx | 87 ++++++++++++++ .../Fallbacks/EditFallbacks.tsx | 106 ++++++++++++++++++ .../Fallbacks/FallbackGroupConfig.tsx | 13 ++- .../Fallbacks/Fallbacks.test.tsx | 56 ++++++--- .../RouterSettings/Fallbacks/Fallbacks.tsx | 34 +++++- 5 files changed, 278 insertions(+), 18 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.test.tsx create mode 100644 ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.test.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.test.tsx new file mode 100644 index 00000000000..225c308af91 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.test.tsx @@ -0,0 +1,87 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import EditFallbacks, { Fallbacks } from "./EditFallbacks"; +import * as fetchModelsModule from "@/components/llm_calls/fetch_models"; + +vi.mock("@/components/llm_calls/fetch_models", () => ({ + fetchAvailableModels: vi.fn(), +})); + +const renderWithQueryClient = (ui: React.ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render({ui}); +}; + +describe("EditFallbacks", () => { + const accessToken = "test-token"; + const fallbackEntry = { "gpt-4": ["gpt-3.5-turbo", "claude-3-opus"] }; + const value: Fallbacks = [{ "gpt-4": ["gpt-3.5-turbo", "claude-3-opus"] }, { "claude-3-opus": ["gpt-4"] }]; + + const setup = (overrides: Partial> = {}) => { + const onChange = overrides.onChange ?? vi.fn().mockResolvedValue(undefined); + const onClose = overrides.onClose ?? vi.fn(); + renderWithQueryClient( + , + ); + return { onChange, onClose }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchModelsModule.fetchAvailableModels).mockResolvedValue([ + { model_group: "gpt-4", mode: "chat" }, + { model_group: "gpt-3.5-turbo", mode: "chat" }, + { model_group: "claude-3-opus", mode: "chat" }, + { model_group: "gemini-pro", mode: "chat" }, + ]); + }); + + it("prefills the existing fallback chain for the primary model", async () => { + setup(); + await waitFor(() => { + expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument(); + expect(screen.getByText("claude-3-opus")).toBeInTheDocument(); + }); + }); + + it("removes a fallback model and saves only the edited entry", async () => { + const user = userEvent.setup(); + const onChange = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + setup({ onChange, onClose }); + + await screen.findByText("gpt-3.5-turbo"); + await user.click(screen.getByTestId("remove-fallback-gpt-3.5-turbo")); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith([{ "gpt-4": ["claude-3-opus"] }, { "claude-3-opus": ["gpt-4"] }]); + }); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + it("blocks saving with an empty fallback chain", async () => { + const user = userEvent.setup(); + const onChange = vi.fn().mockResolvedValue(undefined); + setup({ fallbackEntry: { "gpt-4": ["gpt-3.5-turbo"] }, onChange }); + + await screen.findByText("gpt-3.5-turbo"); + await user.click(screen.getByTestId("remove-fallback-gpt-3.5-turbo")); + + const saveButton = screen.getByRole("button", { name: /save changes/i }); + expect(saveButton).toBeDisabled(); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx new file mode 100644 index 00000000000..938e1104301 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx @@ -0,0 +1,106 @@ +/** + * Modal for editing an existing fallback entry + * Lets the user add/remove models from a primary model's fallback chain + * Reuses FallbackGroupConfig with the primary model locked + */ + +import { Button } from "antd"; +import { useQuery } from "@tanstack/react-query"; +import { Pencil } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { fetchAvailableModels } from "@/components/llm_calls/fetch_models"; +import NotificationManager from "../../../molecules/notifications_manager"; +import { AddFallbacksModal } from "./AddFallbacksModal"; +import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig"; + +export type FallbackEntry = { [modelName: string]: string[] }; +export type Fallbacks = FallbackEntry[]; + +interface EditFallbacksProps { + accessToken: string; + fallbackEntry: FallbackEntry; + value: Fallbacks; + onChange: (fallbacks: Fallbacks) => Promise; + onClose: () => void; + maxFallbacks?: number; +} + +const toGroup = (entry: FallbackEntry): FallbackGroup => { + const primaryModel = Object.keys(entry)[0] ?? null; + return { + id: "edit", + primaryModel, + fallbackModels: primaryModel ? [...(entry[primaryModel] ?? [])] : [], + }; +}; + +export default function EditFallbacks({ + accessToken, + fallbackEntry, + value, + onChange, + onClose, + maxFallbacks = 10, +}: EditFallbacksProps) { + const [group, setGroup] = useState(() => toGroup(fallbackEntry)); + const [isSaving, setIsSaving] = useState(false); + + const { data: modelGroups = [] } = useQuery({ + queryKey: ["availableModels", "fallbacks"], + queryFn: () => fetchAvailableModels(accessToken), + enabled: Boolean(accessToken), + }); + + const availableModels = useMemo( + () => Array.from(new Set(modelGroups.map((option) => option.model_group))).sort(), + [modelGroups], + ); + + const handleSave = async () => { + const primaryModel = group.primaryModel; + if (!primaryModel) { + return; + } + + const updatedFallbacks = (value || []).map((entry) => + primaryModel in entry ? { ...entry, [primaryModel]: group.fallbackModels } : entry, + ); + + setIsSaving(true); + try { + await onChange(updatedFallbacks); + NotificationManager.success(`Fallbacks for ${primaryModel} updated successfully!`); + onClose(); + } catch (error) { + console.error("Error updating fallbacks:", error); + } finally { + setIsSaving(false); + } + }; + + return ( + + +
+ + +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx index e91c938b87a..381818a53f5 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx @@ -18,9 +18,16 @@ interface FallbackGroupConfigProps { onChange: (updatedGroup: FallbackGroup) => void; availableModels: string[]; maxFallbacks: number; + disablePrimaryModel?: boolean; } -export function FallbackGroupConfig({ group, onChange, availableModels, maxFallbacks }: FallbackGroupConfigProps) { +export function FallbackGroupConfig({ + group, + onChange, + availableModels, + maxFallbacks, + disablePrimaryModel = false, +}: FallbackGroupConfigProps) { // Filter available options for fallbacks (exclude primary only, allow already selected to be shown for deselection) const availableFallbackOptions = availableModels.filter((m) => m !== group.primaryModel); @@ -70,12 +77,13 @@ export function FallbackGroupConfig({ group, onChange, availableModels, maxFallb placeholder="Select primary model" value={group.primaryModel} onChange={handlePrimaryChange} + disabled={disablePrimaryModel} showSearch getPopupContainer={(trigger) => trigger.parentElement || document.body} filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())} options={availableModels.map((m) => ({ label: m, value: m }))} /> - {!group.primaryModel && ( + {!disablePrimaryModel && !group.primaryModel && (
Select a model to begin configuring fallbacks @@ -176,6 +184,7 @@ export function FallbackGroupConfig({ group, onChange, availableModels, maxFallb +
), })); -vi.mock("./prompt_info", () => ({ __esModule: true, default: () => null })); -vi.mock("./add_prompt_form", () => ({ __esModule: true, default: () => null })); -vi.mock("./prompt_editor_view", () => ({ __esModule: true, default: () => null })); +vi.mock("./prompt_info", () => ({ __esModule: true, default: () =>
prompt-info-view
})); +vi.mock("./add_prompt_form", () => ({ + __esModule: true, + default: ({ visible }: { visible: boolean }) => (visible ?
add-prompt-form
: null), +})); +vi.mock("./prompt_editor_view", () => ({ __esModule: true, default: () =>
prompt-editor-view
})); const mockGetPromptsList = vi.mocked(getPromptsList); +const mockDeletePromptCall = vi.mocked(deletePromptCall); + +const renderPanel = (userRole?: string) => + render(); describe("PromptsPanel loading state", () => { beforeEach(() => { vi.clearAllMocks(); + mockGetPromptsList.mockResolvedValue({ prompts: [] } as never); }); it("should resolve the loading state when accessToken is null instead of showing the skeleton forever", async () => { @@ -39,7 +59,7 @@ describe("PromptsPanel loading state", () => { mockGetPromptsList.mockReturnValue( new Promise((resolve) => { resolveFetch = resolve; - }), + }) as never, ); render(); expect(screen.getByText("table-loading")).toBeInTheDocument(); @@ -49,3 +69,134 @@ describe("PromptsPanel loading state", () => { expect(mockGetPromptsList).toHaveBeenCalledWith("sk-test", undefined); }); }); + +describe("PromptsPanel toolbar", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetPromptsList.mockResolvedValue({ prompts: [] } as never); + }); + + it("should offer both create actions to a proxy admin", async () => { + renderPanel("Admin"); + + expect(await screen.findByRole("button", { name: /add new prompt/i })).toBeEnabled(); + expect(screen.getByRole("button", { name: /upload \.prompt file/i })).toBeEnabled(); + }); + + it("should hide both create actions from a read-only viewer", async () => { + renderPanel("Admin Viewer"); + + expect(await screen.findByText("table-loaded")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /add new prompt/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /upload \.prompt file/i })).not.toBeInTheDocument(); + }); + + it("should open the editor view when the add action is used", async () => { + const user = userEvent.setup(); + renderPanel("Admin"); + + await user.click(await screen.findByRole("button", { name: /add new prompt/i })); + + expect(screen.getByText("prompt-editor-view")).toBeInTheDocument(); + expect(screen.queryByTestId("prompt-table")).not.toBeInTheDocument(); + }); + + it("should open the upload form when the upload action is used", async () => { + const user = userEvent.setup(); + renderPanel("Admin"); + + expect(screen.queryByText("add-prompt-form")).not.toBeInTheDocument(); + await user.click(await screen.findByRole("button", { name: /upload \.prompt file/i })); + + expect(screen.getByText("add-prompt-form")).toBeInTheDocument(); + }); + + it("should refetch scoped to the environment picked in the filter", async () => { + const user = userEvent.setup(); + renderPanel("Admin"); + await screen.findByText("table-loaded"); + + expect(screen.getByText("All Environments")).toBeInTheDocument(); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Production")); + + await waitFor(() => expect(mockGetPromptsList).toHaveBeenLastCalledWith("sk-test", "production")); + }); + + it("should show the picked environment by label and clear back to the unfiltered list", async () => { + // Base UI's exit animation never completes in jsdom, so the closing popup keeps + // pointer-events: none and blocks the second open. The clicks still dispatch. + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderPanel("Admin"); + await screen.findByText("table-loaded"); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Production")); + await waitFor(() => expect(screen.getByRole("combobox")).toHaveTextContent("Production")); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("All Environments")); + + await waitFor(() => expect(screen.getByRole("combobox")).toHaveTextContent("All Environments")); + await waitFor(() => expect(mockGetPromptsList).toHaveBeenLastCalledWith("sk-test", undefined)); + }); +}); + +describe("PromptsPanel delete confirmation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetPromptsList.mockResolvedValue({ prompts: [] } as never); + mockDeletePromptCall.mockResolvedValue(undefined as never); + }); + + it("should not delete until the confirmation is accepted", async () => { + const user = userEvent.setup(); + renderPanel("Admin"); + + await user.click(await screen.findByRole("button", { name: "row-delete" })); + + expect(await screen.findByText(/delete prompt: my-prompt/i)).toBeInTheDocument(); + expect(screen.getByText(/cannot be undone/i)).toBeInTheDocument(); + expect(mockDeletePromptCall).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: /^delete$/i })); + + await waitFor(() => expect(mockDeletePromptCall).toHaveBeenCalledWith("sk-test", "prompt-1")); + }); + + it("should abandon the delete when the confirmation is dismissed", async () => { + const user = userEvent.setup(); + renderPanel("Admin"); + + await user.click(await screen.findByRole("button", { name: "row-delete" })); + await screen.findByText(/delete prompt: my-prompt/i); + + await user.click(screen.getByRole("button", { name: /cancel/i })); + + await waitFor(() => expect(screen.queryByText(/delete prompt: my-prompt/i)).not.toBeInTheDocument()); + expect(mockDeletePromptCall).not.toHaveBeenCalled(); + }); + + it("should keep the confirmation up while the delete request is still in flight", async () => { + const user = userEvent.setup(); + let finishDelete: () => void = () => {}; + mockDeletePromptCall.mockReturnValue( + new Promise((resolve) => { + finishDelete = () => resolve(); + }) as never, + ); + renderPanel("Admin"); + + await user.click(await screen.findByRole("button", { name: "row-delete" })); + await screen.findByText(/delete prompt: my-prompt/i); + await user.click(screen.getByRole("button", { name: /^delete$/i })); + await waitFor(() => expect(mockDeletePromptCall).toHaveBeenCalledWith("sk-test", "prompt-1")); + + await user.keyboard("{Escape}"); + expect(screen.getByText(/delete prompt: my-prompt/i)).toBeInTheDocument(); + + finishDelete(); + await waitFor(() => expect(screen.queryByText(/delete prompt: my-prompt/i)).not.toBeInTheDocument()); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx index de461ebd86d..9bebabb8cf2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from "react"; -import { Button } from "@tremor/react"; -import { Modal, Select } from "antd"; +import { Plus, Upload } from "lucide-react"; import { getPromptsList, PromptSpec, ListPromptsResponse, deletePromptCall } from "@/components/networking"; import PromptTable from "./PromptTable"; import PromptInfoView from "./prompt_info"; @@ -9,6 +8,28 @@ import AddPromptForm from "./add_prompt_form"; import PromptEditorView from "./prompt_editor_view"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +const ALL_ENVIRONMENTS_LABEL = "All Environments"; + +const ENVIRONMENT_OPTIONS = [ + { label: "Development", value: "development" }, + { label: "Staging", value: "staging" }, + { label: "Production", value: "production" }, +]; + +// SelectValue falls back to the raw value unless the root can map it to a label. +const ENVIRONMENT_ITEMS = [{ label: ALL_ENVIRONMENTS_LABEL, value: null }, ...ENVIRONMENT_OPTIONS]; interface PromptsProps { accessToken: string | null; @@ -141,26 +162,33 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { {canModify && ( <> )} = ({ accessToken, userRole }) => { /> {promptToDelete && ( - { + if (!open && !isDeleting) handleDeleteCancel(); + }} > -

Are you sure you want to delete prompt: {promptToDelete.name} ?

-

This action cannot be undone.

-
+ + + Delete Prompt + + Are you sure you want to delete prompt: {promptToDelete.name} ? This action cannot be undone. + + + + Cancel + + + + )} ); From 692e6d48e983832939d7e3e26c911e21c9ee7ef3 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 22 Jul 2026 17:01:13 -0700 Subject: [PATCH 09/19] refactor(ui): migrate old-usage to shadcn (#34304) * test(ui): characterise the old usage page before migrating it Role- and text-based coverage of the route as it behaves on Tremor, so the shadcn migration has a regression net it did not get to write. Pins the DISABLE_EXPENSIVE_DB_QUERIES branch (warning copy, the docs link and its target, and that every expensive query is skipped), the admin vs non-admin tab set, the cost cards, and the provider and customer tables * refactor(ui): migrate old-usage to shadcn Replaces Tremor with the installed shadcn primitives and the shared recharts wrappers on the only file the route owns. Tabs, cards, tables, the key select and the tag multi-select come from src/components/ui; the bar, area and donut charts come from src/components/shared/charts. Tremor BarList has no shared equivalent, so Total Spend Per Team is composed from ui/meter, which also means the per-team totals stay numbers in state instead of pre-formatted strings; a team total of 1,000 or more used to make the bar widths NaN. The Database Query Limit Reached warning moves with it: same copy, same docs link, still short-circuiting every expensive query. Drops the file's no-restricted-imports suppression and the dead customTooltip, getTopKeys, DataDict and UserData symbols. The characterisation test from the previous commit is unchanged and green on both sides --- ui/litellm-dashboard/eslint-suppressions.json | 3 - .../old-usage/_components/usage.test.tsx | 202 +++++ .../old-usage/_components/usage.tsx | 855 +++++++++--------- 3 files changed, 622 insertions(+), 438 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.test.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index f8927becba0..b9b27e6cbb5 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -664,9 +664,6 @@ } }, "src/app/(dashboard)/old-usage/_components/usage.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/immutability": { "count": 1 }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.test.tsx new file mode 100644 index 00000000000..e3db50b7300 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.test.tsx @@ -0,0 +1,202 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../../../tests/test-utils"; +import UsagePage from "./usage"; + +const networking = vi.hoisted(() => ({ + adminSpendLogsCall: vi.fn(), + adminTopKeysCall: vi.fn(), + adminTopModelsCall: vi.fn(), + adminTopEndUsersCall: vi.fn(), + teamSpendLogsCall: vi.fn(), + tagsSpendLogsCall: vi.fn(), + allTagNamesCall: vi.fn(), + adminspendByProvider: vi.fn(), + adminGlobalActivity: vi.fn(), + adminGlobalActivityPerModel: vi.fn(), + getProxyUISettings: vi.fn(), + modelAvailableCall: vi.fn(), + keyInfoV1Call: vi.fn(), +})); + +vi.mock("@/components/networking", () => networking); +vi.mock("../../../../components/networking", () => networking); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ + accessToken: "sk-test", + token: "tok", + userRole: "Admin", + userId: "u1", + premiumUser: true, + }), +})); + +const UNLIMITED_SETTINGS = { DISABLE_EXPENSIVE_DB_QUERIES: false, NUM_SPEND_LOGS_ROWS: 10 }; + +const renderUsage = (overrides: Partial> = {}) => + renderWithProviders( + , + ); + +beforeEach(() => { + vi.clearAllMocks(); + networking.getProxyUISettings.mockResolvedValue(UNLIMITED_SETTINGS); + networking.adminSpendLogsCall.mockResolvedValue([{ date: "2026-07-01", spend: 12.5 }]); + networking.adminTopKeysCall.mockResolvedValue([ + { api_key: "sk-abcdefghijk", key_alias: "prod-key", total_spend: 9.5 }, + ]); + networking.adminTopModelsCall.mockResolvedValue([{ model: "gpt-5.1", total_spend: 7.25 }]); + networking.adminTopEndUsersCall.mockResolvedValue([ + { end_user: "customer-alpha", total_spend: 3.5, total_count: 42 }, + ]); + networking.teamSpendLogsCall.mockResolvedValue({ + daily_spend: [{ date: "2026-07-01", "team-a": 5 }], + teams: ["team-a"], + total_spend_per_team: [{ team_id: "team-a", total_spend: 5 }], + }); + networking.tagsSpendLogsCall.mockResolvedValue({ spend_per_tag: [{ name: "prod", spend: 4 }] }); + networking.allTagNamesCall.mockResolvedValue({ tag_names: ["prod", "staging"] }); + networking.adminspendByProvider.mockResolvedValue([{ provider: "openai", spend: 6.75 }]); + networking.adminGlobalActivity.mockResolvedValue({ + sum_api_requests: 120, + sum_total_tokens: 4500, + daily_data: [{ date: "2026-07-01", api_requests: 120, total_tokens: 4500 }], + }); + networking.adminGlobalActivityPerModel.mockResolvedValue([]); + networking.modelAvailableCall.mockResolvedValue({ data: [] }); + networking.keyInfoV1Call.mockResolvedValue({ info: {} }); +}); + +describe("old usage page", () => { + describe("when the proxy has disabled expensive DB queries", () => { + beforeEach(() => { + networking.getProxyUISettings.mockResolvedValue({ + DISABLE_EXPENSIVE_DB_QUERIES: true, + NUM_SPEND_LOGS_ROWS: 2500000, + }); + }); + + it("shows the database query limit warning instead of the usage dashboard", async () => { + renderUsage(); + + expect(await screen.findByText("Database Query Limit Reached")).toBeInTheDocument(); + expect(screen.getByText(/SpendLogs in DB has/)).toHaveTextContent("2500000"); + expect(screen.getByText(/Please follow our guide to view usage when SpendLogs has more than 1M rows/i)); + expect(screen.queryByRole("tab", { name: "All Up" })).not.toBeInTheDocument(); + }); + + it("links to the cost tracking guide in a new tab", async () => { + renderUsage(); + + const link = await screen.findByRole("link", { name: "View Usage Guide" }); + expect(link).toHaveAttribute("href", "https://docs.litellm.ai/docs/proxy/cost_tracking"); + expect(link).toHaveAttribute("target", "_blank"); + }); + + it("skips every expensive usage query", async () => { + renderUsage(); + + await screen.findByText("Database Query Limit Reached"); + await waitFor(() => expect(networking.getProxyUISettings).toHaveBeenCalled()); + + expect(networking.adminSpendLogsCall).not.toHaveBeenCalled(); + expect(networking.adminspendByProvider).not.toHaveBeenCalled(); + expect(networking.adminTopKeysCall).not.toHaveBeenCalled(); + expect(networking.adminTopModelsCall).not.toHaveBeenCalled(); + expect(networking.adminGlobalActivity).not.toHaveBeenCalled(); + expect(networking.adminGlobalActivityPerModel).not.toHaveBeenCalled(); + expect(networking.teamSpendLogsCall).not.toHaveBeenCalled(); + expect(networking.adminTopEndUsersCall).not.toHaveBeenCalled(); + expect(networking.tagsSpendLogsCall).not.toHaveBeenCalled(); + }); + }); + + describe("as an admin", () => { + it("renders the admin tabs", async () => { + renderUsage(); + + expect(await screen.findByRole("tab", { name: "All Up" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Team Based Usage" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Customer Usage" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Tag Based Usage" })).toBeInTheDocument(); + }); + + it("renders the cost panel cards", async () => { + renderUsage(); + + expect(await screen.findByText("Monthly Spend")).toBeInTheDocument(); + expect(screen.getByText("Top Virtual Keys")).toBeInTheDocument(); + expect(screen.getByText("Top Models")).toBeInTheDocument(); + expect(screen.getByText("Spend by Provider")).toBeInTheDocument(); + }); + + it("lists spend by provider in a table", async () => { + renderUsage(); + + const providerCell = await screen.findByText("openai"); + const row = providerCell.closest("tr"); + expect(row).not.toBeNull(); + expect(within(row as HTMLElement).getByText("$6.75")).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Provider" })).toBeInTheDocument(); + }); + + it("shows the customer usage table when its tab is selected", async () => { + const user = userEvent.setup(); + renderUsage(); + + await user.click(await screen.findByRole("tab", { name: "Customer Usage" })); + + const customerCell = await screen.findByText("customer-alpha"); + const row = customerCell.closest("tr"); + expect(row).not.toBeNull(); + expect(within(row as HTMLElement).getByText("$3.50")).toBeInTheDocument(); + expect(within(row as HTMLElement).getByText("42")).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Total Events" })).toBeInTheDocument(); + }); + + it("shows the tag spend panel when its tab is selected", async () => { + const user = userEvent.setup(); + renderUsage(); + + await user.click(await screen.findByRole("tab", { name: "Tag Based Usage" })); + + expect(await screen.findByText("Spend Per Tag")).toBeInTheDocument(); + }); + + it("shows the team spend panel when its tab is selected", async () => { + const user = userEvent.setup(); + renderUsage(); + + await user.click(await screen.findByRole("tab", { name: "Team Based Usage" })); + + expect(await screen.findByText("Total Spend Per Team")).toBeInTheDocument(); + expect(screen.getByText("Daily Spend Per Team")).toBeInTheDocument(); + }); + }); + + describe("as a non-admin", () => { + it("renders only the All Up tab and skips admin-only queries", async () => { + renderUsage({ userRole: "Internal User" }); + + expect(await screen.findByRole("tab", { name: "All Up" })).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Team Based Usage" })).not.toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Customer Usage" })).not.toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Tag Based Usage" })).not.toBeInTheDocument(); + + await waitFor(() => expect(networking.adminSpendLogsCall).toHaveBeenCalled()); + expect(networking.teamSpendLogsCall).not.toHaveBeenCalled(); + expect(networking.adminTopEndUsersCall).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx index 01f8cb1cd45..3d55f9bb698 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx @@ -1,40 +1,26 @@ -import { - BarChart, - BarList, - Card, - Title, - Table, - TableHead, - TableHeaderCell, - TableRow, - TableCell, - TableBody, - Subtitle, -} from "@tremor/react"; - import React, { useState, useEffect } from "react"; import ViewUserSpend from "@/components/view_user_spend"; import { ProxySettings } from "@/components/user_dashboard"; import UsageDatePicker from "@/components/shared/usage_date_picker"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { - Grid, - Col, - Text, - TabPanel, - TabPanels, - TabGroup, - TabList, - Tab, - Select, - SelectItem, - DateRangePickerValue, - DonutChart, - AreaChart, - Button, - MultiSelect, - MultiSelectItem, -} from "@tremor/react"; + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, +} from "@/components/ui/combobox"; +import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { AreaChart, BarChart, DonutChart } from "@/components/shared/charts"; import { adminSpendLogsCall, @@ -68,69 +54,41 @@ interface GlobalActivityData { daily_data: { date: string; api_requests: number; total_tokens: number }[]; } -type CustomTooltipTypeBar = { - payload: any; - active: boolean | undefined; - label: any; -}; +type UsageDateRange = { from?: Date; to?: Date }; -const customTooltip = (props: CustomTooltipTypeBar) => { - const { payload, active } = props; - if (!active || !payload) return null; +type TeamSpendTotal = { name: string; value: number }; - const value = payload[0].payload; - const date = value["startTime"]; - const model_values = value["models"]; - const entries: [string, number][] = Object.entries(model_values).map(([key, value]) => [key, value as number]); +type TagOption = { value: string; label: string; disabled: boolean }; - entries.sort((a, b) => b[1] - a[1]); - const topEntries = entries.slice(0, 5); - - return ( -
- {date} - {topEntries.map(([key, value]) => ( -
-
-

- {key} - {":"} - - {" "} - {value ? `$${formatNumberWithCommas(value, 2)}` : ""} - -

-
-
- ))} -
- ); -}; - -function getTopKeys(data: Array<{ [key: string]: unknown }>): any[] { - const spendKeys: { key: string; spend: unknown }[] = []; - - data.forEach((dict) => { - Object.entries(dict).forEach(([key, value]) => { - if (key !== "spend" && key !== "startTime" && key !== "models" && key !== "users") { - spendKeys.push({ key, spend: value }); - } - }); - }); - - spendKeys.sort((a, b) => Number(b.spend) - Number(a.spend)); - - const topKeys = spendKeys.slice(0, 5).map((k) => k.key); - return topKeys; -} -type DataDict = { [key: string]: unknown }; -type UserData = { user_id: string; spend: number }; +const ALL_TAGS = "all-tags"; const isAdminOrAdminViewer = (role: string | null): boolean => { if (role === null) return false; return role === "Admin" || role === "Admin Viewer"; }; +const TeamSpendBarList: React.FC<{ data: TeamSpendTotal[] }> = ({ data }) => { + const max = Math.max(0, ...data.map((team) => team.value)); + + return ( +
+ {data.map((team) => ( +
+

{team.name}

+ + + + + +

+ {formatNumberWithCommas(team.value, 2)} +

+
+ ))} +
+ ); +}; + const UsagePage: React.FC = ({ accessToken, token, userRole, userID, keys, premiumUser }) => { const currentDate = new Date(); const [keySpendData, setKeySpendData] = useState([]); @@ -141,13 +99,13 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use const [topTagsData, setTopTagsData] = useState([]); const [allTagNames, setAllTagNames] = useState([]); const [uniqueTeamIds, setUniqueTeamIds] = useState([]); - const [totalSpendPerTeam, setTotalSpendPerTeam] = useState([]); + const [totalSpendPerTeam, setTotalSpendPerTeam] = useState([]); const [spendByProvider, setSpendByProvider] = useState([]); const [globalActivity, setGlobalActivity] = useState({} as GlobalActivityData); const [globalActivityPerModel, setGlobalActivityPerModel] = useState([]); - const [selectedKeyID, setSelectedKeyID] = useState(""); - const [selectedTags, setSelectedTags] = useState(["all-tags"]); - const [dateValue, setDateValue] = useState({ + const [selectedKeyToken, setSelectedKeyToken] = useState(null); + const [selectedTags, setSelectedTags] = useState([ALL_TAGS]); + const [dateValue, setDateValue] = useState({ from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), to: new Date(), }); @@ -160,6 +118,21 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use let startTime = formatDate(firstDay); let endTime = formatDate(lastDay); + const selectableKeys: { token: string; alias: string }[] = (keys ?? []) + .filter((key: any) => key && typeof key["key_alias"] === "string" && key["key_alias"].length > 0) + .map((key: any) => ({ token: String(key["token"]), alias: String(key["key_alias"]) })); + + const tagOptions: TagOption[] = [ + { value: ALL_TAGS, label: "All Tags", disabled: false }, + ...allTagNames + .filter((tag) => tag !== ALL_TAGS) + .map((tag) => ({ + value: tag, + label: premiumUser ? tag : `✨ ${tag} (Enterprise only Feature)`, + disabled: !premiumUser, + })), + ]; + function valueFormatterNumbers(number: number) { const formatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0, @@ -405,7 +378,7 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use setUniqueTeamIds(teamSpend.teams); return teamSpend.total_spend_per_team.map((tspt: any) => ({ name: tspt["team_id"] || "", - value: formatNumberWithCommas(tspt["total_spend"] || 0, 2), + value: Number(tspt["total_spend"] || 0), })); }, setTotalSpendPerTeam, @@ -524,223 +497,252 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use if (proxySettings?.DISABLE_EXPENSIVE_DB_QUERIES) { return ( -
+
- Database Query Limit Reached - - SpendLogs in DB has {proxySettings.NUM_SPEND_LOGS_ROWS} rows. -

- Please follow our guide to view usage when SpendLogs has more than 1M rows. -
- + + Database Query Limit Reached + + +

+ SpendLogs in DB has {proxySettings.NUM_SPEND_LOGS_ROWS} rows. +

+ Please follow our guide to view usage when SpendLogs has more than 1M rows. +

+
); } return ( -
- - - All Up +
+ + + All Up - {isAdminOrAdminViewer(userRole) ? ( + {isAdminOrAdminViewer(userRole) && ( <> - Team Based Usage - Customer Usage - Tag Based Usage - - ) : ( - <> -
+ Team Based Usage + Customer Usage + Tag Based Usage )} - - - - - - Cost - Activity - - - - - - - Project Spend {new Date().toLocaleString("default", { month: "long" })} 1 -{" "} - {new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).getDate()} - - - - - - Monthly Spend - + + + + + Cost + Activity + + + +
+
+

+ Project Spend {new Date().toLocaleString("default", { month: "long" })} 1 -{" "} + {new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).getDate()} +

+ +
+
+ + + Monthly Spend + + + + + +
+
+ + + Top Virtual Keys + + + {}} /> + + +
+
+ + + Top Models + + + `$${formatNumberWithCommas(value, 2)}`} + /> + + +
+
+
+ + + Spend by Provider + + +
+
+ `$${formatNumberWithCommas(value, 2)}`} + /> +
+
+ + + + Provider + Spend + + + + {spendByProvider.map((provider) => ( + + {provider.provider} + + + + + ))} + +
+
+
+
+
+
+
+
+ + +
+ + + All Up + + +
+
+

+ API Requests {valueFormatterNumbers(globalActivity.sum_api_requests)} +

+ - - - - - Top Virtual Keys - {}} /> - - - - - Top Models +
+
+

+ Tokens {valueFormatterNumbers(globalActivity.sum_total_tokens)} +

`$${formatNumberWithCommas(value, 2)}`} + categories={["total_tokens"]} /> - - - - - - Spend by Provider - <> - - - `$${formatNumberWithCommas(value, 2)}`} - /> - - - - - - Provider - Spend - - - - {spendByProvider.map((provider) => ( - - {provider.provider} - - - - - ))} - -
- -
- -
- - - - - - - All Up - - - +
+
+
+
+ + {globalActivityPerModel.map((globalActivity, index) => ( + + + {globalActivity.model} + + +
+
+

API Requests {valueFormatterNumbers(globalActivity.sum_api_requests)} - +

- - - +
+
+

Tokens {valueFormatterNumbers(globalActivity.sum_total_tokens)} - +

- - - +
+
+
+
+ ))} +
+
+
+
- <> - {globalActivityPerModel.map((globalActivity, index) => ( - - {globalActivity.model} - - - - API Requests {valueFormatterNumbers(globalActivity.sum_api_requests)} - - - - - - Tokens {valueFormatterNumbers(globalActivity.sum_total_tokens)} - - - - - - ))} - -
-
-
-
-
- - - - - Total Spend Per Team - - - - Daily Spend Per Team + +
+
+ + + Total Spend Per Team + + + + + + + + Daily Spend Per Team + + = ({ accessToken, token, userRole, use yAxisWidth={80} stack={true} /> - - - - - - -

- Customers of your LLM API calls. Tracked when a `user` param is passed in your LLM calls{" "} - - docs here - -

- - - { - setDateValue(value); - updateEndUserData(value.from, value.to, null); - }} - /> - - - Select Key - - - + + +
+
+
- - - - - Customer - Spend - Total Events - - - - - {topUsers?.map((user: any, index: number) => ( - - {user.end_user} - - - - {user.total_count} - + +

+ Customers of your LLM API calls. Tracked when a `user` param is passed in your LLM calls{" "} + + docs here + +

+
+
+ { + setDateValue(value); + updateEndUserData(value.from, value.to, null); + }} + /> +
+
+

Select Key

+
-
-
- - - - { - setDateValue(value); - updateTagSpendData(value.from, value.to); - }} - /> - + + +
+
- - {premiumUser ? ( -
- setSelectedTags(value as string[])}> - setSelectedTags(["all-tags"])} - > - All Tags - - {allTagNames && - allTagNames - .filter((tag) => tag !== "all-tags") - .map((tag: any, index: number) => { - return ( - - {tag} - - ); - })} - -
- ) : ( -
- setSelectedTags(value as string[])}> - setSelectedTags(["all-tags"])} - > - All Tags - - {allTagNames && - allTagNames - .filter((tag) => tag !== "all-tags") - .map((tag: any, index: number) => { - return ( - - ✨ {tag} (Enterprise only Feature) - - ); - })} - -
- )} - - - - - - Spend Per Tag - + + +
+ + + + Customer + Spend + Total Events + + + + + {topUsers?.map((user: any, index: number) => ( + + {user.end_user} + + + + {user.total_count} + + ))} + +
+
+
+
+ + + +
+
+ { + setDateValue(value); + updateTagSpendData(value.from, value.to); + }} + /> +
+ +
+ selectedTags.includes(option.value))} + onValueChange={(options: TagOption[]) => setSelectedTags(options.map((option) => option.value))} + isItemEqualToValue={(a: TagOption, b: TagOption) => a.value === b.value} + itemToStringLabel={(option: TagOption) => option.label} + > + + + {(options: TagOption[]) => + options.map((option) => ( + + {option.label} + + )) + } + + + + + No tags found + + {(option: TagOption) => ( + + {option.label} + + )} + + + +
+
+
+
+ + + Spend Per Tag + + +

Get Started by Tracking cost per tag{" "} here - - - - - - - - - +

+ +
+
+
+
+
+
); }; From 169ba0e287e9993f0b2f1a5d229c26d3d64d39bb Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 22 Jul 2026 17:01:41 -0700 Subject: [PATCH 10/19] refactor(ui): migrate transform-request to shadcn (#34303) * test(ui): characterise transform-request panel behaviour before migration * refactor(ui): migrate transform-request to shadcn * fix(ui): keep transform-request panels within the fixed-height content fold * fix(ui): let transform-request flow naturally so the shell scrolls instead of clipping * test(ui): select the copy button by its accessible name --- ui/litellm-dashboard/eslint-suppressions.json | 5 - .../TransformRequestPanel.test.tsx | 160 ++++++++++++++ .../TransformRequestPanel.tsx | 205 ++++++------------ 3 files changed, 226 insertions(+), 144 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.test.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index b9b27e6cbb5..269348ec054 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1098,11 +1098,6 @@ "count": 1 } }, - "src/app/(dashboard)/transform-request/TransformRequestPanel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/ui-theme/UIThemeSettings.tsx": { "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.test.tsx new file mode 100644 index 00000000000..a0add153116 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.test.tsx @@ -0,0 +1,160 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import TransformRequestPanel from "./TransformRequestPanel"; +import { transformRequestCall } from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +vi.mock("@/components/networking", () => ({ + transformRequestCall: vi.fn(), +})); + +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { + success: vi.fn(), + info: vi.fn(), + fromBackend: vi.fn(), + }, +})); + +const transformRequestCallMock = vi.mocked(transformRequestCall); +const notify = vi.mocked(NotificationsManager); + +const ACCESS_TOKEN = "sk-test-token"; + +const getRequestTextarea = () => screen.getByPlaceholderText(/press cmd\/ctrl \+ enter to transform/i); + +const getTransformButton = () => screen.getByRole("button", { name: /transform/i }); + +const getCopyButton = () => screen.getByRole("button", { name: /copy to clipboard/i }); + +describe("TransformRequestPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders both panels, the prefilled request and the placeholder curl", () => { + render(); + + expect(screen.getByText("Original Request")).toBeInTheDocument(); + expect(screen.getByText("Transformed Request")).toBeInTheDocument(); + expect(screen.getByText(/sensitive headers are not shown/i)).toBeInTheDocument(); + + expect((getRequestTextarea() as HTMLTextAreaElement).value).toContain('"model": "openai/gpt-4o"'); + expect(screen.getByText(/https:\/\/api\.openai\.com\/v1\/chat\/completions/)).toBeInTheDocument(); + + expect(screen.getByRole("link", { name: /here/i })).toHaveAttribute( + "href", + "https://github.com/BerriAI/litellm/issues", + ); + }); + + it("sends the edited request body as a completion call and renders the returned curl", async () => { + const user = userEvent.setup(); + transformRequestCallMock.mockResolvedValue({ + raw_request_api_base: "https://api.anthropic.com/v1/messages", + raw_request_body: { model: "claude-opus-4-8", max_tokens: 42 }, + raw_request_headers: { "x-api-key": "redacted" }, + }); + + render(); + + const textarea = getRequestTextarea(); + await user.clear(textarea); + await user.type(textarea, '{{"model": "claude-opus-4-8"}'); + + await user.click(getTransformButton()); + + await waitFor(() => expect(transformRequestCallMock).toHaveBeenCalledTimes(1)); + expect(transformRequestCallMock).toHaveBeenCalledWith(ACCESS_TOKEN, { + call_type: "completion", + request_body: { model: "claude-opus-4-8" }, + }); + + const output = await screen.findByText(/api\.anthropic\.com\/v1\/messages/); + expect(output.textContent).toContain("curl -X POST"); + expect(output.textContent).toContain("-H 'x-api-key: redacted'"); + expect(output.textContent).toContain('"model": "claude-opus-4-8"'); + expect(output.textContent).toContain('"max_tokens": 42'); + expect(notify.success).toHaveBeenCalledWith("Request transformed successfully"); + }); + + it("transforms on Cmd/Ctrl + Enter without clicking the button", async () => { + const user = userEvent.setup(); + transformRequestCallMock.mockResolvedValue({ + raw_request_api_base: "https://api.openai.com/v1/chat/completions", + raw_request_body: { model: "gpt-4o" }, + raw_request_headers: {}, + }); + + render(); + + getRequestTextarea().focus(); + await user.keyboard("{Meta>}{Enter}{/Meta}"); + + await waitFor(() => expect(transformRequestCallMock).toHaveBeenCalledTimes(1)); + }); + + it("rejects invalid JSON without calling the backend", async () => { + const user = userEvent.setup(); + + render(); + + const textarea = getRequestTextarea(); + await user.clear(textarea); + await user.type(textarea, "not json"); + await user.click(getTransformButton()); + + await waitFor(() => expect(notify.fromBackend).toHaveBeenCalledWith("Invalid JSON in request body")); + expect(transformRequestCallMock).not.toHaveBeenCalled(); + }); + + it("does not call the backend when there is no access token", async () => { + const user = userEvent.setup(); + + render(); + + await user.click(getTransformButton()); + + await waitFor(() => expect(notify.fromBackend).toHaveBeenCalledWith("No access token found")); + expect(transformRequestCallMock).not.toHaveBeenCalled(); + }); + + it("reports a failed transform and leaves the placeholder curl in place", async () => { + const user = userEvent.setup(); + vi.spyOn(console, "error").mockImplementation(() => {}); + transformRequestCallMock.mockRejectedValue(new Error("boom")); + + render(); + + await user.click(getTransformButton()); + + await waitFor(() => expect(notify.fromBackend).toHaveBeenCalledWith("Failed to transform request")); + expect(screen.getByText(/https:\/\/api\.openai\.com\/v1\/chat\/completions/)).toBeInTheDocument(); + }); + + it("copies the transformed request to the clipboard", async () => { + const user = userEvent.setup(); + const writeText = vi.spyOn(navigator.clipboard, "writeText"); + transformRequestCallMock.mockResolvedValue({ + raw_request_api_base: "https://api.anthropic.com/v1/messages", + raw_request_body: { model: "claude-opus-4-8" }, + raw_request_headers: {}, + }); + + render(); + + await user.click(getTransformButton()); + await screen.findByText(/api\.anthropic\.com\/v1\/messages/); + + await user.click(getCopyButton()); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0]?.[0]).toContain("https://api.anthropic.com/v1/messages"); + expect(notify.success).toHaveBeenCalledWith("Copied to clipboard"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.tsx index 04d1701de3f..0c41547b9b7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.tsx @@ -1,9 +1,12 @@ import React, { useState } from "react"; -import { Button } from "antd"; -import { CopyOutlined } from "@ant-design/icons"; -import { Title } from "@tremor/react"; +import { ArrowRight, Copy } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { transformRequestCall } from "@/components/networking"; import NotificationsManager from "@/components/molecules/notifications_manager"; + interface TransformRequestPanelProps { accessToken: string | null; } @@ -128,130 +131,50 @@ ${formattedBody} }; return ( -
- Playground -

See how LiteLLM transforms your request for the specified provider.

-
+
+

Playground

+

+ See how LiteLLM transforms your request for the specified provider. +

+
{/* Original Request Panel */} -
-
-

Original Request

-

- The request you would send to LiteLLM /chat/completions endpoint. -

-
+ + + Original Request + The request you would send to LiteLLM /chat/completions endpoint. + -