From 4429742e834377b666cc255ea0bceca22f2dc7b0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:11:44 +0000 Subject: [PATCH 01/38] fix(proxy): fetch background responses through the router in CheckResponsesCost Closes #35131 --- .../common_utils/check_responses_cost.py | 49 ++-- .../test_check_responses_cost.py | 216 ++++++++++++++++++ 2 files changed, 250 insertions(+), 15 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py index dc0168683c8..5a587de12e9 100644 --- a/enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py +++ b/enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py @@ -1,10 +1,10 @@ """ Polls LiteLLM_ManagedObjectTable to check if the response is complete. -Cost tracking is handled automatically by litellm.aget_responses(). +Cost tracking is handled automatically by the get-responses call. """ from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, Optional, cast import litellm from litellm._logging import verbose_proxy_logger @@ -13,11 +13,15 @@ from litellm.constants import ( MAX_OBJECTS_PER_POLL_CYCLE, STALE_OBJECT_CLEANUP_BATCH_SIZE, ) +from litellm.responses.utils import ResponsesAPIRequestUtils +from litellm.types.llms.openai import ResponsesAPIResponse if TYPE_CHECKING: from litellm.proxy.utils import PrismaClient, ProxyLogging from litellm.router import Router +TERMINAL_RESPONSE_STATUSES = frozenset({"completed", "failed", "cancelled", "incomplete"}) + class CheckResponsesCost: def __init__( @@ -33,6 +37,28 @@ class CheckResponsesCost: self.prisma_client: PrismaClient = prisma_client self.llm_router: Router = llm_router + async def _get_response( + self, + response_id: str, + litellm_metadata: Dict[str, str], + ) -> ResponsesAPIResponse: + """Fetch the upstream response, using deployment credentials when available. + + LiteLLM-encoded response IDs carry the ``model_id`` of the deployment that + served the original request, so routing through ``llm_router`` applies that + deployment's ``api_base`` / ``api_key`` / ``api_version``, exactly like + ``GET /v1/responses/{id}`` does. ``litellm.aget_responses`` on its own only + sees provider env vars, so it fails for every deployment whose credentials + live in the config; the row then never leaves ``queued``. + """ + model_id: Optional[str] = ResponsesAPIRequestUtils.get_model_id_from_response_id(response_id) + if model_id is None: + return await litellm.aget_responses(response_id=response_id, litellm_metadata=litellm_metadata) + router_response = await self.llm_router.aget_responses( + response_id=response_id, litellm_metadata=litellm_metadata + ) + return cast(ResponsesAPIResponse, router_response) + async def _expire_stale_rows( self, cutoff: datetime, batch_size: int ) -> int: @@ -87,8 +113,8 @@ class CheckResponsesCost: Check if background responses are complete and track their cost. - Get all status="queued" or "in_progress" and file_purpose="response" jobs - Query the provider to check if response is complete - - Cost is automatically tracked by litellm.aget_responses() - - Mark completed/failed/cancelled responses as complete in the database + - Cost is automatically tracked by the get-responses call + - Mark responses in a terminal state as complete in the database """ try: await self._cleanup_stale_managed_objects() @@ -134,7 +160,7 @@ class CheckResponsesCost: litellm_metadata["model"] = model_name litellm_metadata["model_group"] = model_name # Use same value for model_group - response = await litellm.aget_responses( + response = await self._get_response( response_id=responses_id_security, litellm_metadata=litellm_metadata, ) @@ -144,21 +170,14 @@ class CheckResponsesCost: ) except Exception as e: - verbose_proxy_logger.info( + verbose_proxy_logger.warning( f"Skipping job {unified_object_id} due to error: {e}" ) continue - # Check if response is in a terminal state - if response.status == "completed": + if response.status in TERMINAL_RESPONSE_STATUSES: verbose_proxy_logger.info( - f"Response {unified_object_id} is complete. Cost automatically tracked by aget_responses." - ) - completed_jobs.append(job) - - elif response.status in ["failed", "cancelled"]: - verbose_proxy_logger.info( - f"Response {unified_object_id} has status {response.status}, marking as complete" + f"Response {unified_object_id} has terminal status {response.status}, marking as complete" ) completed_jobs.append(job) diff --git a/tests/proxy_unit_tests/test_check_responses_cost.py b/tests/proxy_unit_tests/test_check_responses_cost.py index 4c0ca94df48..16ad5c07919 100644 --- a/tests/proxy_unit_tests/test_check_responses_cost.py +++ b/tests/proxy_unit_tests/test_check_responses_cost.py @@ -449,6 +449,222 @@ class TestCheckResponsesCost: assert "job-3" in completion_call[1]["where"]["id"]["in"] assert "job-2" not in completion_call[1]["where"]["id"]["in"] + @pytest.mark.asyncio + async def test_encoded_response_id_is_fetched_through_router( + self, check_responses_cost_instance, mock_prisma_client, mock_llm_router + ): + """ + Regression test for https://github.com/BerriAI/litellm/issues/35131 + + A background response created against a deployment whose credentials only + exist in the config (e.g. Azure api_base/api_key) must be fetched through + the router so the deployment credentials are applied. Calling + litellm.aget_responses directly only sees provider env vars, fails, and + leaves the row in "queued" forever. + """ + from litellm.responses.utils import ResponsesAPIRequestUtils + + encoded_response_id = ResponsesAPIRequestUtils._build_responses_api_response_id( + custom_llm_provider="azure", + model_id="deployment-abc", + response_id="resp_upstream_123", + ) + + mock_job = MagicMock() + mock_job.unified_object_id = encoded_response_id + mock_job.created_by = "test-user" + mock_job.id = "job-router" + mock_job.file_object = {"model": "azure-gpt-5", "id": encoded_response_id} + + mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock( + return_value=[mock_job] + ) + mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock( + return_value=0 + ) + + mock_llm_router.aget_responses = AsyncMock( + return_value=ResponsesAPIResponse( + id=encoded_response_id, + object="response", + status="completed", + created_at=int(datetime.now().timestamp()), + output=[], + usage=ResponseAPIUsage( + input_tokens=100, output_tokens=50, total_tokens=150 + ), + ) + ) + + with patch( + "litellm.aget_responses", + new_callable=AsyncMock, + side_effect=AssertionError( + "must not bypass the router for a deployment-scoped response id" + ), + ) as mock_sdk_aget: + await check_responses_cost_instance.check_responses_cost() + + mock_sdk_aget.assert_not_called() + assert ( + mock_llm_router.aget_responses.call_args[1]["response_id"] + == encoded_response_id + ) + + calls = ( + mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args_list + ) + assert len(calls) == 1 + assert calls[0][1]["data"]["status"] == "completed" + assert calls[0][1]["where"]["id"]["in"] == ["job-router"] + + @pytest.mark.asyncio + async def test_encrypted_response_id_is_fetched_through_router( + self, check_responses_cost_instance, mock_prisma_client, mock_llm_router, monkeypatch + ): + """ + Rows store the *encrypted* response id when responses id security is on. + After decryption the id still carries the deployment model_id, so the + fetch must go through the router (issue #35131). + """ + from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper + from litellm.responses.utils import ResponsesAPIRequestUtils + from litellm.types.utils import SpecialEnums + + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-key-for-response-ids") + + encoded_response_id = ResponsesAPIRequestUtils._build_responses_api_response_id( + custom_llm_provider="openai", + model_id="deployment-xyz", + response_id="resp_upstream_456", + ) + encrypted_response_id = "resp_" + str( + encrypt_value_helper( + value=SpecialEnums.LITELLM_MANAGED_RESPONSE_API_RESPONSE_ID_COMPLETE_STR.value.format( + encoded_response_id, "test-user", "test-team" + ) + ) + ) + + mock_job = MagicMock() + mock_job.unified_object_id = encrypted_response_id + mock_job.created_by = "test-user" + mock_job.id = "job-encrypted" + mock_job.file_object = {"model": "gpt-5", "id": encrypted_response_id} + + mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock( + return_value=[mock_job] + ) + mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock( + return_value=0 + ) + + mock_llm_router.aget_responses = AsyncMock( + return_value=ResponsesAPIResponse( + id=encoded_response_id, + object="response", + status="completed", + created_at=int(datetime.now().timestamp()), + output=[], + usage=None, + ) + ) + + with patch( + "litellm.aget_responses", + new_callable=AsyncMock, + side_effect=AssertionError( + "must not bypass the router for a deployment-scoped response id" + ), + ) as mock_sdk_aget: + await check_responses_cost_instance.check_responses_cost() + + mock_sdk_aget.assert_not_called() + assert ( + mock_llm_router.aget_responses.call_args[1]["response_id"] + == encoded_response_id + ) + calls = ( + mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args_list + ) + assert len(calls) == 1 + assert calls[0][1]["where"]["id"]["in"] == ["job-encrypted"] + + @pytest.mark.asyncio + async def test_response_id_without_model_id_uses_sdk( + self, check_responses_cost_instance, mock_prisma_client, mock_llm_router + ): + """Ids that carry no deployment info can't be routed, so fall back to the SDK.""" + mock_job = MagicMock() + mock_job.unified_object_id = "resp_plain_upstream_id" + mock_job.created_by = "test-user" + mock_job.id = "job-plain" + mock_job.file_object = {"model": "gpt-5", "id": "resp_plain_upstream_id"} + + mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock( + return_value=[mock_job] + ) + mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock( + return_value=0 + ) + mock_llm_router.aget_responses = AsyncMock( + side_effect=AssertionError("router cannot route an id without a model_id") + ) + + mock_response = ResponsesAPIResponse( + id="resp_plain_upstream_id", + object="response", + status="completed", + created_at=int(datetime.now().timestamp()), + output=[], + usage=None, + ) + + with patch("litellm.aget_responses", new_callable=AsyncMock) as mock_sdk_aget: + mock_sdk_aget.return_value = mock_response + await check_responses_cost_instance.check_responses_cost() + + mock_sdk_aget.assert_called_once() + mock_llm_router.aget_responses.assert_not_called() + + @pytest.mark.asyncio + async def test_check_responses_cost_with_incomplete_response( + self, check_responses_cost_instance, mock_prisma_client + ): + """'incomplete' is terminal in the Responses API, so the row must not stay queued.""" + mock_job = MagicMock() + mock_job.unified_object_id = "resp_test_incomplete" + mock_job.created_by = "test-user" + mock_job.id = "job-incomplete" + mock_job.file_object = {"model": "gpt-5", "id": "resp_test_incomplete"} + + mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock( + return_value=[mock_job] + ) + mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock( + return_value=0 + ) + + mock_response = ResponsesAPIResponse( + id="resp_incomplete", + object="response", + status="incomplete", + created_at=int(datetime.now().timestamp()), + output=[], + usage=None, + ) + + with patch("litellm.aget_responses", new_callable=AsyncMock) as mock_aget: + mock_aget.return_value = mock_response + await check_responses_cost_instance.check_responses_cost() + + calls = ( + mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args_list + ) + assert len(calls) == 1 + assert calls[0][1]["data"]["status"] == "completed" + assert calls[0][1]["where"]["id"]["in"] == ["job-incomplete"] + @pytest.mark.asyncio async def test_check_responses_cost_no_model_in_file_object( self, check_responses_cost_instance, mock_prisma_client From f02e095ddb21063b4d6c1135c59b919c418b4947 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:11:53 +0000 Subject: [PATCH 02/38] fix(cost): stop token-pricing the placeholder input on file content calls --- litellm/litellm_core_utils/litellm_logging.py | 18 ++++-- .../test_litellm_logging.py | 57 +++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 83d6fcc0bee..aad4ad1f582 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -1411,10 +1411,7 @@ class Logging(LiteLLMLoggingBaseClass): litellm_params=(self.litellm_params if hasattr(self, "litellm_params") else None) ) - prompt = "" # use for tts cost calc - _input = self.model_call_details.get("input", None) - if _input is not None and isinstance(_input, str): - prompt = _input + prompt = self._prompt_for_cost_calculation() if cache_hit is None: cache_hit = self.model_call_details.get("cache_hit", False) @@ -1473,6 +1470,19 @@ class Logging(LiteLLMLoggingBaseClass): return None + def _prompt_for_cost_calculation(self) -> str: + """ + The raw input string is only priced directly for text-to-speech, which bills per character. + Every other call type gets its billable units from the response usage object, and call types + that carry no usage at all (file content retrieval, and anything else `function_setup` cannot + build messages for) only have the ``"default-message-value"`` placeholder here, so passing the + input along would token-price that placeholder. + """ + if self.call_type not in (CallTypes.speech.value, CallTypes.aspeech.value): + return "" + _input = self.model_call_details.get("input", None) + return _input if isinstance(_input, str) else "" + def _generate_content_result_as_model_response(self, result: object) -> Optional[ModelResponse]: """ Native Google :generateContent bodies report token usage under diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index edc257f4c3f..4a9200aaf7c 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -11,6 +11,9 @@ sys.path.insert( import time +import httpx +from openai._legacy_response import HttpxBinaryResponseContent + import litellm from litellm.constants import SENTRY_DENYLIST, SENTRY_PII_DENYLIST from litellm.integrations.custom_logger import CustomLogger @@ -1771,6 +1774,60 @@ def test_response_cost_calculator_does_not_transform_non_generate_content_dict() assert not cost +def _file_content_logging_obj(call_type: str) -> LitellmLogging: + logging_obj = LitellmLogging( + model="gemini-3-flash-preview", + messages="default-message-value", + stream=False, + call_type=call_type, + start_time=time.time(), + litellm_call_id=f"file-content-{call_type}", + function_id=f"file-content-{call_type}", + ) + logging_obj.model_call_details["custom_llm_provider"] = "vertex_ai" + logging_obj.model_call_details["input"] = "default-message-value" + logging_obj.optional_params = {} + return logging_obj + + +@pytest.mark.parametrize("call_type", ["afile_content", "file_content"]) +def test_file_content_call_is_not_billed(call_type): + """ + Regression for #35130: file content retrieval has no token usage, but ``function_setup`` + stores the ``"default-message-value"`` placeholder as the logged input, which the cost + calculator then token-priced, billing every call at exactly 3 * input_cost_per_token. + """ + result = HttpxBinaryResponseContent(httpx.Response(status_code=200, content=b"file contents")) + + cost = _file_content_logging_obj(call_type)._response_cost_calculator(result=result) + + assert cost == 0.0 + + +@pytest.mark.parametrize("call_type", ["aspeech", "speech"]) +def test_speech_call_is_still_priced_from_input_characters(call_type): + """tts bills per input character, so speech call types must keep passing the input along.""" + logging_obj = LitellmLogging( + model="tts-1", + messages="the quick brown fox jumped over the lazy dogs", + stream=False, + call_type=call_type, + start_time=time.time(), + litellm_call_id=f"speech-{call_type}", + function_id=f"speech-{call_type}", + ) + logging_obj.model_call_details["custom_llm_provider"] = "openai" + logging_obj.model_call_details["input"] = "the quick brown fox jumped over the lazy dogs" + logging_obj.optional_params = {} + + result = HttpxBinaryResponseContent(httpx.Response(status_code=200, content=b"audio bytes")) + + cost = logging_obj._response_cost_calculator(result=result) + + assert cost is not None + assert cost > 0 + + def test_sentry_event_scrubber_initialization(monkeypatch): # Step 1: Create a fake sentry_sdk.scrubber module mock_event_scrubber_instance = MagicMock() From 833670f7dbe32331f8689171bc9c0310ea11dd0a Mon Sep 17 00:00:00 2001 From: elinacse Date: Sun, 2 Aug 2026 12:20:46 +0530 Subject: [PATCH 03/38] fix(batch): track cost for managed batches with no attributable key/user/team LiteLLM_ManagedObjectTable only stores created_by (user_id) and team_id, never the raw API key hash. A batch created with the master key or a team-less key has both null, so CheckBatchCost's synthetic logging_obj for the completed batch carried no attributable key/user/team/end-user. _should_track_cost_callback silently skipped the DB write in that case (by design, to avoid tracking truly anonymous requests), with no error or warning: batch_processed still became true, but no LiteLLM_SpendLogs row was ever written despite real, already-incurred provider cost. Extend the same allowance already made for unauthenticated pass-through requests to aretrieve_batch's cost event, and pass job.team_id through so a batch's team gets real attribution when one exists. --- .../proxy/common_utils/check_batch_cost.py | 1 + .../proxy/hooks/proxy_track_cost_callback.py | 12 +- .../proxy_unit_tests/test_check_batch_cost.py | 128 ++++++++++++++++++ .../hooks/test_proxy_track_cost_callback.py | 17 ++- 4 files changed, 154 insertions(+), 4 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py index 22f9f40ecd8..0214c6cceb6 100644 --- a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py +++ b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py @@ -502,6 +502,7 @@ class CheckBatchCost: }, "metadata": { "user_api_key_user_id": creator_user_id, + "user_api_key_team_id": getattr(job, "team_id", None), **user_info, }, }, diff --git a/litellm/proxy/hooks/proxy_track_cost_callback.py b/litellm/proxy/hooks/proxy_track_cost_callback.py index 857429fa89f..2ff7808868d 100644 --- a/litellm/proxy/hooks/proxy_track_cost_callback.py +++ b/litellm/proxy/hooks/proxy_track_cost_callback.py @@ -34,11 +34,17 @@ from litellm.types.utils import ( ) from litellm.utils import get_end_user_id_for_cost_tracking -_PASS_THROUGH_CALL_TYPES: frozenset[str] = frozenset( +_UNATTRIBUTED_TRACKABLE_CALL_TYPES: frozenset[str] = frozenset( { CallTypes.pass_through.value, CallTypes.llm_passthrough_route.value, CallTypes.allm_passthrough_route.value, + # CheckBatchCost's synthetic logging_obj for a completed managed batch only ever + # carries user_api_key_user_id (from LiteLLM_ManagedObjectTable.created_by) and + # user_api_key_team_id (from .team_id) -- both are None for batches created with + # the master key or a team-less key, since the table never stores the raw key + # hash. The batch already incurred real provider cost, so track it regardless. + CallTypes.aretrieve_batch.value, } ) @@ -434,6 +440,8 @@ def _should_track_cost_callback( the request with no key/user/team/end-user to attribute spend to. Those requests still forward real provider traffic that operators expect to see in request/usage logs, so they are tracked even when unauthenticated. + The same reasoning applies to a completed managed batch's cost event + (see _UNATTRIBUTED_TRACKABLE_CALL_TYPES). """ # don't run track cost callback if user opted into disabling spend @@ -442,7 +450,7 @@ def _should_track_cost_callback( if user_api_key is not None or user_id is not None or team_id is not None or end_user_id is not None: return True - return call_type in _PASS_THROUGH_CALL_TYPES + return call_type in _UNATTRIBUTED_TRACKABLE_CALL_TYPES def _get_budget_reservation_from_metadata(metadata: dict) -> dict | None: diff --git a/tests/proxy_unit_tests/test_check_batch_cost.py b/tests/proxy_unit_tests/test_check_batch_cost.py index a15abd023d8..42499f2ac55 100644 --- a/tests/proxy_unit_tests/test_check_batch_cost.py +++ b/tests/proxy_unit_tests/test_check_batch_cost.py @@ -420,6 +420,134 @@ class TestCheckBatchCost: ), "update() must include batch_processed=True when column is present" assert update_data["status"] == "complete" + @pytest.mark.asyncio + async def test_completed_batch_with_no_attributable_owner_still_writes_spend_log( + self, check_batch_cost_instance, mock_prisma_client, mock_llm_router + ): + """Regression: a batch created with the master key or a team-less key has + created_by=None and team_id=None on LiteLLM_ManagedObjectTable (the table + never stores the raw key hash). CheckBatchCost's synthetic logging_obj for + such a batch then carries no attributable key/user/team/end-user, and + before the fix _should_track_cost_callback silently skipped the DB write + with no error or warning: batch_processed still became True, but no + LiteLLM_SpendLogs row was ever written. + + Unlike the other tests in this file, this one does NOT mock + litellm_logging.Logging or async_success_handler -- it runs the real + logging pipeline through to _ProxyDBLogger, which is the exact gap that + let the original bug ship undetected. + """ + import litellm + from litellm.proxy.hooks.proxy_track_cost_callback import _ProxyDBLogger + + mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock(return_value=0) + mock_prisma_client.db.litellm_managedobjecttable.update = AsyncMock() + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None) + + mock_job = MagicMock() + mock_job.id = "job-unattributed-1" + mock_job.unified_object_id = "dW5pZmllZF9iYXRjaF9pZA==" + mock_job.created_by = None + mock_job.team_id = None + + mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock(return_value=[mock_job]) + + # A real LiteLLMBatch (not a bare MagicMock): this test runs the real + # litellm_logging.Logging pipeline, which type-checks the result via + # isinstance(..., LiteLLMBatch) before it will compute/attach a cost. + from litellm.types.utils import LiteLLMBatch + + mock_response = LiteLLMBatch( + id="batch-1", + completion_window="24h", + created_at=1, + endpoint="/v1/chat/completions", + input_file_id="file-input-123", + object="batch", + status="completed", + output_file_id="file-output-123", + ) + + mock_llm_router.aretrieve_batch = AsyncMock(return_value=mock_response) + mock_llm_router.get_deployment_credentials_with_provider = MagicMock(return_value={"api_key": "sk-test"}) + + mock_deployment = MagicMock() + mock_deployment.litellm_params.custom_llm_provider = "openai" + mock_deployment.litellm_params.model = "gpt-4" + mock_deployment.model_info.model_dump.return_value = {} + mock_llm_router.get_deployment = MagicMock(return_value=mock_deployment) + + mock_file_content = MagicMock() + mock_file_content.content = b'{"id":"req-1"}' + + decoded_id = "llm_model_id,model-123;llm_batch_id,batch-456;" + + db_logger = _ProxyDBLogger() + mock_update_database = AsyncMock() + + # Unlike the other tests in this file, this one runs the real + # litellm_logging.Logging pipeline, which calls + # _is_base64_encoded_unified_file_id an extra time (checking result.id + # after it's reset to job.unified_object_id). Key off the argument + # instead of a fixed-length side_effect list so the exact call count + # doesn't matter. + def _fake_is_base64_encoded(file_id): + return decoded_id if file_id == mock_job.unified_object_id else None + + with ( + patch( + "litellm.proxy.openai_files_endpoints.common_utils._is_base64_encoded_unified_file_id", + side_effect=_fake_is_base64_encoded, + ), + patch( + "litellm.proxy.openai_files_endpoints.common_utils.get_model_id_from_unified_batch_id", + return_value="model-123", + ), + patch( + "litellm.proxy.openai_files_endpoints.common_utils.get_batch_id_from_unified_batch_id", + return_value="batch-456", + ), + patch( + "litellm.files.main.afile_content", + new_callable=AsyncMock, + return_value=mock_file_content, + ), + patch( + "litellm.batches.batch_utils._get_file_content_as_dictionary", + return_value=[{"id": "req-1"}], + ), + patch( + "litellm.batches.batch_utils.calculate_batch_cost_and_usage", + new_callable=AsyncMock, + return_value=( + 0.01, + {"prompt_tokens": 10, "completion_tokens": 5}, + ["gpt-4"], + ), + ), + patch( + "litellm.litellm_core_utils.get_llm_provider_logic.get_llm_provider", + return_value=("gpt-4", "openai", None, None), + ), + patch.object(litellm, "_async_success_callback", [db_logger]), + patch( + "litellm.proxy.proxy_server.proxy_logging_obj", + MagicMock( + db_spend_update_writer=MagicMock(update_database=mock_update_database), + slack_alerting_instance=MagicMock(customer_spend_alert=AsyncMock()), + ), + ), + patch("litellm.proxy.proxy_server.increment_spend_counters", AsyncMock()), + patch("litellm.proxy.proxy_server.update_cache", AsyncMock()), + ): + await check_batch_cost_instance.check_batch_cost() + + mock_update_database.assert_awaited_once() + assert mock_update_database.call_args.kwargs["response_cost"] == 0.01 + assert mock_prisma_client.db.litellm_managedobjecttable.update.call_count == 1, ( + "the job must still be marked processed once cost tracking succeeds" + ) + @pytest.mark.asyncio async def test_cost_tracking_failure_leaves_job_unprocessed( self, check_batch_cost_instance, mock_prisma_client, mock_llm_router diff --git a/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py b/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py index f289148101a..69f04ce2bbe 100644 --- a/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py +++ b/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py @@ -1186,6 +1186,7 @@ async def test_track_cost_callback_enriches_user_id_for_mcp_style_metadata(): ("pass_through_endpoint", True), ("llm_passthrough_route", True), ("allm_passthrough_route", True), + ("aretrieve_batch", True), ("acompletion", False), ("call_mcp_tool", False), (None, False), @@ -1194,7 +1195,14 @@ async def test_track_cost_callback_enriches_user_id_for_mcp_style_metadata(): def test_should_track_cost_callback_pass_through_without_owner(call_type, expected): """Regression for LIT-3782: unauthenticated pass-through requests (auth=false) carry no key/user/team/end-user, yet must still be tracked so they land in - LiteLLM_SpendLogs. Other call types with no owner stay untracked.""" + LiteLLM_SpendLogs. Other call types with no owner stay untracked. + + aretrieve_batch is included for the same reason: CheckBatchCost's synthetic + logging_obj for a completed managed batch only ever carries + user_api_key_user_id/user_api_key_team_id from LiteLLM_ManagedObjectTable, + both of which are None for a batch created with the master key or a + team-less key (the table never stores the raw key hash). Before this fix, + such a batch's cost silently never reached LiteLLM_SpendLogs.""" assert ( _should_track_cost_callback( user_api_key=None, @@ -1211,6 +1219,7 @@ def test_should_track_cost_callback_pass_through_without_owner(call_type, expect "call_type, expect_spend_log", [ ("pass_through_endpoint", True), + ("aretrieve_batch", True), ("acompletion", False), (None, False), ], @@ -1223,7 +1232,11 @@ async def test_track_cost_callback_logs_unauthenticated_pass_through_request( cost callback with no key/user/team/end-user. Before the fix the spend-log write was skipped and the request never appeared in request/usage logs. It must now be written for pass-through call types while other unauthenticated - calls remain skipped.""" + calls remain skipped. + + aretrieve_batch is included because CheckBatchCost's completed-batch cost + event reaches this same callback with no attributable key/user/team when + the batch was created with the master key or a team-less key.""" logger = _ProxyDBLogger() kwargs = { From a3d1efeaa5dd463f9df828d6aaa520fb23f39402 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 4 Aug 2026 13:53:20 -0700 Subject: [PATCH 04/38] refactor(ui): drop unreferenced locals from dashboard route components @typescript-eslint/no-unused-vars is disabled in the dashboard eslint config, so unused locals accumulated with nothing to catch them. This is the first slice: symbols under src/app that no code reads. Every removal is an unused import, an unused interface or type alias, or a local const whose only mention was its own declaration. Nothing else on the touched lines changes, so no behavior moves with it. Part of LIT-5162. --- .../caching/_components/cache_dashboard.tsx | 25 ------------- .../_components/provider_margin_table.tsx | 8 ----- .../_components/GuardrailDetail.tsx | 2 +- .../_components/GuardrailTestResults.tsx | 3 -- .../content_filter/KeywordTable.tsx | 3 +- .../guardrails/_components/guardrail_info.tsx | 21 ----------- .../_components/CreateMCPServer.tsx | 1 - .../mcp-servers/_components/mcp_connect.tsx | 25 +------------ .../playground/components/chat_ui/ChatUI.tsx | 18 ++-------- .../components/chat_ui/RealtimePlayground.tsx | 2 -- .../prompts/_components/add_prompt_form.tsx | 6 ---- .../(dashboard)/prompts/_components/index.tsx | 2 +- .../_components/prompt_editor_view/index.tsx | 2 +- .../TransformRequestPanel.tsx | 6 ---- .../_components/components/UsagePageView.tsx | 35 ------------------- .../users/_components/edit_user.tsx | 3 +- .../_components/vector_store_info.tsx | 1 - ui/litellm-dashboard/src/app/chat/page.tsx | 1 - 18 files changed, 8 insertions(+), 156 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx index 47c266ceac0..5167ac16542 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx @@ -65,32 +65,7 @@ interface CachePageProps { premiumUser: boolean; } -interface CacheHealthResponse { - status?: string; - cache_type?: string; - ping_response?: boolean; - set_cache_response?: string; - litellm_cache_params?: string; - error?: { - message: string; - type: string; - param: string; - code: string; - }; -} - // Helper function to deep-parse a JSON string if possible -const deepParse = (input: any) => { - let parsed = input; - if (typeof parsed === "string") { - try { - parsed = JSON.parse(parsed); - } catch { - return parsed; - } - } - return parsed; -}; const CacheDashboard: React.FC = ({ accessToken, token, userRole, userID, premiumUser }) => { const [selectedApiKeys, setSelectedApiKeys] = useState([]); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.tsx index 9d11e13fb52..75939a23751 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.tsx @@ -69,14 +69,6 @@ const ProviderMarginTable: React.FC = ({ setEditFixedAmount(""); }; - const handleKeyDown = (e: React.KeyboardEvent, provider: string) => { - if (e.key === "Enter") { - handleSaveEdit(provider); - } else if (e.key === "Escape") { - handleCancelEdit(); - } - }; - const formatMargin = (margin: number | { percentage?: number; fixed_amount?: number }): string => { if (typeof margin === "number") { return `${(margin * 100).toFixed(1)}%`; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx index 1d959ccca95..2e007c06744 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx @@ -25,7 +25,7 @@ const statusColors: Record = export function GuardrailDetail({ guardrailId, onBack, accessToken = null, startDate, endDate }: GuardrailDetailProps) { const [activeTab, setActiveTab] = useState("overview"); const [evaluationModalOpen, setEvaluationModalOpen] = useState(false); - const [logsPage, setLogsPage] = useState(1); + const [logsPage] = useState(1); const logsPageSize = 50; const { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx index 3c974de3632..10ce709244b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx @@ -1,11 +1,8 @@ import React, { useState } from "react"; import { Button, Card } from "@tremor/react"; -import { Typography } from "antd"; import { CopyOutlined, CheckCircleOutlined, ClockCircleOutlined, DownOutlined, RightOutlined } from "@ant-design/icons"; import NotificationsManager from "@/components/molecules/notifications_manager"; -const { Text } = Typography; - interface TestResult { guardrailName: string; response_text: string; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordTable.tsx index 57e59423aa5..eed2c8a5129 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordTable.tsx @@ -1,8 +1,7 @@ import { DeleteOutlined } from "@ant-design/icons"; -import { Button, Select, Table, Typography } from "antd"; +import { Button, Select, Table } from "antd"; import React from "react"; -const { Text } = Typography; const { Option } = Select; interface BlockedWord { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx index 07df6ff15d9..54cac4bbe5c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx @@ -35,22 +35,6 @@ export interface GuardrailInfoProps { isAdmin: boolean; } -interface ProviderParam { - param: string; - description: string; - required: boolean; - default_value?: string; - options?: string[]; - type?: string; - fields?: { [key: string]: ProviderParam }; - dict_key_options?: string[]; - dict_value_type?: string; -} - -interface ProviderParamsResponse { - [provider: string]: { [key: string]: ProviderParam }; -} - const GuardrailInfoView: React.FC = ({ guardrailId, onClose, accessToken, isAdmin }) => { const [guardrailData, setGuardrailData] = useState(null); const [guardrailProviderSpecificParams, setGuardrailProviderSpecificParams] = useState(null); @@ -244,11 +228,6 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, resetToolPermissionEditor(); }, [resetToolPermissionEditor]); - const handleToolPermissionConfigChange = (config: ToolPermissionConfig) => { - setToolPermissionConfig(config); - setToolPermissionDirty(true); - }; - const handlePiiEntitySelect = (entity: string) => { setSelectedPiiEntities((prev) => { if (prev.includes(entity)) { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx index 0785dd142ff..d567c318743 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/CreateMCPServer.tsx @@ -333,7 +333,6 @@ const CreateMCPServer: React.FC = ({ if (!pendingRestoredValues) { return; } - const transportReady = transportType || pendingRestoredValues.transport || ""; if (pendingRestoredValues.transport && !transportType) { // wait until transportType state catches up so the URL field is mounted return; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx index 7bdfd9c6b8f..74b77735377 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx @@ -1,14 +1,13 @@ /* eslint-disable react/no-unescaped-entities */ import React, { useState } from "react"; -import { Card, Typography, Space, Alert, Button, Switch, Form, Collapse } from "antd"; +import { Card, Typography, Space, Alert, Button, Switch, Form } from "antd"; import { TabPanel, TabPanels, TabGroup, TabList, Tab, Title as TremorTitle, Text as TremorText } from "@tremor/react"; import { CopyIcon, Code, Terminal, Globe, CheckIcon, ExternalLinkIcon, KeyIcon, ServerIcon, Zap } from "lucide-react"; import { getProxyBaseUrl } from "@/components/networking"; import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; const { Title, Text } = Typography; -const { Panel } = Collapse; interface CodeBlockProps { code: string; @@ -117,12 +116,6 @@ interface MCPConnectProps { const MCPConnect: React.FC = ({ currentServerAccessGroups = [] }) => { const proxyBaseUrl = getProxyBaseUrl(); const [copiedStates, setCopiedStates] = useState>({}); - const [serverHeaders, setServerHeaders] = useState>({ - openai: [], - litellm: [], - cursor: [], - http: [], - }); const [currentServer] = useState("Zapier_MCP"); // This should match the current server being viewed const copyToClipboard = async (text: string, key: string) => { @@ -135,22 +128,6 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] } }; - const getHeadersConfig = (type: string) => { - const headers: Record = { - "x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY", - }; - - if (serverHeaders[type]?.length > 0) { - // Format server names (replace spaces with underscores) - const formattedServers = serverHeaders[type].map((s) => s.replace(/\s+/g, "_")); - - // Use comma-separated string (can include both servers and access groups) - headers["x-mcp-servers"] = formattedServers.join(","); - } - - return headers; - }; - const CodeBlock: React.FC<{ code: string; copyKey: string; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx index e7261db6260..79368886c8b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx @@ -7,7 +7,6 @@ import { CodeOutlined, DatabaseOutlined, DeleteOutlined, - FilePdfOutlined, InfoCircleOutlined, KeyOutlined, LinkOutlined, @@ -19,12 +18,10 @@ import { SoundOutlined, TagsOutlined, ToolOutlined, - UserOutlined, } from "@ant-design/icons"; import { Card, Text, TextInput, Title, Button as TremorButton } from "@tremor/react"; import { Button, Input, Modal, Popover, Select, Spin, Tooltip, Upload } from "antd"; import React, { useEffect, useRef, useState } from "react"; -import ReactMarkdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; import { v4 as uuidv4 } from "uuid"; @@ -50,14 +47,10 @@ import { makeOpenAIImageEditsRequest } from "../../llm_calls/image_edits"; import { makeOpenAIImageGenerationRequest } from "../../llm_calls/image_generation"; import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api"; import { makeInteractionsRequest } from "../../llm_calls/interactions_api"; -import A2AMetrics from "./A2AMetrics"; import AdditionalModelSettings from "./AdditionalModelSettings"; -import AudioRenderer from "./AudioRenderer"; import { OPEN_AI_VOICE_SELECT_OPTIONS, OpenAIVoice } from "./chatConstants"; -import ChatImageRenderer from "./ChatImageRenderer"; import ChatImageUpload from "./ChatImageUpload"; import { createChatDisplayMessage, createChatMultimodalMessage } from "./ChatImageUtils"; -import CodeInterpreterOutput from "./CodeInterpreterOutput"; import CodeInterpreterTool from "./CodeInterpreterTool"; import { generateCodeSnippet } from "@/components/chat_ui/CodeSnippets"; import EndpointSelector from "./EndpointSelector"; @@ -65,15 +58,11 @@ import FilePreviewCard from "./FilePreviewCard"; import ChatMessageBubble from "./ChatMessageBubble"; import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay"; import { EndpointType, getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping"; -import ReasoningContent from "@/components/chat_ui/ReasoningContent"; -import ResponseMetrics, { TokenUsage } from "@/components/chat_ui/ResponseMetrics"; -import ResponsesImageRenderer from "./ResponsesImageRenderer"; import ResponsesImageUpload from "./ResponsesImageUpload"; import { createDisplayMessage, createMultimodalMessage } from "./ResponsesImageUtils"; -import { SearchResultsDisplay } from "./SearchResultsDisplay"; import SessionManagement from "./SessionManagement"; import RealtimePlayground from "./RealtimePlayground"; -import { A2ATaskMetadata, MessageType } from "@/components/chat_ui/types"; +import { MessageType } from "@/components/chat_ui/types"; import { useCodeInterpreter } from "../../hooks/useCodeInterpreter"; import { useChatHistory } from "../../hooks/useChatHistory"; import { getSecureItem, setSecureItem } from "@/utils/secureStorage"; @@ -147,13 +136,10 @@ const ChatUI: React.FC = ({ chatHistory, setChatHistory, mcpEvents, - setMCPEvents, messageTraceId, setMessageTraceId, responsesSessionId, - setResponsesSessionId, useApiSessionManagement, - setUseApiSessionManagement, updateTextUI, updateReasoningContent, updateTimingData, @@ -604,7 +590,7 @@ const ChatUI: React.FC = ({ return; } // Resolve the real server ID (toolsets use toolset: prefix) - const mcpServerId = rawSelected.startsWith("toolset:") ? rawSelected : rawSelected; + rawSelected.startsWith("toolset:") ? rawSelected : rawSelected; if (!selectedMCPDirectTool) { NotificationsManager.fromBackend("Please select an MCP tool to call"); return; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.tsx index 68a150be8c0..2bf645fced2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.tsx @@ -37,8 +37,6 @@ const RealtimePlayground: React.FC = ({ const audioContextRef = useRef(null); const mediaStreamRef = useRef(null); const processorRef = useRef(null); - const playbackQueueRef = useRef([]); - const isPlayingRef = useRef(false); const messagesEndRef = useRef(null); const nextPlayTimeRef = useRef(0); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/add_prompt_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/add_prompt_form.tsx index 1bd831ca49d..c02efa82499 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/add_prompt_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/add_prompt_form.tsx @@ -15,12 +15,6 @@ interface AddPromptFormProps { onSuccess: () => void; } -interface PromptFormData { - prompt_id: string; - prompt_integration: string; - prompt_file?: File; -} - const AddPromptForm: React.FC = ({ visible, onClose, accessToken, onSuccess }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); 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 9bebabb8cf2..c885fdcbf35 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx @@ -47,7 +47,7 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { const [isDeleting, setIsDeleting] = useState(false); const [promptToDelete, setPromptToDelete] = useState<{ id: string; name: string } | null>(null); - const isAdmin = userRole ? isAdminRole(userRole) : false; + userRole ? isAdminRole(userRole) : false; // Admin Viewer follows the read-parity rule: see prompts, no writes. const canModify = userRole ? isProxyAdminRole(userRole) : false; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/index.tsx index fa69a520145..e09ca787a69 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/index.tsx @@ -44,7 +44,7 @@ const PromptEditorView: React.FC = ({ onClose, onSuccess, }; const [prompt, setPrompt] = useState(getInitialPrompt()); - const [editMode, setEditMode] = useState(!!initialPromptData); + const [editMode] = useState(!!initialPromptData); const [showHistoryModal, setShowHistoryModal] = useState(false); // Construct versioned ID from prompt_id and version field 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 0c41547b9b7..0c7f10eab0e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.tsx @@ -11,12 +11,6 @@ interface TransformRequestPanelProps { accessToken: string | null; } -interface TransformResponse { - raw_request_api_base: string; - raw_request_body: Record; - raw_request_headers: Record; -} - const TransformRequestPanel: React.FC = ({ accessToken }) => { const [originalRequestJSON, setOriginalRequestJSON] = useState(`{ "model": "openai/gpt-4o", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index 46a17017d39..19f650386a6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -985,40 +985,5 @@ const UsagePage: React.FC = ({ teams, organizations }) => { }; // Add this helper function to process model-specific activity data -const getModelActivityData = (userSpendData: { results: DailyData[]; metadata: any }) => { - const modelData: { - [key: string]: { - total_requests: number; - total_tokens: number; - daily_data: Array<{ - date: string; - api_requests: number; - total_tokens: number; - }>; - }; - } = {}; - - userSpendData.results.forEach((day: DailyData) => { - Object.entries(day.breakdown.models || {}).forEach(([model, metrics]) => { - if (!modelData[model]) { - modelData[model] = { - total_requests: 0, - total_tokens: 0, - daily_data: [], - }; - } - - modelData[model].total_requests += metrics.metrics.api_requests; - modelData[model].total_tokens += metrics.metrics.total_tokens; - modelData[model].daily_data.push({ - date: day.date, - api_requests: metrics.metrics.api_requests, - total_tokens: metrics.metrics.total_tokens, - }); - }); - }); - - return modelData; -}; export default UsagePage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/edit_user.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/edit_user.tsx index de031984846..0f3e0dedb52 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/edit_user.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/edit_user.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { TextInput, SelectItem } from "@tremor/react"; import { Button as Button2, Modal, Form, Select as Select2, InputNumber } from "antd"; @@ -15,7 +15,6 @@ interface EditUserModalProps { } const EditUserModal: React.FC = ({ visible, possibleUIRoles, onCancel, user, onSubmit }) => { - const [editedUser, setEditedUser] = useState(user); const [form] = Form.useForm(); useEffect(() => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx index ec20a3fd318..e5646037d14 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx @@ -36,7 +36,6 @@ const VectorStoreInfoView: React.FC = ({ const [isEditing, setIsEditing] = useState(editVectorStore); const [metadataString, setMetadataString] = useState("{}"); const [credentials, setCredentials] = useState([]); - const [activeTab, setActiveTab] = useState(editVectorStore ? "details" : "details"); const fetchVectorStoreDetails = async () => { if (!accessToken) return; diff --git a/ui/litellm-dashboard/src/app/chat/page.tsx b/ui/litellm-dashboard/src/app/chat/page.tsx index b6dccef47c6..7b6b26c6436 100644 --- a/ui/litellm-dashboard/src/app/chat/page.tsx +++ b/ui/litellm-dashboard/src/app/chat/page.tsx @@ -65,7 +65,6 @@ export default function ChatConversationPage() { updateLastAssistantMessage, truncateFromMessage, } = useChatShell(); - const hadActiveConversationOnMountRef = useRef(activeConversationId !== null); const [selectedModel, setSelectedModel] = useState(null); const [models, setModels] = useState([]); From 0fba22151a8682a1c3fc8f824f930d99a05a7dd5 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 4 Aug 2026 15:07:39 -0700 Subject: [PATCH 05/38] refactor(ui): delete the discarded expressions, not just their bindings Dropping the binding but keeping the initializer left two statements that compute a value and throw it away: a ternary in ChatUI returning rawSelected from both branches under a comment about resolving server IDs, and an isAdminRole call in the prompts panel that also kept its import alive. Both computations were already unreachable in effect; remove them whole. --- .../app/(dashboard)/playground/components/chat_ui/ChatUI.tsx | 2 -- .../src/app/(dashboard)/prompts/_components/index.tsx | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx index 79368886c8b..57ff7906eda 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx @@ -589,8 +589,6 @@ const ChatUI: React.FC = ({ NotificationsManager.fromBackend("Please select an MCP server to test"); return; } - // Resolve the real server ID (toolsets use toolset: prefix) - rawSelected.startsWith("toolset:") ? rawSelected : rawSelected; if (!selectedMCPDirectTool) { NotificationsManager.fromBackend("Please select an MCP tool to call"); return; 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 c885fdcbf35..f9bba3ce661 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx @@ -7,7 +7,7 @@ import PromptInfoView from "./prompt_info"; 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 { isProxyAdminRole } from "@/utils/roles"; import { Button } from "@/components/ui/button"; import { AlertDialog, @@ -47,7 +47,6 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { const [isDeleting, setIsDeleting] = useState(false); const [promptToDelete, setPromptToDelete] = useState<{ id: string; name: string } | null>(null); - userRole ? isAdminRole(userRole) : false; // Admin Viewer follows the read-parity rule: see prompts, no writes. const canModify = userRole ? isProxyAdminRole(userRole) : false; From b5889a60ad45ab8a9d08ed1964063439bd1c5df9 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 4 Aug 2026 13:58:45 -0700 Subject: [PATCH 06/38] refactor(ui): drop unreferenced locals from shared dashboard components Second slice of the same sweep, covering src/components. Same rule as the first: every removal is an unused import, an unused interface or type alias, or a local const whose only mention was its own declaration. The modelGroupOptions computation in add_auto_router_tab goes whole rather than losing only its binding, since a Set and two arrays allocated per render and then discarded is no better than the dead const was. ToolDetail is deliberately left alone. Its unread teamsData traces back to a useQuery that still issues a /team/list request, so removing it drops a network call; that is a behavior change and belongs in a slice that gets QA'd, not this one. Stacked on litellm_dead_locals_1_app_routes; review that one first. Part of LIT-5162. --- .../add_model/RouterConfigBuilder.tsx | 7 - .../add_model/add_auto_router_tab.tsx | 7 - .../src/components/add_pass_through.tsx | 6 - .../components/bulk_create_users_button.tsx | 18 --- .../src/components/chat_ui/CodeSnippets.tsx | 3 - .../components/chat_ui/MCPEventsDisplay.tsx | 3 +- .../src/components/model_filters.tsx | 7 - .../src/components/networking.tsx | 1 - .../src/components/pass_through_info.tsx | 2 +- .../src/components/settings.tsx | 124 +----------------- .../components/templates/key_edit_view.tsx | 16 --- .../src/components/user_agent_activity.tsx | 6 +- .../GuardrailViewer/GuardrailViewer.tsx | 16 --- .../src/components/view_user_spend.tsx | 5 - 14 files changed, 4 insertions(+), 217 deletions(-) diff --git a/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx b/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx index 08acf993e2c..b28d402f116 100644 --- a/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx +++ b/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx @@ -126,13 +126,6 @@ const RouterConfigBuilder: React.FC = ({ modelInfo, va }; // Handle utterances change (convert textarea string to array) - const handleUtterancesChange = (routeId: string, utterancesText: string) => { - const utterancesArray = utterancesText - .split("\n") - .map((line) => line.trim()) // Only trims leading/trailing whitespace, preserves internal spaces - .filter((line) => line.length > 0); - updateRoute(routeId, "utterances", utterancesArray); - }; // Prepare model options for dropdowns const modelOptions = modelInfo.map((model) => ({ diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index ae90d42ba8a..894d1819901 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -38,8 +38,6 @@ interface AddAutoRouterTabProps { createScope?: ModelWriteScope; } -const { Title } = Typography; - const AddAutoRouterTab: React.FC = ({ handleOk, accessToken, @@ -91,11 +89,6 @@ const AddAutoRouterTab: React.FC = ({ const isAdmin = all_admin_roles.includes(userRole); - const modelGroupOptions = Array.from(new Set(modelInfo.map((option) => option.model_group))).map((model_group) => ({ - value: model_group, - label: model_group, - })); - // Why the submit is unavailable, or null when it is available. The button reads this to disable // itself and to say what is missing, so the two can never give different answers. const submitBlockedReason = diff --git a/ui/litellm-dashboard/src/components/add_pass_through.tsx b/ui/litellm-dashboard/src/components/add_pass_through.tsx index c0343e268a1..0c9fbfb0347 100644 --- a/ui/litellm-dashboard/src/components/add_pass_through.tsx +++ b/ui/litellm-dashboard/src/components/add_pass_through.tsx @@ -37,7 +37,6 @@ const AddPassThroughEndpoint: React.FC = ({ const [form] = Form.useForm(); const [isModalVisible, setIsModalVisible] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [selectedModel, setSelectedModel] = useState(""); const [pathValue, setPathValue] = useState(""); const [targetValue, setTargetValue] = useState(""); const [includeSubpath, setIncludeSubpath] = useState(true); @@ -107,11 +106,6 @@ const AddPassThroughEndpoint: React.FC = ({ } }; - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - NotificationsManager.success("Copied to clipboard!"); - }; - return (
); } return
{body}
; } + +function IdentityCellLink({ href, className, body }: { href: string; className?: string; body: React.ReactNode }) { + const router = useRouter(); + + const handleClick = (e: React.MouseEvent) => { + const hasModifierKey = e.metaKey || e.ctrlKey || e.shiftKey; + const isNativeNewTabClick = hasModifierKey || e.button === 1; + if (isNativeNewTabClick) return; + e.preventDefault(); + router.push(href); + }; + + return ( + + {body} + + + ); +} diff --git a/ui/litellm-dashboard/src/utils/entityLinks.ts b/ui/litellm-dashboard/src/utils/entityLinks.ts index 2659a307866..85052448866 100644 --- a/ui/litellm-dashboard/src/utils/entityLinks.ts +++ b/ui/litellm-dashboard/src/utils/entityLinks.ts @@ -3,3 +3,7 @@ import { migratedHref } from "@/utils/migratedPages"; export function teamDetailHref(teamId: string): string { return `${migratedHref("teams")}?team=${encodeURIComponent(teamId)}`; } + +export function keyDetailHref(keyToken: string): string { + return `${migratedHref("api-keys")}?key=${encodeURIComponent(keyToken)}`; +} From f2690aa60e3684c48ef33d864c678e49794e1e9f Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 5 Aug 2026 17:31:22 -0700 Subject: [PATCH 19/38] fix(ui): opening a project now pushes ?project= so back and deep links work (#36001) * fix(ui): drive project detail selection from the ?project= url param Opening a project kept selectedProjectId in useState, so the URL never changed; the detail view could not be linked or reloaded and browser Back skipped past the Projects page entirely. Selection now lives in the ?project= query param via nuqs with history: push, matching how Teams, Organizations and Virtual Keys already work. * fix(ui): project detail close replaces history to match the other detail pages Adopts the close semantics from PR #36013 so browser Back after an in-page close leaves the Projects page instead of reopening the dismissed detail; the close test now pins the replace mode --- .../_components/ProjectsPage.test.tsx | 50 ++++++++++++++++++- .../projects/_components/ProjectsPage.tsx | 15 ++++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx index 1c06b61698b..0bba9d49fd3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent from "@testing-library/user-event"; +import type { UrlUpdateEvent } from "nuqs/adapters/testing"; import { renderWithProviders, screen, waitFor, within } from "../../../../../tests/test-utils"; import { ProjectsPage } from "./ProjectsPage"; import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; @@ -20,7 +21,12 @@ vi.mock("./ProjectModals/CreateProjectModal", () => ({ })); vi.mock("./ProjectDetailsPage", () => ({ - ProjectDetail: ({ projectId }: { projectId: string }) =>
{projectId}
, + ProjectDetail: ({ projectId, onBack }: { projectId: string; onBack: () => void }) => ( +
+ {projectId} + +
+ ), })); const mockProjects: ProjectResponse[] = [ @@ -200,6 +206,48 @@ describe("ProjectsPage", () => { }); }); + it("should open the detail view directly from a ?project= deep link", () => { + mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false }); + renderWithProviders(, { searchParams: "?project=proj-2" }); + + expect(screen.getByTestId("project-detail")).toHaveTextContent("proj-2"); + expect(screen.queryByRole("heading", { name: /projects/i })).not.toBeInTheDocument(); + }); + + it("should push ?project= as a new history entry when a project is opened", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false }); + renderWithProviders(, { onUrlUpdate }); + + await user.click(screen.getByText("proj-1")); + + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ + queryString: "?project=proj-1", + options: expect.objectContaining({ history: "push" }), + }), + ); + }); + }); + + it("should clear ?project= and return to the list when the detail view is closed", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false }); + renderWithProviders(, { searchParams: "?project=proj-1", onUrlUpdate }); + + await user.click(screen.getByRole("button", { name: /back to projects/i })); + + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: "" })); + }); + expect(onUrlUpdate.mock.calls.at(-1)?.[0].options.history).toBe("replace"); + expect(screen.queryByTestId("project-detail")).not.toBeInTheDocument(); + expect(screen.getByText("Alpha Project")).toBeInTheDocument(); + }); + it("should resolve team alias from the teams list in the Team column", () => { mockUseTeams.mockReturnValue({ data: [{ team_id: "team-1", team_alias: "Engineering", models: [] }], diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx index 58c2c4c3ad8..a7e2f70198f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx @@ -3,6 +3,7 @@ import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { PlusOutlined } from "@ant-design/icons"; import { Button, Flex, Input, Layout, Space, theme, Typography } from "antd"; import { SearchIcon } from "lucide-react"; +import { parseAsString, useQueryState } from "nuqs"; import { useMemo, useState } from "react"; import { CreateProjectModal } from "./ProjectModals/CreateProjectModal"; import { ProjectDetail } from "./ProjectDetailsPage"; @@ -16,7 +17,10 @@ export function ProjectsPage() { const { data: projects, isLoading } = useProjects(); const { data: teams, isLoading: isTeamsLoading } = useTeams(); - const [selectedProjectId, setSelectedProjectId] = useState(null); + const [selectedProjectId, setSelectedProjectId] = useQueryState( + "project", + parseAsString.withOptions({ history: "push" }), + ); const [isCreateModalVisible, setIsCreateModalVisible] = useState(false); const [searchText, setSearchText] = useState(""); @@ -44,7 +48,12 @@ export function ProjectsPage() { }, [projects, searchText, teamAliasMap]); if (selectedProjectId) { - return setSelectedProjectId(null)} />; + return ( + void setSelectedProjectId(null, { history: "replace" })} + /> + ); } return ( @@ -76,7 +85,7 @@ export function ProjectsPage() { projects={filteredProjects} isLoading={isLoading} isFiltered={searchText.trim().length > 0} - onProjectClick={setSelectedProjectId} + onProjectClick={(id) => void setSelectedProjectId(id)} teamAliasMap={teamAliasMap} isTeamsLoading={isTeamsLoading} /> From 097c03eebb948e502db639c48e5bf88561f369d6 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:33:09 -0700 Subject: [PATCH 20/38] fix(proxy): tolerate non-scalar sse keepalive interval config shapes --- litellm/proxy/common_utils/sse_keepalive.py | 2 +- .../proxy/common_utils/test_sse_keepalive.py | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/common_utils/sse_keepalive.py b/litellm/proxy/common_utils/sse_keepalive.py index e6d5da03a71..6700700ff7c 100644 --- a/litellm/proxy/common_utils/sse_keepalive.py +++ b/litellm/proxy/common_utils/sse_keepalive.py @@ -14,7 +14,7 @@ def _coerce_interval(ping_interval_seconds: float | str | None) -> float | None: return None try: interval: Final = float(ping_interval_seconds) - except ValueError: + except (TypeError, ValueError): return None if not math.isfinite(interval) or interval <= 0: return None diff --git a/tests/test_litellm/proxy/common_utils/test_sse_keepalive.py b/tests/test_litellm/proxy/common_utils/test_sse_keepalive.py index 6e98248dd18..9cca9bbfe12 100644 --- a/tests/test_litellm/proxy/common_utils/test_sse_keepalive.py +++ b/tests/test_litellm/proxy/common_utils/test_sse_keepalive.py @@ -1,6 +1,6 @@ import asyncio from collections.abc import AsyncGenerator -from typing import Final +from typing import Final, cast import pytest from fastapi.responses import StreamingResponse @@ -105,7 +105,19 @@ async def test_non_positive_interval_returns_stream_unwrapped(): @pytest.mark.asyncio -@pytest.mark.parametrize("bad_interval", [None, "abc", "", float("inf"), float("nan"), "-3"]) +@pytest.mark.parametrize( + "bad_interval", + [ + None, + "abc", + "", + float("inf"), + float("nan"), + "-3", + cast("float | str | None", [15]), + cast("float | str | None", {"seconds": 15}), + ], +) async def test_invalid_config_interval_returns_stream_unwrapped(bad_interval: float | str | None): async def any_stream() -> AsyncGenerator[str, None]: yield MESSAGE_START_CHUNK From f3bfa19ce5d02ae7a4a1d9ae0393f1920730ae77 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:36:22 -0700 Subject: [PATCH 21/38] fix(managed_files): resolve model_name identically across all output file registration paths so full unified ids converge --- .../proxy/common_utils/check_batch_cost.py | 12 +-- .../proxy/hooks/managed_files.py | 22 ++---- .../openai_files_endpoints/common_utils.py | 21 +++-- .../proxy/test_managed_files_hook.py | 78 +++++++++++++++++++ 4 files changed, 104 insertions(+), 29 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py index 22f9f40ecd8..3ed63b0d9ee 100644 --- a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py +++ b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py @@ -296,17 +296,13 @@ class CheckBatchCost: underlying provider model (e.g. ``gpt-5.5``), which no key is allowed to call. """ from litellm.proxy.openai_files_endpoints.common_utils import ( - convert_b64_uid_to_unified_uid, - get_models_from_unified_file_id, + resolve_managed_output_file_model_name, ) - input_file_id = cls._get_input_file_id(job) - target_model_names = ( - get_models_from_unified_file_id(convert_b64_uid_to_unified_uid(input_file_id)) if input_file_id else [] + return resolve_managed_output_file_model_name( + unified_input_file_id=cls._get_input_file_id(job), + fallback_model_name=deployment_info.model_name or None, ) - if target_model_names: - return ",".join(target_model_names) - return deployment_info.model_name or None @staticmethod def _get_input_file_id(job: "LiteLLM_ManagedObjectTable") -> Optional[str]: diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index 5fe7b38c06a..404dba6b919 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -34,8 +34,8 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( get_batch_id_from_unified_batch_id, get_content_type_from_file_object, get_model_id_from_unified_batch_id, - get_models_from_unified_file_id, normalize_mime_type_for_provider, + resolve_managed_output_file_model_name, ) from litellm.types.llms.openai import ( # pyright: ignore[reportAttributeAccessIssue] AllMessageValues, @@ -1102,21 +1102,13 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): ) # managed batch id model_id = cast(Optional[str], response._hidden_params.get("model_id")) model_name = cast(Optional[str], response._hidden_params.get("model_name")) - resolved_model_name = model_name - # Some providers (e.g. Vertex batch retrieve) do not set model_name on - # the response. In that case, recover target_model_names from the input - # managed file metadata so unified output IDs preserve routing metadata. - if not resolved_model_name and isinstance(unified_file_id, str): - decoded_unified_file_id = ( - _is_base64_encoded_unified_file_id(unified_file_id) - or unified_file_id - ) - target_model_names = get_models_from_unified_file_id( - decoded_unified_file_id - ) - if target_model_names: - resolved_model_name = ",".join(target_model_names) + resolved_model_name = resolve_managed_output_file_model_name( + unified_input_file_id=unified_file_id + if isinstance(unified_file_id, str) + else None, + fallback_model_name=model_name, + ) original_response_id = response.id if (unified_batch_id or unified_file_id) and model_id: diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 412d75cc82e..0dd2c65bba6 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -43,6 +43,17 @@ def convert_b64_uid_to_unified_uid(b64_uid: str) -> str: return b64_uid +def resolve_managed_output_file_model_name( + unified_input_file_id: str | None, fallback_model_name: str | None +) -> str | None: + if not unified_input_file_id: + return fallback_model_name + target_model_names: Final = get_models_from_unified_file_id(convert_b64_uid_to_unified_uid(unified_input_file_id)) + if target_model_names: + return ",".join(target_model_names) + return fallback_model_name + + def get_models_from_unified_file_id(unified_file_id: str) -> list[str]: """ Extract model names from unified file ID. @@ -947,13 +958,11 @@ async def ensure_batch_response_managed_file_ids( if not model_id: return - model_name = hidden_params.get("model_name") unified_file_id: Final = hidden_params.get("unified_file_id") - if not model_name and isinstance(unified_file_id, str): - decoded_unified_file_id: Final = _is_base64_encoded_unified_file_id(unified_file_id) or unified_file_id - target_model_names: Final = get_models_from_unified_file_id(decoded_unified_file_id) - if target_model_names: - model_name = ",".join(target_model_names) + model_name: Final = resolve_managed_output_file_model_name( + unified_input_file_id=unified_file_id if isinstance(unified_file_id, str) else None, + fallback_model_name=hidden_params.get("model_name"), + ) if user_api_key_dict is None and db_batch_object is not None: from litellm.proxy._types import UserAPIKeyAuth diff --git a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py index 2a1b6ee222e..26df6463351 100644 --- a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py +++ b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py @@ -6,6 +6,7 @@ async_post_call_success_hook when processing completed batch responses. """ import asyncio +import base64 import json import pytest @@ -522,3 +523,80 @@ async def test_concurrent_first_registrations_converge_on_one_row(): } assert minted_ids[0] == minted_ids[1] assert upserted_row_keys == {minted_ids[0]} + + +def _b64_unified_input_file_id(target_model_names: str) -> str: + unified_input_file_id = ( + "litellm_proxy:application/octet-stream;unified_id,input-uuid;" + f"target_model_names,{target_model_names}" + ) + return base64.urlsafe_b64encode(unified_input_file_id.encode()).decode().rstrip("=") + + +@pytest.mark.asyncio +async def test_hook_mint_prefers_input_file_target_model_names(): + managed_files = _make_managed_files_instance() + batch_response = _make_batch_response(model_name="model-a") + batch_response._hidden_params["unified_file_id"] = _b64_unified_input_file_id( + "model-a,model-b" + ) + + mock_router = MagicMock() + mock_router.get_deployment_credentials_with_provider = MagicMock(return_value={}) + + with ( + patch("litellm.afile_retrieve", AsyncMock(return_value=_make_file_object())), + patch("litellm.proxy.proxy_server.llm_router", mock_router), + ): + await managed_files.async_post_call_success_hook( + data={}, + user_api_key_dict=_make_user_api_key_dict(), + response=batch_response, + ) + + assert batch_response.output_file_id == managed_files.get_unified_output_file_id( + output_file_id="file-output-abc", + model_id="model-deploy-xyz", + model_name="model-a,model-b", + ) + + +def test_cost_job_and_retrieve_paths_mint_identical_unified_output_file_ids(): + from litellm.proxy.openai_files_endpoints.common_utils import ( + resolve_managed_output_file_model_name, + ) + from litellm_enterprise.proxy.common_utils.check_batch_cost import CheckBatchCost + + managed_files = _make_managed_files_instance() + unified_input_file_id = _b64_unified_input_file_id("model-a,model-b") + + retrieve_path_model_name = resolve_managed_output_file_model_name( + unified_input_file_id=unified_input_file_id, + fallback_model_name="model-a", + ) + + job = MagicMock() + job.file_object = { + "id": "batch-123", + "completion_window": "24h", + "created_at": 1700000000, + "endpoint": "/v1/chat/completions", + "input_file_id": unified_input_file_id, + "object": "batch", + "status": "completed", + } + cost_job_model_name = CheckBatchCost._get_managed_file_model_name( + job=job, deployment_info=MagicMock(model_name="model-a") + ) + + assert retrieve_path_model_name == cost_job_model_name == "model-a,model-b" + + minted_ids = { + managed_files.get_unified_output_file_id( + output_file_id="file-output-abc", + model_id="model-deploy-xyz", + model_name=model_name, + ) + for model_name in (retrieve_path_model_name, cost_job_model_name) + } + assert len(minted_ids) == 1 From 888f911133022f5c2474271f1f6a0601c9de36ea Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 5 Aug 2026 17:46:39 -0700 Subject: [PATCH 22/38] refactor(ui): drop unreferenced locals from tests and narrow destructures Third and fourth slices of the sweep, combined because they raise nearly the same question and neither changes what runs. Nine test files plus one source file lose symbols whose only mention was their own declaration. Ten more narrow a destructure to the keys actually read, so `const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized()` keeps only `accessToken`. Aliases are preserved as written. ignoreRestSiblings stays on so the omit idiom `const { tags, ...rest } = metadata` is left alone; dropping `tags` there would fold it back into rest. ToolDetail is held back again. Its unread binding only looks like a plain deletion on the first pass, because the dead useMemo still reads it; one more pass exposes a useQuery that issues a real request. That belongs with the slices that get QA'd. Part of LIT-5162. --- .../pricing_calculator/multi_cost_results.test.tsx | 2 +- .../pricing_calculator/multi_export_dropdown.test.tsx | 2 +- .../_components/provider_discount_table.test.tsx | 2 +- .../guardrails/_components/guardrail_info.test.tsx | 2 +- .../mcp-servers/_components/MCPLogoSelector.test.tsx | 2 +- .../playground/components/chat_ui/ChatUI.test.tsx | 6 +++--- .../src/app/(dashboard)/ui-theme/UIThemeSettings.tsx | 2 +- ui/litellm-dashboard/src/components/CreateUserButton.tsx | 9 ++------- ui/litellm-dashboard/src/components/HelpLink.test.tsx | 2 +- .../src/components/ModelSelect/ModelSelect.tsx | 9 ++------- .../UsagePage/components/EntityUsage/TopKeyView.tsx | 2 +- .../src/components/add_model/AddModelForm.tsx | 2 +- .../components/agent_management/AgentSelector.test.tsx | 1 - .../components/common_components/DeleteResourceModal.tsx | 2 +- .../src/components/model_add/reuse_credentials.tsx | 2 +- ui/litellm-dashboard/src/components/navbar.test.tsx | 2 +- ui/litellm-dashboard/src/components/onboarding_link.tsx | 2 +- .../src/components/templates/key_info_view.test.tsx | 2 -- .../LogDetailsDrawer/RealtimePrettyView.test.tsx | 2 +- 19 files changed, 21 insertions(+), 34 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx index 6dc9309b5e3..04ef60469f0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { screen, within } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "../../../../../../tests/test-utils"; import MultiCostResults from "./multi_cost_results"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.test.tsx index 02940dd1325..e40fe7dbbca 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "../../../../../../tests/test-utils"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx index 2e8dbb429f0..f9a0a40f07d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { screen, within } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "../../../../../tests/test-utils"; import ProviderDiscountTable from "./provider_discount_table"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx index 7bb7737e152..5fcf07d3f8b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx @@ -229,7 +229,7 @@ describe("Guardrail Info", () => { vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({}); vi.mocked(networking.updateGuardrailCall).mockResolvedValue({ status: "success" }); - const { getByText, getByRole, getAllByRole, getByLabelText } = render( + const { getByText, getByLabelText } = render( {}} accessToken="123" isAdmin={true} />, ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx index 67b5d6bfe92..f126a030b9f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent from "@testing-library/user-event"; import MCPLogoSelector from "./MCPLogoSelector"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx index b5f2bf7b10c..3c94977f0dc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx @@ -60,7 +60,7 @@ describe("ChatUI", () => { }); it("should show the voice selector when the endpoint type is audio_speech", async () => { - const { getByText, container } = render( + const { getByText } = render( { }); it("should allow the user to select a model", async () => { - const { getByText, container } = render( + const { getByText } = render( { { model_group: "ResponsesModel", mode: "responses" }, ]); - const { getByText, baseElement } = render( + const { getByText } = render( = ({ userID, userRole, accessToken }) => { - const { logoUrl, setLogoUrl, faviconUrl, setFaviconUrl } = useTheme(); + const { setLogoUrl, setFaviconUrl } = useTheme(); const [logoUrlInput, setLogoUrlInput] = useState(""); const [faviconUrlInput, setFaviconUrlInput] = useState(""); const [loading, setLoading] = useState(false); diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.tsx index 2b0c801f0bd..8de9f010fa3 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.tsx @@ -15,7 +15,7 @@ import { Tooltip, Typography, } from "antd"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useState } from "react"; import BulkCreateUsers from "./bulk_create_users_button"; import TeamDropdown from "./common_components/team_dropdown"; import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; @@ -29,7 +29,7 @@ import { } from "./networking"; import OnboardingModal, { InvitationLink } from "./onboarding_link"; const { Option } = Select; -const { Text, Link, Title } = Typography; +const { Text, Link } = Typography; // Helper function to generate UUID compatible across all environments const generateUUID = (): string => { if (typeof crypto !== "undefined" && crypto.randomUUID) { @@ -80,11 +80,6 @@ export const CreateUserButton: React.FC = ({ const { data: organizations = [] } = useOrganizations(); // Derive teams from the user's organizations, falling back to the teams prop - const availableTeams = useMemo(() => { - const orgTeams = organizations.flatMap((org) => org.teams || []); - if (orgTeams.length > 0) return orgTeams; - return teams || []; - }, [organizations, teams]); useEffect(() => { const fetchData = async () => { diff --git a/ui/litellm-dashboard/src/components/HelpLink.test.tsx b/ui/litellm-dashboard/src/components/HelpLink.test.tsx index b1709436c72..e502126477a 100644 --- a/ui/litellm-dashboard/src/components/HelpLink.test.tsx +++ b/ui/litellm-dashboard/src/components/HelpLink.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "../../tests/test-utils"; diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx index 0965683c241..e993fed2408 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx @@ -2,7 +2,7 @@ import { ProxyModel, useAllProxyModels } from "@/app/(dashboard)/hooks/models/us import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; -import { Select, Skeleton, Tooltip, type SelectProps } from "antd"; +import { Select, Skeleton, Tooltip } from "antd"; import { Organization, Team } from "../networking"; import { splitWildcardModels } from "./modelUtils"; @@ -93,8 +93,7 @@ const filterModels = ( export const ModelSelect = (props: ModelSelectProps) => { const { teamID, organizationID, options, context, dataTestId, value = [], onChange, style } = props; - const { includeUserModels, showAllTeamModelsOption, showAllProxyModelsOverride, includeSpecialOptions } = - options || {}; + const { showAllProxyModelsOverride, includeSpecialOptions } = options || {}; const { data: allProxyModels, isLoading: isLoadingAllProxyModels } = useAllProxyModels(); const { data: team, isLoading: isLoadingTeam } = useTeam(teamID); const { data: organization, isLoading: isLoadingOrganization } = useOrganization(organizationID); @@ -113,10 +112,6 @@ export const ModelSelect = (props: ModelSelectProps) => { return ; } - const optionRender: NonNullable = (option) => { - return {option.label}; - }; - const handleChange = (values: string[]) => { const specialValues = values.filter(isSpecialOption); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx index 60f25837589..1542b313334 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx @@ -20,7 +20,7 @@ interface TopKeyViewProps { } const TopKeyView: React.FC = ({ topKeys, teams, showTags = false, topKeysLimit, setTopKeysLimit }) => { - const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); + const { accessToken } = useAuthorized(); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedKey, setSelectedKey] = useState(null); const [keyData, setKeyData] = useState(undefined); diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index 03be04b9919..307f3665e83 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -66,7 +66,7 @@ const AddModelForm: React.FC = ({ } = useProviderFields(); const { data: guardrailsData } = useGuardrails(); const guardrailsList = guardrailsData?.guardrails.map((g) => g.guardrail_name); - const { data: tagsList, isLoading: isTagsLoading, error: tagsError } = useTags(); + const { data: tagsList } = useTags(); const handleTestConnection = async () => { setIsTestingConnection(true); diff --git a/ui/litellm-dashboard/src/components/agent_management/AgentSelector.test.tsx b/ui/litellm-dashboard/src/components/agent_management/AgentSelector.test.tsx index e483fd833c6..a41167df673 100644 --- a/ui/litellm-dashboard/src/components/agent_management/AgentSelector.test.tsx +++ b/ui/litellm-dashboard/src/components/agent_management/AgentSelector.test.tsx @@ -1,5 +1,4 @@ import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi, beforeEach } from "vitest"; // Mock networking module diff --git a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx index 26419585a4c..5a6160483a0 100644 --- a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx +++ b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx @@ -32,7 +32,7 @@ export default function DeleteResourceModal({ confirmLoading, requiredConfirmation, }: DeleteResourceModalProps) { - const { Title, Text } = Typography; + const { Text } = Typography; const { token } = theme.useToken(); const [requiredConfirmationInput, setRequiredConfirmationInput] = useState(""); diff --git a/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx b/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx index 715bb3bcbf8..85ba82ea046 100644 --- a/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx +++ b/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Form, Button, Tooltip, Typography, Modal } from "antd"; import { TextInput } from "@tremor/react"; import { CredentialItem } from "../networking"; -const { Title, Link } = Typography; +const { Link } = Typography; interface ReuseCredentialsModalProps { isVisible: boolean; diff --git a/ui/litellm-dashboard/src/components/navbar.test.tsx b/ui/litellm-dashboard/src/components/navbar.test.tsx index 4a8fe2a6094..72c15990f20 100644 --- a/ui/litellm-dashboard/src/components/navbar.test.tsx +++ b/ui/litellm-dashboard/src/components/navbar.test.tsx @@ -1,5 +1,5 @@ import userEvent from "@testing-library/user-event"; -import React, { useState } from "react"; +import React from "react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders, screen, waitFor } from "../../tests/test-utils"; import Navbar from "./navbar"; diff --git a/ui/litellm-dashboard/src/components/onboarding_link.tsx b/ui/litellm-dashboard/src/components/onboarding_link.tsx index 0c27287a9e4..b5f3c6d3e17 100644 --- a/ui/litellm-dashboard/src/components/onboarding_link.tsx +++ b/ui/litellm-dashboard/src/components/onboarding_link.tsx @@ -58,7 +58,7 @@ export default function OnboardingModal({ invitationLinkData, modalType = "invitation", }: OnboardingProps) { - const { Title, Paragraph } = Typography; + const { Paragraph } = Typography; const handleInvitationOk = () => { setIsInvitationLinkModalVisible(false); }; diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx index e46c6fd5c30..98339f6ffc8 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx @@ -1,11 +1,9 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; import { renderWithProviders } from "../../../tests/test-utils"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useResetKeySpend } from "@/app/(dashboard)/hooks/keys/useResetKeySpend"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; import { keyDeleteCall, keyUpdateCall } from "../networking"; import { QueryClient } from "@tanstack/react-query"; diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.test.tsx index 286b55ca6d6..4f8f662aa16 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { RealtimePrettyView, isRealtimeResponse } from "./RealtimePrettyView"; From ce5c4c1bf914189cda1fb73ebf8ae4118de0a6a5 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 5 Aug 2026 18:07:17 -0700 Subject: [PATCH 23/38] refactor(ui): drop dead locals and unused React state across the dashboard Removes declarations nothing reads, along with the writes that fed them, so the remaining code says what it actually does. Where a declaration was dead but its initializer had a real effect, the call survives and only the binding goes: spies stay installed, renders still run, and every awaited request keeps its await. Pure computations are deleted whole rather than left as statements that build a value and throw it away. Dead useState pairs are removed outright instead of being elided to const [, setX], which would keep a hook and every write to a value nothing reads. Three chains turned out to be dead end to end and are removed with their fetches: the tool detail team list, the Teams MCP access group load, and the user dashboard proxy settings load. ColumnMeta's declaration merging in columnMeta.ts and view_logs/table.tsx is a false positive; TypeScript requires those type parameters to match the upstream signature exactly, so both get a scoped suppression instead. --- ui/litellm-dashboard/eslint-suppressions.json | 14 +-- .../agents/_components/add_agent_form.tsx | 6 +- .../caching/_components/cache_health.tsx | 3 - .../_components/add_guardrail_form.tsx | 115 +----------------- .../(dashboard)/hooks/teams/useTeams.test.ts | 3 - .../(dashboard)/hooks/useAuthorized.test.ts | 2 +- .../policies/_components/add_policy_form.tsx | 5 - .../policies/_components/policy_info.tsx | 4 - .../VersionHistorySidePanel.test.tsx | 4 +- .../UsageViewSelect/UsageViewSelect.test.tsx | 9 +- .../AIHub/UsefulLinksManagement.tsx | 4 - .../EntityUsageExport/utils.test.ts | 8 +- .../Modals/EditSSOSettingsModal.test.tsx | 2 +- ui/litellm-dashboard/src/components/Teams.tsx | 92 +------------- .../src/components/ToolDetail.tsx | 27 ---- .../add_model/handle_add_model_submit.tsx | 2 +- .../add_model/model_connection_test.tsx | 5 +- .../edit_auto_router_modal.tsx | 13 -- .../components/llm_calls/chat_completion.tsx | 6 - .../organisms/create_key_button.tsx | 29 ----- .../organization/organization_view.tsx | 4 +- .../src/components/per_user_usage.tsx | 4 - .../src/components/price_data_reload.tsx | 8 -- .../components/shared/DataTable/columnMeta.ts | 1 + .../components/team/LoggingSettings.test.tsx | 2 - .../src/components/team/TeamInfo.tsx | 45 +------ .../KeyInfoView.handleKeyUpdate.test.tsx | 12 +- .../templates/key_edit_view.test.tsx | 4 +- .../components/templates/key_info_view.tsx | 6 +- .../src/components/user_dashboard.tsx | 52 +------- .../SimpleToolCallBlock.test.tsx | 2 +- .../src/components/view_logs/table.tsx | 1 + 32 files changed, 39 insertions(+), 455 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 50b652ff57e..e5d17d49a33 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -42,9 +42,6 @@ }, "react-hooks/set-state-in-effect": { "count": 2 - }, - "unused-imports/no-unused-imports": { - "count": 1 } }, "src/app/(dashboard)/agents/_components/agent_card_discovery.tsx": { @@ -2440,7 +2437,7 @@ "count": 2 }, "react-hooks/set-state-in-effect": { - "count": 3 + "count": 1 } }, "src/components/TeamsPage/teamTableColumns.tsx": { @@ -2448,11 +2445,6 @@ "count": 1 } }, - "src/components/ToolDetail.tsx": { - "unused-imports/no-unused-imports": { - "count": 1 - } - }, "src/components/UIAccessControlForm.tsx": { "no-restricted-imports": { "count": 2 @@ -3383,7 +3375,7 @@ "count": 2 }, "prefer-const": { - "count": 4 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 4 @@ -4005,7 +3997,7 @@ "count": 1 }, "react-hooks/set-state-in-effect": { - "count": 2 + "count": 1 } }, "src/components/vector_store_management/VectorStoreSelector.test.tsx": { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx index e35388b78da..677ba9e6285 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { Modal, Form, Select, Input, Steps, Radio, Tag, Divider, Switch, InputNumber, Collapse } from "antd"; +import { Modal, Form, Select, Input, Steps, Radio, Tag, Divider, Switch, InputNumber } from "antd"; import MessageManager from "@/components/molecules/message_manager"; import { Logo } from "@/components/molecules/logo/Logo"; import { Button } from "@tremor/react"; @@ -47,7 +47,6 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok const [isSubmitting, setIsSubmitting] = useState(false); const [agentType, setAgentType] = useState("a2a"); const [agentTypeMetadata, setAgentTypeMetadata] = useState([]); - const [loadingMetadata, setLoadingMetadata] = useState(false); // Step 3: key assignment state const [keyAssignOption, setKeyAssignOption] = useState<"create_new" | "existing_key" | "skip">("create_new"); @@ -82,14 +81,11 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok // Fetch agent type metadata on mount useEffect(() => { const fetchMetadata = async () => { - setLoadingMetadata(true); try { const metadata = await getAgentCreateMetadata(); setAgentTypeMetadata(metadata); } catch (error) { console.error("Error fetching agent metadata:", error); - } finally { - setLoadingMetadata(false); } }; fetchMetadata(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx index 85649c1cf26..0f52bed874d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx @@ -20,14 +20,11 @@ const deepParse = (input: any) => { // TableClickableErrorField component with copy-to-clipboard functionality const TableClickableErrorField: React.FC<{ label: string; value: string | null | undefined }> = ({ label, value }) => { const [isExpanded, setIsExpanded] = React.useState(false); - const [copied, setCopied] = React.useState(false); const safeValue = value?.toString() || "N/A"; const truncated = safeValue.length > 50 ? safeValue.substring(0, 50) + "..." : safeValue; const handleCopy = () => { navigator.clipboard.writeText(safeValue); - setCopied(true); - setTimeout(() => setCopied(false), 2000); }; return ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx index 251ce631beb..5f15eaceb28 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx @@ -1,4 +1,4 @@ -import { Form, Input, Modal, Select, Tag, Typography, Button } from "antd"; +import { Form, Input, Modal, Select, Tag, Button } from "antd"; import React, { useEffect, useMemo, useState } from "react"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { @@ -30,7 +30,6 @@ import LLMJudgeFields from "./llm_judge/LLMJudgeFields"; import PiiConfiguration from "./pii_configuration"; import ToolPermissionRulesEditor, { ToolPermissionConfig } from "./tool_permission/ToolPermissionRulesEditor"; -const { Title, Text, Link } = Typography; const { Option } = Select; // Define human-friendly descriptions for each mode @@ -163,11 +162,6 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a const [currentStep, setCurrentStep] = useState(0); const [providerParams, setProviderParams] = useState(null); - // Azure Text Moderation state - const [selectedCategories, setSelectedCategories] = useState([]); - const [globalSeverityThreshold, setGlobalSeverityThreshold] = useState(2); - const [categorySpecificThresholds, setCategorySpecificThresholds] = useState<{ [key: string]: number }>({}); - // Content Filter state const [selectedPatterns, setSelectedPatterns] = useState([]); const [blockedWords, setBlockedWords] = useState([]); @@ -297,11 +291,6 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a setSelectedEntities([]); setSelectedActions({}); - // Reset Azure Text Moderation selections when changing provider - setSelectedCategories([]); - setGlobalSeverityThreshold(2); - setCategorySpecificThresholds({}); - // Reset Content Filter selections setSelectedPatterns([]); setBlockedWords([]); @@ -335,24 +324,6 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a })); }; - // Azure Text Moderation handlers - const handleCategorySelect = (category: string) => { - setSelectedCategories((prev) => - prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category], - ); - }; - - const handleGlobalSeverityChange = (threshold: number) => { - setGlobalSeverityThreshold(threshold); - }; - - const handleCategorySeverityChange = (category: string, threshold: number) => { - setCategorySpecificThresholds((prev) => ({ - ...prev, - [category]: threshold, - })); - }; - const nextStep = async () => { try { // Validate current step fields @@ -388,53 +359,11 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a setCurrentStep(currentStep - 1); }; - const handleAddAndContinue = (competitorIntentOnly?: boolean) => { - // Competitor intent only: just advance to next step (no category to add) - if (competitorIntentOnly) { - setCurrentStep(currentStep + 1); - return; - } - - if (!pendingCategorySelection || !guardrailSettings) return; - - const contentFilterSettings = guardrailSettings.content_filter_settings; - if (!contentFilterSettings) return; - - const category = contentFilterSettings.content_categories?.find((c) => c.name === pendingCategorySelection); - if (!category) return; - - // Check if already added - if (selectedContentCategories.some((c) => c.category === pendingCategorySelection)) { - setPendingCategorySelection(""); - setCurrentStep(currentStep + 1); - return; - } - - // Add the category - setSelectedContentCategories([ - ...selectedContentCategories, - { - id: `category-${Date.now()}`, - category: category.name, - display_name: category.display_name, - action: category.default_action as "BLOCK" | "MASK", - severity_threshold: "medium", - }, - ]); - - // Clear pending selection and advance to next step - setPendingCategorySelection(""); - setCurrentStep(currentStep + 1); - }; - const resetForm = () => { form.resetFields(); setSelectedProvider(null); setSelectedEntities([]); setSelectedActions({}); - setSelectedCategories([]); - setGlobalSeverityThreshold(2); - setCategorySpecificThresholds({}); setSelectedPatterns([]); setBlockedWords([]); setSelectedContentCategories([]); @@ -965,48 +894,6 @@ const AddGuardrailForm: React.FC = ({ visible, onClose, a } }; - const renderStepButtons = () => { - const totalSteps = shouldRenderContentFilterConfigSettings(selectedProvider) ? 5 : 2; - const isLastStep = currentStep === totalSteps - 1; - const isCategoriesStep = shouldRenderContentFilterConfigSettings(selectedProvider) && currentStep === 1; - const hasPendingCategory = pendingCategorySelection !== ""; - const hasCompetitorIntentConfigured = - competitorIntentEnabled && (competitorIntentConfig?.brand_self?.length ?? 0) > 0; - const canContinueFromCategoriesStep = hasPendingCategory || hasCompetitorIntentConfigured; - - return ( -
- {currentStep > 0 && } - {isCategoriesStep ? ( - <> - - - - ) : ( - <> - {!isLastStep && ( - - )} - {isLastStep && ( - - )} - - )} - -
- ); - }; - const renderEndpointSettings = () => { return (
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts index 28320b76597..66dfc43cebb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts @@ -436,9 +436,6 @@ describe("useTeam", () => { showSSOBanner: false, }); - // Import useQueryClient to get access to query client - const { useQueryClient } = await import("@tanstack/react-query"); - // Manually test the queryFn logic by calling it directly // This simulates what would happen if enabled check was bypassed const testQueryFn = async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts index bb14f6c3d21..1ca0581a391 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts @@ -305,7 +305,7 @@ describe("useAuthorized", () => { const token = createJwt(decodedPayload); document.cookie = `token=${token}; path=/;`; - const { result } = renderHook(() => useAuthorized(), { wrapper }); + renderHook(() => useAuthorized(), { wrapper }); await waitFor(() => { expect(clearTokenCookiesMock).toHaveBeenCalled(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx index 8da2d3af36f..74e8bd3c6d9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx @@ -8,7 +8,6 @@ import NotificationsManager from "@/components/molecules/notifications_manager"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; const { Text } = Typography; -const { Option } = Select; interface AddPolicyFormProps { visible: boolean; @@ -162,7 +161,6 @@ const AddPolicyForm: React.FC = ({ const [form] = Form.useForm(); const [isSubmitting, setIsSubmitting] = useState(false); const [resolvedGuardrails, setResolvedGuardrails] = useState([]); - const [isLoadingResolved, setIsLoadingResolved] = useState(false); const [modelConditionType, setModelConditionType] = useState<"model" | "regex">("model"); const [availableModels, setAvailableModels] = useState([]); const [step, setStep] = useState<"pick_mode" | "simple_form">("pick_mode"); @@ -231,14 +229,11 @@ const AddPolicyForm: React.FC = ({ const loadResolvedGuardrails = async (policyId: string) => { if (!accessToken) return; - setIsLoadingResolved(true); try { const data = await getResolvedGuardrails(accessToken, policyId); setResolvedGuardrails(data.resolved_guardrails || []); } catch (error) { console.error("Failed to load resolved guardrails:", error); - } finally { - setIsLoadingResolved(false); } }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_info.tsx index c49093f3238..0a5c7ef1186 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_info.tsx @@ -53,7 +53,6 @@ const PolicyInfoView: React.FC = ({ const [policy, setPolicy] = useState(null); const [isLoading, setIsLoading] = useState(true); const [resolvedGuardrails, setResolvedGuardrails] = useState([]); - const [isLoadingResolved, setIsLoadingResolved] = useState(false); const fetchPolicy = useCallback(async () => { if (!accessToken || !policyId) return; @@ -64,14 +63,11 @@ const PolicyInfoView: React.FC = ({ setPolicy(data); // Also fetch resolved guardrails - setIsLoadingResolved(true); try { const resolvedData = await getResolvedGuardrails(accessToken, policyId); setResolvedGuardrails(resolvedData.resolved_guardrails || []); } catch (error) { console.error("Error fetching resolved guardrails:", error); - } finally { - setIsLoadingResolved(false); } } catch (error) { console.error("Error fetching policy:", error); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/VersionHistorySidePanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/VersionHistorySidePanel.test.tsx index c76c64b89a6..97f671b1b71 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/VersionHistorySidePanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/VersionHistorySidePanel.test.tsx @@ -182,7 +182,7 @@ describe("VersionHistorySidePanel", () => { render(); await waitFor(() => { - const versionItems = screen.getAllByTestId("tag"); + screen.getAllByTestId("tag"); // Should have Active tag for the selected version expect(screen.getByText("Active")).toBeInTheDocument(); }); @@ -464,7 +464,7 @@ describe("VersionHistorySidePanel", () => { render(); await waitFor(() => { - const versionElements = screen.getAllByTestId("tag"); + screen.getAllByTestId("tag"); // Verify versions are displayed as they come from the API expect(screen.getByText("v2")).toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx index b33129fcdb4..aa9ff921ee1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx @@ -7,13 +7,10 @@ vi.mock("antd", async () => { function Select(props: any) { const { value, onChange, options, optionRender, labelRender, ...rest } = props; - const selectedOption = options?.find((opt: any) => opt.value === value); - const renderedLabel = labelRender ? labelRender({ value, label: selectedOption?.label }) : selectedOption?.label; - const optionElements = options?.map((opt: any) => { - const rendered = optionRender ? optionRender({ value: opt.value, label: opt.label }) : opt.label; - return React.createElement("option", { key: opt.value, value: opt.value }, opt.label); - }); + const optionElements = options?.map((opt: any) => + React.createElement("option", { key: opt.value, value: opt.value }, opt.label), + ); const optionRenderOutputs = options ?.map((opt: any) => { diff --git a/ui/litellm-dashboard/src/components/AIHub/UsefulLinksManagement.tsx b/ui/litellm-dashboard/src/components/AIHub/UsefulLinksManagement.tsx index 42acfd94185..f29504fac50 100644 --- a/ui/litellm-dashboard/src/components/AIHub/UsefulLinksManagement.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/UsefulLinksManagement.tsx @@ -23,7 +23,6 @@ const UsefulLinksManagement: React.FC = ({ accessTok const [links, setLinks] = useState([]); const [newLink, setNewLink] = useState({ url: "", displayName: "" }); const [editingLink, setEditingLink] = useState(null); - const [loading, setLoading] = useState(false); const [isExpanded, setIsExpanded] = useState(true); const [isRearranging, setIsRearranging] = useState(false); const [originalLinksOrder, setOriginalLinksOrder] = useState([]); @@ -32,7 +31,6 @@ const UsefulLinksManagement: React.FC = ({ accessTok if (!accessToken) return; try { - setLoading(true); const response = await getPublicModelHubInfo(); if (response && response.useful_links) { @@ -73,8 +71,6 @@ const UsefulLinksManagement: React.FC = ({ accessTok } catch (error) { console.error("Error fetching useful links:", error); setLinks([]); - } finally { - setLoading(false); } }; diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts index dfeecdc4fa6..e91b7b73a1e 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts @@ -1876,7 +1876,7 @@ describe("EntityUsageExport utils", () => { it("should create CSV file and trigger download", () => { const createObjectURLSpy = vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:mock-url"); - const revokeObjectURLSpy = vi.spyOn(window.URL, "revokeObjectURL"); + vi.spyOn(window.URL, "revokeObjectURL"); const createElementSpy = vi.spyOn(document, "createElement"); const appendChildSpy = vi.spyOn(document.body, "appendChild"); const removeChildSpy = vi.spyOn(document.body, "removeChild"); @@ -1892,7 +1892,7 @@ describe("EntityUsageExport utils", () => { it("should generate correct filename", () => { const anchorElement = document.createElement("a"); - const createElementSpy = vi.spyOn(document, "createElement").mockReturnValue(anchorElement); + vi.spyOn(document, "createElement").mockReturnValue(anchorElement); const today = new Date().toISOString().split("T")[0]; @@ -1935,7 +1935,7 @@ describe("EntityUsageExport utils", () => { it("should create JSON file and trigger download", () => { const createObjectURLSpy = vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:mock-url"); - const revokeObjectURLSpy = vi.spyOn(window.URL, "revokeObjectURL"); + vi.spyOn(window.URL, "revokeObjectURL"); const createElementSpy = vi.spyOn(document, "createElement"); const appendChildSpy = vi.spyOn(document.body, "appendChild"); const removeChildSpy = vi.spyOn(document.body, "removeChild"); @@ -1955,7 +1955,7 @@ describe("EntityUsageExport utils", () => { it("should generate correct filename", () => { const anchorElement = document.createElement("a"); - const createElementSpy = vi.spyOn(document, "createElement").mockReturnValue(anchorElement); + vi.spyOn(document, "createElement").mockReturnValue(anchorElement); const today = new Date().toISOString().split("T")[0]; const mockDateRange: DateRangePickerValue = { diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx index 7415683af83..7536286b404 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx @@ -320,7 +320,7 @@ describe("EditSSOSettingsModal", () => { useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false }, }); - const { mockOnSuccess } = renderComponent(); + renderComponent(); fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT)); diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index edf376cb8d4..95642b93019 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -22,16 +22,13 @@ import AgentSelector from "./agent_management/AgentSelector"; import ModelAliasManager from "./common_components/ModelAliasManager"; import PremiumLoggingSettings from "./common_components/PremiumLoggingSettings"; import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "./common_components/RouterSettingsAccordion"; -import { - fetchAvailableModelsForTeamOrKey, - unfurlWildcardModelsInList, -} from "./key_team_helpers/fetch_available_models_team_key"; +import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key"; import type { Team } from "./key_team_helpers/key_list"; import MCPServerSelector from "./mcp_server_management/MCPServerSelector"; import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions"; import NotificationsManager from "./molecules/notifications_manager"; import { extractProxyErrorMessage } from "@/lib/http/client"; -import { Organization, fetchMCPAccessGroups, getGuardrailsList, getPoliciesList, teamDeleteCall } from "./networking"; +import { Organization, getGuardrailsList, getPoliciesList, teamDeleteCall } from "./networking"; import NumericalInput from "./shared/numerical_input"; import VectorStoreSelector from "./vector_store_management/VectorStoreSelector"; import SearchToolSelector from "./search_tools/SearchToolSelector"; @@ -43,35 +40,10 @@ interface TeamProps { premiumUser?: boolean; } -interface EditTeamModalProps { - visible: boolean; - onCancel: () => void; - team: any; // Assuming TeamType is a type representing your team object - onSubmit: (data: FormData) => void; // Assuming FormData is the type of data to be submitted -} - import DeleteResourceModal from "./common_components/DeleteResourceModal"; import { teamCreateCall } from "./networking"; import { ModelSelect } from "./ModelSelect/ModelSelect"; -const getOrganizationModels = (organization: Organization | null, userModels: string[]) => { - let tempModelsToPick = []; - - if (organization) { - if (organization.models.length > 0) { - tempModelsToPick = organization.models; - } else { - // show all available models if the team has no models set - tempModelsToPick = userModels; - } - } else { - // no team set, show all available models - tempModelsToPick = userModels; - } - - return unfurlWildcardModelsInList(tempModelsToPick, userModels); -}; - const canCreateOrManageTeams = ( userRole: string | null, userID: string | null, @@ -112,18 +84,6 @@ const getAdminOrganizations = ( return []; }; -const getOrganizationAlias = ( - organizationId: string | null | undefined, - organizations: Organization[] | null | undefined, -): string => { - if (!organizationId || !organizations) { - return organizationId || "N/A"; - } - - const organization = organizations.find((org) => org.organization_id === organizationId); - return organization?.organization_alias || organizationId; -}; - // @deprecated const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser = false }) => { const { data: organizationsData } = useOrganizations(); @@ -135,35 +95,25 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState(null); const [form] = Form.useForm(); - const [memberForm] = Form.useForm(); - const [value, setValue] = useState(""); - const [editModalVisible, setEditModalVisible] = useState(false); const [selectedTeam, setSelectedTeam] = useState(null); const [selectedTeamId, setSelectedTeamId] = useQueryState("team", parseAsString.withOptions({ history: "push" })); const [editTeam, setEditTeam] = useState(false); const [isTeamModalVisible, setIsTeamModalVisible] = useState(false); - const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); - const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); const [userModels, setUserModels] = useState([]); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [teamToDelete, setTeamToDelete] = useState(null); - const [modelsToPick, setModelsToPick] = useState([]); const [isTeamDeleting, setIsTeamDeleting] = useState(false); // Add this state near the other useState declarations const [guardrailsList, setGuardrailsList] = useState([]); const [policiesList, setPoliciesList] = useState([]); const [loggingSettings, setLoggingSettings] = useState([]); - const [mcpAccessGroups, setMcpAccessGroups] = useState([]); - const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false); const [modelAliases, setModelAliases] = useState<{ [key: string]: string }>({}); const [routerSettings, setRouterSettings] = useState(null); const [routerSettingsKey, setRouterSettingsKey] = useState(0); useEffect(() => { - const models = getOrganizationModels(currentOrgForCreateTeam, userModels); - setModelsToPick(models); form.setFieldValue("models", []); }, [currentOrgForCreateTeam, userModels]); @@ -220,22 +170,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser fetchPolicies(); }, [accessToken]); - const fetchMcpAccessGroups = async () => { - try { - if (accessToken == null) { - return; - } - const groups = await fetchMCPAccessGroups(accessToken); - setMcpAccessGroups(groups); - } catch (error) { - console.error("Failed to fetch MCP access groups:", error); - } - }; - - useEffect(() => { - fetchMcpAccessGroups(); - }, [accessToken]); - const handleOk = () => { setIsTeamModalVisible(false); form.resetFields(); @@ -245,12 +179,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser setRouterSettingsKey((prev) => prev + 1); }; - const handleMemberOk = () => { - setIsAddMemberModalVisible(false); - setIsEditMemberModalVisible(false); - memberForm.resetFields(); - }; - const handleCancel = () => { setIsTeamModalVisible(false); form.resetFields(); @@ -260,12 +188,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser setRouterSettingsKey((prev) => prev + 1); }; - const handleMemberCancel = () => { - setIsAddMemberModalVisible(false); - setIsEditMemberModalVisible(false); - memberForm.resetFields(); - }; - const handleDelete = async (team: Team) => { // Set the team to delete and open the confirmation modal setTeamToDelete(team); @@ -749,15 +671,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser /> - { - if (!mcpAccessGroupsLoaded) { - fetchMcpAccessGroups(); - setMcpAccessGroupsLoaded(true); - } - }} - > + Additional Settings diff --git a/ui/litellm-dashboard/src/components/ToolDetail.tsx b/ui/litellm-dashboard/src/components/ToolDetail.tsx index 06f14638141..d90689fc627 100644 --- a/ui/litellm-dashboard/src/components/ToolDetail.tsx +++ b/ui/litellm-dashboard/src/components/ToolDetail.tsx @@ -24,12 +24,9 @@ import { fetchToolPolicyOptions, getToolUsageLogs, keyListCall, - teamListCall, updateToolPolicy, - type ToolPolicyOption, type ToolPolicyOverrideRow, } from "@/components/networking"; -import type { Team } from "@/components/key_team_helpers/key_list"; interface ToolDetailProps { toolName: string; @@ -87,12 +84,6 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) { staleTime: 60_000, }); - const { data: teamsData } = useQuery({ - queryKey: ["teams-list-tool-detail"], - queryFn: () => teamListCall(accessToken!, null, null), - enabled: !!accessToken, - }); - const { data: keysData } = useQuery({ queryKey: ["keys-list-tool-detail"], queryFn: () => keyListCall(accessToken!, null, null, null, null, null, 1, 100), @@ -122,24 +113,6 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) { })); }, [logsData?.logs]); - const teams: Team[] = useMemo(() => { - const arr = Array.isArray(teamsData) ? teamsData : teamsData?.data ?? []; - return arr.map((t: { team_id?: string; id?: string; team_alias?: string }) => ({ - team_id: t.team_id ?? t.id ?? "", - team_alias: t.team_alias ?? t.team_id ?? "", - models: [], - max_budget: null, - budget_duration: null, - tpm_limit: null, - rpm_limit: null, - organization_id: "", - created_at: "", - keys: [], - members_with_roles: [], - spend: 0, - })); - }, [teamsData]); - const keys: KeyOption[] = useMemo(() => { const keysRes = keysData?.keys ?? keysData?.data ?? []; return keysRes.map((k: { token?: string; api_key?: string; key_hash?: string; key_alias?: string }) => ({ diff --git a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx index 71590660765..908a4c498dc 100644 --- a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx +++ b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx @@ -197,7 +197,7 @@ export const handleAddModelSubmit = async (values: any, accessToken: string, for model_info: modelInfoObj, }; - const response: any = await modelCreateCall(accessToken, new_model); + await modelCreateCall(accessToken, new_model); } callback && callback(); diff --git a/ui/litellm-dashboard/src/components/add_model/model_connection_test.tsx b/ui/litellm-dashboard/src/components/add_model/model_connection_test.tsx index 5b284fd26f5..62bb5975478 100644 --- a/ui/litellm-dashboard/src/components/add_model/model_connection_test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/model_connection_test.tsx @@ -24,7 +24,6 @@ const ModelConnectionTest: React.FC = ({ onTestComplete, }) => { const [error, setError] = React.useState(null); - const [rawRequest, setRawRequest] = React.useState(null); const [rawResponse, setRawResponse] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); const [isSuccess, setIsSuccess] = React.useState(false); @@ -34,7 +33,6 @@ const ModelConnectionTest: React.FC = ({ setIsLoading(true); setShowDetails(false); setError(null); - setRawRequest(null); setRawResponse(null); setIsSuccess(false); @@ -51,7 +49,7 @@ const ModelConnectionTest: React.FC = ({ return; } - const { litellmParamsObj, modelInfoObj, modelName: returnedModelName } = result[0]; + const { litellmParamsObj, modelInfoObj } = result[0]; const response = await testConnectionRequest(accessToken, litellmParamsObj, modelInfoObj, modelInfoObj?.mode); if (response.status === "success") { @@ -61,7 +59,6 @@ const ModelConnectionTest: React.FC = ({ } else { const errorMessage = response.result?.error || response.message || "Unknown error"; setError(errorMessage); - setRawRequest(litellmParamsObj); setRawResponse(response.result?.raw_request_typed_dict); setIsSuccess(false); } diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx index f9005e099a5..cf1a5727948 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx @@ -158,8 +158,6 @@ const EditAutoRouterModal: React.FC = ({ const [loading, setLoading] = useState(false); const [modelAccessGroups, setModelAccessGroups] = useState([]); const [modelInfo, setModelInfo] = useState([]); - const [showCustomDefaultModel, setShowCustomDefaultModel] = useState(false); - const [showCustomEmbeddingModel, setShowCustomEmbeddingModel] = useState(false); const [showValidationErrors, setShowValidationErrors] = useState(false); const [routerConfig, setRouterConfig] = useState(null); const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState([]); @@ -308,11 +306,6 @@ const EditAutoRouterModal: React.FC = ({ auto_router_embedding_model: modelData.litellm_params?.auto_router_embedding_model || "", model_access_group: modelData.model_info?.access_groups || [], }); - - // Check if using custom models - const allModelGroups = new Set(modelInfo.map((model) => model.model_group)); - setShowCustomDefaultModel(!allModelGroups.has(modelData.litellm_params?.auto_router_default_model)); - setShowCustomEmbeddingModel(!allModelGroups.has(modelData.litellm_params?.auto_router_embedding_model)); } catch (error) { console.error("Error parsing auto router config:", error); NotificationsManager.fromBackend("Error loading auto router configuration"); @@ -516,9 +509,6 @@ const EditAutoRouterModal: React.FC = ({ > { - setShowCustomDefaultModel(value === "custom"); - }} options={[...modelOptions, { value: "custom", label: "Enter custom model name" }]} showSearch={true} /> @@ -532,9 +522,6 @@ const EditAutoRouterModal: React.FC = ({ > { - setShowCustomEmbeddingModel(value === "custom"); - }} options={[...modelOptions, { value: "custom", label: "Enter custom model name" }]} showSearch={true} /> diff --git a/ui/litellm-dashboard/src/components/llm_calls/chat_completion.tsx b/ui/litellm-dashboard/src/components/llm_calls/chat_completion.tsx index c20d758fe91..549f309b4ea 100644 --- a/ui/litellm-dashboard/src/components/llm_calls/chat_completion.tsx +++ b/ui/litellm-dashboard/src/components/llm_calls/chat_completion.tsx @@ -73,10 +73,6 @@ export async function makeOpenAIChatCompletionRequest( let firstTokenReceived = false; let timeToFirstToken: number | undefined = undefined; - // For collecting complete response text - let fullResponseContent = ""; - let fullReasoningContent = ""; - // Track MCP metadata cumulatively across chunks let mcpMetadata: { mcp_list_tools?: any[]; @@ -167,7 +163,6 @@ export async function makeOpenAIChatCompletionRequest( if (chunk.choices[0]?.delta?.content) { const content = chunk.choices[0].delta.content; updateUI(content, chunk.model); - fullResponseContent += content; } // Process image generation if present @@ -181,7 +176,6 @@ export async function makeOpenAIChatCompletionRequest( if (onReasoningContent) { onReasoningContent(reasoningContent); } - fullReasoningContent += reasoningContent; } // Check for search results in provider_specific_fields diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 00f36f016b7..b167e44fe04 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -93,26 +93,6 @@ interface UserOption { user: User; } -const getPredefinedTags = (data: any[] | null) => { - let allTags = []; - - if (data) { - for (let key of data) { - if (key["metadata"] && key["metadata"]["tags"]) { - allTags.push(...key["metadata"]["tags"]); - } - } - } - - // Deduplicate using Set - const uniqueTags = Array.from(new Set(allTags)).map((tag) => ({ - value: tag, - label: tag, - })); - - return uniqueTags; -}; - export const fetchTeamModels = async ( userID: string, userRole: string, @@ -178,7 +158,6 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const [form] = Form.useForm(); const [isModalVisible, setIsModalVisible] = useState(false); const [apiKey, setApiKey] = useState(null); - const [softBudget, setSoftBudget] = useState(null); const [userModels, setUserModels] = useState([]); const [modelsToPick, setModelsToPick] = useState([]); const [keyOwner, setKeyOwner] = useState("you"); @@ -192,11 +171,9 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const [selectedOrganizationId, setSelectedOrganizationId] = useState(null); const [selectedProjectId, setSelectedProjectId] = useState(null); const [isCreateUserModalVisible, setIsCreateUserModalVisible] = useState(false); - const [newlyCreatedUserId, setNewlyCreatedUserId] = useState(null); const [possibleUIRoles, setPossibleUIRoles] = useState>>({}); const [userOptions, setUserOptions] = useState([]); const [userSearchLoading, setUserSearchLoading] = useState(false); - const [mcpAccessGroups, setMcpAccessGroups] = useState([]); const [disabledCallbacks, setDisabledCallbacks] = useState([]); const [keyType, setKeyType] = useState("llm_api"); const [modelAliases, setModelAliases] = useState<{ [key: string]: string }>({}); @@ -578,7 +555,6 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp queryClient.invalidateQueries({ queryKey: keyKeys.lists() }); setApiKey(response["key"]); - setSoftBudget(response["soft_budget"]); NotificationsManager.success("Virtual Key Created"); form.resetFields(); setBudgetLimits([]); @@ -592,10 +568,6 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp } }; - const handleCopy = () => { - NotificationsManager.success("Virtual Key copied to clipboard"); - }; - // Fetch available models when team or auth changes. // Note: Model prefill from URL params is handled by the useEffect below, which // watches for pendingPrefillModels + modelsToPick to both be populated. @@ -657,7 +629,6 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp // Add a callback function to handle user creation const handleUserCreated = (userId: string) => { - setNewlyCreatedUserId(userId); form.setFieldsValue({ user_id: userId }); setIsCreateUserModalVisible(false); }; diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index 10af5bc1a07..a9f79810e1d 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -69,7 +69,7 @@ const OrganizationInfoView: React.FC = ({ user_id: values.user_id, role: values.role, }; - const response = await organizationMemberAddCall(accessToken, organizationId, member); + await organizationMemberAddCall(accessToken, organizationId, member); NotificationsManager.success("Organization member added successfully"); setIsAddMemberModalVisible(false); @@ -90,7 +90,7 @@ const OrganizationInfoView: React.FC = ({ role: values.role, }; - const response = await organizationMemberUpdateCall(accessToken, organizationId, member); + await organizationMemberUpdateCall(accessToken, organizationId, member); NotificationsManager.success("Organization member updated successfully"); setIsEditMemberModalVisible(false); queryClient.invalidateQueries({ queryKey: organizationKeys.all }); diff --git a/ui/litellm-dashboard/src/components/per_user_usage.tsx b/ui/litellm-dashboard/src/components/per_user_usage.tsx index 30933700cf3..e542be8df8f 100644 --- a/ui/litellm-dashboard/src/components/per_user_usage.tsx +++ b/ui/litellm-dashboard/src/components/per_user_usage.tsx @@ -55,13 +55,11 @@ const PerUserUsage: React.FC = ({ accessToken, selectedTags, total_pages: 0, }); - const [loading, setLoading] = useState(false); const [currentPage, setCurrentPage] = useState(1); const fetchPerUserData = async () => { if (!accessToken) return; - setLoading(true); try { const response = await perUserAnalyticsCall( accessToken, @@ -72,8 +70,6 @@ const PerUserUsage: React.FC = ({ accessToken, selectedTags, setPerUserData(response); } catch (error) { console.error("Failed to fetch per-user data:", error); - } finally { - setLoading(false); } }; diff --git a/ui/litellm-dashboard/src/components/price_data_reload.tsx b/ui/litellm-dashboard/src/components/price_data_reload.tsx index e0ec0fb794d..f56387d283c 100644 --- a/ui/litellm-dashboard/src/components/price_data_reload.tsx +++ b/ui/litellm-dashboard/src/components/price_data_reload.tsx @@ -60,9 +60,7 @@ const PriceDataReload: React.FC = ({ const [showScheduleModal, setShowScheduleModal] = useState(false); const [hours, setHours] = useState(6); const [reloadStatus, setReloadStatus] = useState(null); - const [loadingStatus, setLoadingStatus] = useState(false); const [sourceInfo, setSourceInfo] = useState(null); - const [loadingSource, setLoadingSource] = useState(false); // Fetch status on component mount and periodically useEffect(() => { @@ -81,7 +79,6 @@ const PriceDataReload: React.FC = ({ const fetchReloadStatus = async () => { if (!accessToken) return; - setLoadingStatus(true); try { const status = await getModelCostMapReloadStatus(accessToken); setReloadStatus(status); @@ -94,22 +91,17 @@ const PriceDataReload: React.FC = ({ last_run: null, next_run: null, }); - } finally { - setLoadingStatus(false); } }; const fetchSourceInfo = async () => { if (!accessToken) return; - setLoadingSource(true); try { const info = await getModelCostMapSource(accessToken); setSourceInfo(info); } catch (error) { console.error("Failed to fetch cost map source info:", error); - } finally { - setLoadingSource(false); } }; diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts b/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts index eff4e0cb7db..39a99f1e861 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts +++ b/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts @@ -4,6 +4,7 @@ import type * as React from "react"; import type { ColumnPinnedSide, DataTableSkeletonShape } from "./types"; declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- declaration merging requires the type parameters to match the upstream ColumnMeta signature exactly (TS2428) interface ColumnMeta { numeric?: boolean; className?: string; diff --git a/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx b/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx index e724e32d1c4..f998d7d3ecf 100644 --- a/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx @@ -1,6 +1,5 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import userEvent from "@testing-library/user-event"; import { renderWithProviders, screen, fireEvent } from "../../../tests/test-utils"; import LoggingSettings from "./LoggingSettings"; @@ -10,7 +9,6 @@ describe("LoggingSettings", () => { }); it("passes a number to updateCallbackVar when user inputs a number in NumericalInput", async () => { - const user = userEvent.setup(); const mockOnChange = vi.fn(); // Create initial config with a callback that has number parameters (LangSmith has langsmith_sampling_rate) diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index bbe5dc05a88..b7bbbba3aed 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -52,7 +52,6 @@ import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; import { ModelSelect } from "../ModelSelect/ModelSelect"; import NotificationsManager from "../molecules/notifications_manager"; -import { fetchMCPAccessGroups } from "../networking"; import ObjectPermissionsView from "../object_permissions_view"; import NumericalInput from "../shared/numerical_input"; import VectorStoreSelector from "../vector_store_management/VectorStoreSelector"; @@ -161,29 +160,6 @@ export interface TeamInfoProps { premiumUser?: boolean; } -const getOrganizationModels = (organization: Organization | null, userModels: string[]) => { - let tempModelsToPick = []; - - if (organization) { - // Check if organization has "all-proxy-models" in its models array - if (organization.models.includes("all-proxy-models")) { - // Treat as all-proxy-models (use userModels) - tempModelsToPick = userModels; - } else if (organization.models.length > 0) { - // Organization has specific models - tempModelsToPick = organization.models; - } else { - // Empty array [] is treated as all-proxy-models - tempModelsToPick = userModels; - } - } else { - // No organization, show all available models - tempModelsToPick = userModels; - } - - return unfurlWildcardModelsInList(tempModelsToPick, userModels); -}; - const TeamInfoView: React.FC = ({ teamId, onClose, @@ -203,8 +179,6 @@ const TeamInfoView: React.FC = ({ const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); const [selectedEditMember, setSelectedEditMember] = useState(null); const [isEditing, setIsEditing] = useState(false); - const [mcpAccessGroups, setMcpAccessGroups] = useState([]); - const [mcpAccessGroupsLoaded, setMcpAccessGroupsLoaded] = useState(false); const [copiedStates, setCopiedStates] = useState>({}); const { data: guardrailsData, isLoading: isGuardrailsLoading } = useGuardrails(); const globalGuardrailNames = guardrailsData?.globalGuardrailNames ?? new Set(); @@ -293,23 +267,6 @@ const TeamInfoView: React.FC = ({ fetchOrganization(); }, [accessToken, teamData?.team_info?.organization_id]); - // Compute modelsToPick based on organization and userModels - const modelsToPick = useMemo(() => { - return getOrganizationModels(organization, userModels); - }, [organization, userModels]); - - const fetchMcpAccessGroups = async () => { - if (!accessToken) return; - if (mcpAccessGroupsLoaded) return; - try { - const groups = await fetchMCPAccessGroups(accessToken); - setMcpAccessGroups(groups); - setMcpAccessGroupsLoaded(true); - } catch (error) { - console.error("Failed to fetch MCP access groups:", error); - } - }; - useEffect(() => { const fetchPolicies = async () => { try { @@ -653,7 +610,7 @@ const TeamInfoView: React.FC = ({ } } - const response = await teamUpdateCall(accessToken, updateData); + await teamUpdateCall(accessToken, updateData); queryClient.invalidateQueries({ queryKey: organizationKeys.all }); NotificationsManager.success("Team settings updated successfully"); diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index fa8ffcd7366..3b001e39a25 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -196,32 +196,28 @@ vi.mock("lucide-react", async () => { }); // Heavy children -> async factories & local React -vi.mock("../organisms/RegenerateKeyModal", async () => { - const React = await import("react"); +vi.mock("../organisms/RegenerateKeyModal", () => { function RegenerateKeyModal() { return null; } (RegenerateKeyModal as any).displayName = "RegenerateKeyModal"; return { RegenerateKeyModal }; }); -vi.mock("../object_permissions_view", async () => { - const React = await import("react"); +vi.mock("../object_permissions_view", () => { function ObjectPermissionsView() { return null; } (ObjectPermissionsView as any).displayName = "ObjectPermissionsView"; return { __esModule: true, default: ObjectPermissionsView }; }); -vi.mock("../logging_settings_view", async () => { - const React = await import("react"); +vi.mock("../logging_settings_view", () => { function LoggingSettingsView() { return null; } (LoggingSettingsView as any).displayName = "LoggingSettingsView"; return { __esModule: true, default: LoggingSettingsView }; }); -vi.mock("../common_components/AutoRotationView", async () => { - const React = await import("react"); +vi.mock("../common_components/AutoRotationView", () => { function AutoRotationView() { return null; } diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index 756481487e7..e93b28f4ea3 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -1007,7 +1007,7 @@ describe("KeyEditView", () => { }); it("should disable the organization dropdown for non-admin users", async () => { - const { container } = renderWithProviders( + renderWithProviders( {}} @@ -1029,7 +1029,7 @@ describe("KeyEditView", () => { }); it("should not disable the organization dropdown for admin users", async () => { - const { container } = renderWithProviders( + renderWithProviders( {}} diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 280abb3dac7..f4803727875 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -6,7 +6,7 @@ import { formatNumberWithCommas } from "@/utils/dataUtils"; import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; import { ArrowLeftIcon } from "@heroicons/react/outline"; import { Badge, Button, Card, Grid, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react"; -import { Form, Modal, Tag } from "antd"; +import { Modal, Tag } from "antd"; import { KeyInfoHeader } from "./KeyInfoHeader"; import { useEffect, useState } from "react"; import { isProxyAdminRole, isUserTeamAdminForSingleTeam, rolesWithWriteAccess } from "../../utils/roles"; @@ -74,10 +74,8 @@ export default function KeyInfoView({ const { data: uiSettingsData } = useUISettings(); const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); const [isEditing, setIsEditing] = useState(false); - const [form] = Form.useForm(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); - const [deleteConfirmInput, setDeleteConfirmInput] = useState(""); const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false); const [isResetSpendModalOpen, setIsResetSpendModalOpen] = useState(false); const [isBlockModalOpen, setIsBlockModalOpen] = useState(false); @@ -340,7 +338,6 @@ export default function KeyInfoView({ } finally { setDeleteLoading(false); setIsDeleteModalOpen(false); - setDeleteConfirmInput(""); } }; @@ -526,7 +523,6 @@ export default function KeyInfoView({ ]} onCancel={() => { setIsDeleteModalOpen(false); - setDeleteConfirmInput(""); }} onOk={handleDelete} confirmLoading={deleteLoading} diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index 1ed4e1d0bba..1de232fadb8 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -6,14 +6,7 @@ import React, { useEffect, useState } from "react"; import { fetchTeams } from "./common_components/fetch_teams"; import { KeyResponse, Team } from "./key_team_helpers/key_list"; import { effectiveSessionRole } from "@/utils/roles"; -import { - getProxyBaseUrl, - getProxyUISettings, - keyInfoCall, - modelAvailableCall, - Organization, - userGetInfoV2, -} from "./networking"; +import { getProxyBaseUrl, keyInfoCall, modelAvailableCall, Organization, userGetInfoV2 } from "./networking"; import CreateKey, { CreateKeyPrefillData } from "./organisms/create_key_button"; import { VirtualKeysTable } from "./VirtualKeysPage/VirtualKeysTable"; @@ -50,12 +43,6 @@ interface UserDashboardProps { prefillData?: CreateKeyPrefillData; } -type TeamInterface = { - models: any[]; - team_id: null; - team_alias: string; -}; - const UserDashboard: React.FC = ({ userID, userRole, @@ -73,15 +60,12 @@ const UserDashboard: React.FC = ({ prefillData, }) => { const [userSpendData, setUserSpendData] = useState(null); - const [currentOrg, setCurrentOrg] = useState(null); + const [currentOrg] = useState(null); const token = getCookie("token"); const [accessToken, setAccessToken] = useState(null); - const [teamSpend, setTeamSpend] = useState(null); - const [userModels, setUserModels] = useState([]); - const [proxySettings, setProxySettings] = useState(null); - const [selectedTeam, setSelectedTeam] = useState(null); + const [selectedTeam] = useState(null); // Clear session storage on page unload so next load fetches fresh data. // Note: MCP auth tokens are persistent and should not be cleared on page refresh @@ -123,14 +107,9 @@ const UserDashboard: React.FC = ({ } if (userID && accessToken && userRole && !userSpendData) { const cachedUserModels = sessionStorage.getItem("userModels" + userID); - if (cachedUserModels) { - setUserModels(JSON.parse(cachedUserModels)); - } else { + if (!cachedUserModels) { const fetchData = async () => { try { - const proxy_settings: ProxySettings = await getProxyUISettings(accessToken); - setProxySettings(proxy_settings); - const response = await userGetInfoV2(accessToken, userID); setUserSpendData(response); @@ -140,7 +119,6 @@ const UserDashboard: React.FC = ({ const model_available = await modelAvailableCall(accessToken, userID, userRole); // loop through model_info["data"] and create an array of element.model_name let available_model_names = model_available["data"].map((element: { id: string }) => element.id); - setUserModels(available_model_names); sessionStorage.setItem("userModels" + userID, JSON.stringify(available_model_names)); } catch (error: any) { @@ -162,7 +140,7 @@ const UserDashboard: React.FC = ({ if (accessToken) { const fetchKeyInfo = async () => { try { - const keyInfo = await keyInfoCall(accessToken, [accessToken]); + await keyInfoCall(accessToken, [accessToken]); } catch (error: any) { if (error.message.includes("Invalid proxy server token passed")) { gotoLogin(); @@ -179,26 +157,6 @@ const UserDashboard: React.FC = ({ } }, [currentOrg]); - useEffect(() => { - // This code will run every time selectedTeam changes - if (keys !== null && selectedTeam !== null && selectedTeam !== undefined && selectedTeam.team_id !== null) { - let sum = 0; - for (const key of keys) { - if (selectedTeam.hasOwnProperty("team_id") && key.team_id !== null && key.team_id === selectedTeam.team_id) { - sum += key.spend; - } - } - setTeamSpend(sum); - } else if (keys !== null) { - // sum the keys which don't have team-id set (default team) - let sum = 0; - for (const key of keys) { - sum += key.spend; - } - setTeamSpend(sum); - } - }, [selectedTeam]); - function gotoLogin() { // Clear token cookies using the utility function clearTokenCookies(); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SimpleToolCallBlock.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SimpleToolCallBlock.test.tsx index 107b911e386..0eb61686b73 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SimpleToolCallBlock.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SimpleToolCallBlock.test.tsx @@ -31,7 +31,7 @@ describe("SimpleToolCallBlock", () => { }); it("should not render arguments section when arguments are empty", () => { - const { container } = render(); + render(); // The tool name and "function" badge should be there, but no key: value pairs expect(screen.getByText("get_weather")).toBeInTheDocument(); expect(screen.queryByText(/:$/)).not.toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/view_logs/table.tsx b/ui/litellm-dashboard/src/components/view_logs/table.tsx index c96f34f9b93..d522478e370 100644 --- a/ui/litellm-dashboard/src/components/view_logs/table.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/table.tsx @@ -14,6 +14,7 @@ import { import { Table, TableHeader, TableHead, TableBody, TableRow, TableCell } from "@/components/ui/table"; declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- declaration merging requires the type parameters to match the upstream ColumnMeta signature exactly (TS2428) interface ColumnMeta { numeric?: boolean; } From d70e10982a46f728c6d5a431fd8692a85b3ebf23 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:21:58 -0700 Subject: [PATCH 24/38] fix(guardrails): keep tool-results-only scans off function definitions and merge scoped write-backs Gate the OpenAI handler's tools forwarding behind scan_only_tool_results, matching the Anthropic handler, so a tool-results-only scan can no longer evaluate or rewrite trusted function definitions. When a guardrail returns a replacement structured_messages list, substitute the returned messages back into the positions their scoped originals came from instead of installing the scoped list as the whole conversation, so out-of-scope messages (system prompt, prior turns) survive redaction on both the OpenAI and Anthropic paths. --- .../chat/guardrail_translation/handler.py | 32 +++++--- .../base_llm/guardrail_translation/utils.py | 58 ++++++++++--- .../chat/guardrail_translation/handler.py | 26 +++--- .../test_anthropic_guardrail_handler.py | 54 +++++++++++++ .../test_openai_guardrail_handler.py | 81 +++++++++++++++++++ 5 files changed, 216 insertions(+), 35 deletions(-) diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index 535f4b7ae61..c25fa624f7f 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -29,7 +29,8 @@ from litellm.llms.base_llm.guardrail_translation.utils import ( effective_scan_only_tool_results_for_guardrail, effective_skip_system_message_for_guardrail, effective_skip_tool_message_for_guardrail, - filtered_structured_messages, + merge_guardrailed_scoped_messages, + scoped_structured_message_indices, ) from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import ( AnthropicPassthroughLoggingHandler, @@ -330,17 +331,17 @@ class AnthropicMessagesHandler(BaseTranslation): chat_completion_compatible_request: Final = self._translate_to_openai(data) - structured_messages: Final = list( - filtered_structured_messages( - cast( - list[AllMessageValues], - chat_completion_compatible_request.get("messages", []), - ), - scan_only_tool_results=scan_only_tool_results, - skip_system=skip_system, - skip_tool=skip_tool, - ) + full_structured_messages: Final = cast( + list[AllMessageValues], + chat_completion_compatible_request.get("messages", []), ) + scoped_message_indices: Final = scoped_structured_message_indices( + full_structured_messages, + scan_only_tool_results=scan_only_tool_results, + skip_system=skip_system, + skip_tool=skip_tool, + ) + structured_messages: Final = [full_structured_messages[index] for index in scoped_message_indices] tools_to_check: Final[list[ChatCompletionToolParam]] = ( [] if scan_only_tool_results else chat_completion_compatible_request.get("tools", []) @@ -402,7 +403,14 @@ class AnthropicMessagesHandler(BaseTranslation): guardrailed_structured_messages is not None and guardrailed_structured_messages is not original_structured_messages ): - self._write_back_structured_messages(data, guardrailed_structured_messages) + self._write_back_structured_messages( + data, + merge_guardrailed_scoped_messages( + full_messages=full_structured_messages, + scoped_indices=scoped_message_indices, + guardrailed_scoped=guardrailed_structured_messages, + ), + ) else: # Step 3: Map guardrail responses back to original message structure await self._apply_guardrail_responses_to_input( diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index e365913f2e1..fcd504fee2f 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from collections.abc import Sequence +from collections.abc import Iterator, Sequence from typing import Any, Final from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicUsage @@ -130,12 +130,6 @@ def openai_messages_without_tool( return tuple(m for m in messages if _message_role(m) != "tool") -def openai_messages_only_tool( - messages: Sequence[AllMessageValues], -) -> tuple[AllMessageValues, ...]: - return tuple(m for m in messages if _message_role(m) == "tool") - - def effective_scan_only_tool_results_for_guardrail(guardrail_to_apply: Any) -> bool: return getattr(guardrail_to_apply, "scan_only_tool_results", None) is True @@ -154,13 +148,53 @@ def role_out_of_guardrail_scope( return scan_only_tool_results and role != "tool" -def filtered_structured_messages( +def scoped_structured_message_indices( messages: Sequence[AllMessageValues], *, scan_only_tool_results: bool, skip_system: bool, skip_tool: bool, -) -> tuple[AllMessageValues, ...]: - scoped: Final = openai_messages_only_tool(messages) if scan_only_tool_results else tuple(messages) - without_system: Final = openai_messages_without_system(scoped) if skip_system else scoped - return openai_messages_without_tool(without_system) if skip_tool else without_system +) -> tuple[int, ...]: + return tuple( + index + for index, message in enumerate(messages) + if not role_out_of_guardrail_scope( + _message_role(message), + skip_system_message=skip_system, + skip_tool_message=skip_tool, + scan_only_tool_results=scan_only_tool_results, + ) + ) + + +def merge_guardrailed_scoped_messages( + full_messages: Sequence[AllMessageValues], + scoped_indices: Sequence[int], + guardrailed_scoped: Sequence[AllMessageValues], +) -> list[AllMessageValues]: + """Substitute guardrail-returned messages back into the full conversation. + + Guardrails only ever see the scoped subset of messages, so a replacement + list they hand back describes that subset, not the whole request. Writing + it over ``data["messages"]`` wholesale would silently drop every + out-of-scope message (system prompt, prior turns). Instead, swap each + returned message into the position its scoped original came from; extra + returned messages land after the last scoped position, and scoped + originals without a counterpart are treated as removed by the guardrail. + When nothing was filtered out this degenerates to the returned list + itself, preserving wholesale-replacement behavior for unscoped guardrails. + """ + replacements: Final = dict(zip(scoped_indices, guardrailed_scoped)) + removed: Final = frozenset(scoped_indices[len(guardrailed_scoped) :]) + appended: Final = tuple(guardrailed_scoped[len(scoped_indices) :]) + last_scoped_index: Final = scoped_indices[-1] if scoped_indices else None + + def _merged() -> Iterator[AllMessageValues]: + for index, message in enumerate(full_messages): + if index in removed: + continue + yield replacements.get(index, message) + if index == last_scoped_index: + yield from appended + + return list(_merged()) diff --git a/litellm/llms/openai/chat/guardrail_translation/handler.py b/litellm/llms/openai/chat/guardrail_translation/handler.py index 67550890d2d..9d7fe6ce2a8 100644 --- a/litellm/llms/openai/chat/guardrail_translation/handler.py +++ b/litellm/llms/openai/chat/guardrail_translation/handler.py @@ -26,8 +26,9 @@ from litellm.llms.base_llm.guardrail_translation.utils import ( effective_scan_only_tool_results_for_guardrail, effective_skip_system_message_for_guardrail, effective_skip_tool_message_for_guardrail, - filtered_structured_messages, + merge_guardrailed_scoped_messages, role_out_of_guardrail_scope, + scoped_structured_message_indices, ) from litellm.main import stream_chunk_builder from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam @@ -114,18 +115,17 @@ class OpenAIChatCompletionsHandler(BaseTranslation): if tool_calls_to_check: inputs["tool_calls"] = tool_calls_to_check structured_messages: Final = self.get_structured_messages(data) + scoped_message_indices: Final = scoped_structured_message_indices( + structured_messages or [], + scan_only_tool_results=scan_only_tool_results, + skip_system=skip_system, + skip_tool=skip_tool, + ) if structured_messages: - inputs["structured_messages"] = list( - filtered_structured_messages( - structured_messages, - scan_only_tool_results=scan_only_tool_results, - skip_system=skip_system, - skip_tool=skip_tool, - ) - ) + inputs["structured_messages"] = [structured_messages[index] for index in scoped_message_indices] # Pass tools (function definitions) to the guardrail tools: Final = data.get("tools") - if tools: + if tools and not scan_only_tool_results: inputs["tools"] = tools # Include model information if available model: Final = data.get("model") @@ -151,7 +151,11 @@ class OpenAIChatCompletionsHandler(BaseTranslation): guardrailed_structured_messages is not None and guardrailed_structured_messages is not original_structured_messages ): - data["messages"] = guardrailed_structured_messages + data["messages"] = merge_guardrailed_scoped_messages( + full_messages=structured_messages or [], + scoped_indices=scoped_message_indices, + guardrailed_scoped=guardrailed_structured_messages, + ) else: # Step 3: Map guardrail responses back to original message structure if guardrailed_texts and texts_to_check: diff --git a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py index e90ae579d6d..a016e1a2deb 100644 --- a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py @@ -5,6 +5,7 @@ Tests the handler's ability to process streaming output for Anthropic Messages A with guardrail transformations, specifically testing edge cases with empty choices. """ +import json import os import sys from typing import Any, Literal, Optional @@ -778,12 +779,65 @@ class InputsRecordingGuardrail(MockMaskingGuardrail): return await super().apply_guardrail(inputs, request_data, input_type, logging_obj) +class StructuredMessagesRewritingGuardrail(CustomGuardrail): + """Returns a new structured_messages list with a canary redacted, like redaction guardrails do.""" + + def __init__(self): + super().__init__(guardrail_name="structured-rewrite") + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[Any] = None, + ) -> GenericGuardrailAPIInputs: + structured = inputs.get("structured_messages") or [] + inputs["structured_messages"] = [ + json.loads(json.dumps(message).replace("POISON", "[BLOCKED]")) for message in structured + ] + return inputs + + class TestAnthropicMessagesScanOnlyToolResults: def _guardrail(self): guardrail = InputsRecordingGuardrail() guardrail.scan_only_tool_results = True return guardrail + @pytest.mark.asyncio + async def test_structured_write_back_merges_into_the_full_conversation(self): + handler = AnthropicMessagesHandler() + guardrail = StructuredMessagesRewritingGuardrail() + guardrail.scan_only_tool_results = True + data = { + "model": "claude-sonnet-4-5", + "system": "You are a careful agent harness.", + "messages": [ + {"role": "user", "content": "fetch the page"}, + { + "role": "assistant", + "content": [{"type": "tool_use", "id": "tu1", "name": "Bash", "input": {"cmd": "curl"}}], + }, + { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "tu1", "content": "fetched POISON page"}], + }, + ], + } + + await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) + + assert data["system"] == "You are a careful agent harness." + assert [m["role"] for m in data["messages"]] == ["user", "assistant", "user"], ( + "a redacting guardrail must not strip out-of-scope turns from the request" + ) + serialized = json.dumps(data["messages"]) + assert "fetch the page" in serialized + assert "tool_use" in serialized + assert "fetched [BLOCKED] page" in serialized + assert "POISON" not in serialized + @pytest.mark.asyncio async def test_scan_narrows_to_tool_results_and_write_back_stays_aligned(self): handler = AnthropicMessagesHandler() diff --git a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py index c8a1b98aa82..907da66e5bf 100644 --- a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py @@ -1231,6 +1231,28 @@ class TestIncrementalScanRespectsSkipFlags: assert scanned == ["It is sunny in Paris.", "And tomorrow?"] +class StructuredRedactionGuardrail(CustomGuardrail): + """Captures inputs and returns a new structured_messages list with a canary redacted.""" + + def __init__(self): + super().__init__(guardrail_name="structured-redaction") + self.captured_inputs: Optional[GenericGuardrailAPIInputs] = None + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[Any] = None, + ) -> GenericGuardrailAPIInputs: + self.captured_inputs = inputs + structured = inputs.get("structured_messages") or [] + inputs["structured_messages"] = [ + {**m, "content": str(m.get("content", "")).replace("POISON", "[BLOCKED]")} for m in structured + ] + return inputs + + class TestScanOnlyToolResults: def _bedrock_guardrail(self): from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import BedrockGuardrail @@ -1297,3 +1319,62 @@ class TestScanOnlyToolResults: assert scanned == ["USER-PROMPT", "TOOL-RESULT"], ( "anything but an explicit True must leave the whole request in scope" ) + + @pytest.mark.parametrize("scan_only_tool_results", [True, False]) + @pytest.mark.asyncio + async def test_function_definitions_are_scoped_out_with_the_tool_results_flag(self, scan_only_tool_results): + handler = OpenAIChatCompletionsHandler() + guardrail = StructuredRedactionGuardrail() + guardrail.scan_only_tool_results = scan_only_tool_results + tools = [ + { + "type": "function", + "function": {"name": "read_file", "parameters": {"type": "object", "properties": {}}}, + } + ] + data = { + "messages": [ + {"role": "user", "content": "read the report"}, + {"role": "tool", "tool_call_id": "call_1", "content": "TOOL-RESULT"}, + ], + "tools": tools, + } + + await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) + + assert guardrail.captured_inputs is not None + expected_tools = None if scan_only_tool_results else tools + assert guardrail.captured_inputs.get("tools") == expected_tools, ( + "function definitions must stay out of a tool-results-only scan" + ) + + @pytest.mark.asyncio + async def test_structured_write_back_keeps_out_of_scope_messages(self): + handler = OpenAIChatCompletionsHandler() + guardrail = StructuredRedactionGuardrail() + guardrail.scan_only_tool_results = True + data = { + "messages": [ + {"role": "system", "content": "SYSTEM-PROMPT"}, + {"role": "user", "content": "fetch the page"}, + { + "role": "assistant", + "content": "fetching", + "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "fetch", "arguments": "{}"}} + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "page says POISON here"}, + {"role": "user", "content": "and then?"}, + ] + } + + await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) + + assert [m["role"] for m in data["messages"]] == ["system", "user", "assistant", "tool", "user"], ( + "a redacting guardrail must not strip out-of-scope messages from the request" + ) + assert data["messages"][0]["content"] == "SYSTEM-PROMPT" + assert data["messages"][3]["content"] == "page says [BLOCKED] here" + assert data["messages"][3]["tool_call_id"] == "call_1" + assert data["messages"][4]["content"] == "and then?" From 00cbebf5031f07202aee3300db3ccc08fb452fb0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:33:20 -0700 Subject: [PATCH 25/38] fix(managed_files): source the unified input file id from the response so retrieve-time mints converge with the cost job --- .../proxy/hooks/managed_files.py | 2 +- .../openai_files_endpoints/common_utils.py | 7 +- .../proxy/test_managed_files_hook.py | 80 ++++++++++++++----- 3 files changed, 68 insertions(+), 21 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index 404dba6b919..61ed249c24f 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -1106,7 +1106,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): resolved_model_name = resolve_managed_output_file_model_name( unified_input_file_id=unified_file_id if isinstance(unified_file_id, str) - else None, + else response.input_file_id, fallback_model_name=model_name, ) original_response_id = response.id diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 0dd2c65bba6..89c946cdea6 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -959,8 +959,13 @@ async def ensure_batch_response_managed_file_ids( return unified_file_id: Final = hidden_params.get("unified_file_id") + input_file_id: Final = getattr(response, "input_file_id", None) model_name: Final = resolve_managed_output_file_model_name( - unified_input_file_id=unified_file_id if isinstance(unified_file_id, str) else None, + unified_input_file_id=unified_file_id + if isinstance(unified_file_id, str) + else input_file_id + if isinstance(input_file_id, str) + else None, fallback_model_name=hidden_params.get("model_name"), ) diff --git a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py index 26df6463351..b44314f56e7 100644 --- a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py +++ b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py @@ -561,18 +561,65 @@ async def test_hook_mint_prefers_input_file_target_model_names(): ) -def test_cost_job_and_retrieve_paths_mint_identical_unified_output_file_ids(): +@pytest.mark.asyncio +async def test_hook_mint_falls_back_to_response_input_file_id_target_models(): + managed_files = _make_managed_files_instance() + batch_response = _make_batch_response() + batch_response.input_file_id = _b64_unified_input_file_id("model-a,model-b") + batch_response._hidden_params = { + "unified_batch_id": "some-unified-batch-id", + "model_id": "model-deploy-xyz", + } + + mock_router = MagicMock() + mock_router.get_deployment_credentials_with_provider = MagicMock(return_value={}) + + with ( + patch("litellm.afile_retrieve", AsyncMock(return_value=_make_file_object())), + patch("litellm.proxy.proxy_server.llm_router", mock_router), + ): + await managed_files.async_post_call_success_hook( + data={}, + user_api_key_dict=_make_user_api_key_dict(), + response=batch_response, + ) + + assert batch_response.output_file_id == managed_files.get_unified_output_file_id( + output_file_id="file-output-abc", + model_id="model-deploy-xyz", + model_name="model-a,model-b", + ) + + +@pytest.mark.asyncio +async def test_cost_job_and_retrieve_paths_mint_identical_unified_output_file_ids(): from litellm.proxy.openai_files_endpoints.common_utils import ( - resolve_managed_output_file_model_name, + ensure_batch_response_managed_file_ids, ) from litellm_enterprise.proxy.common_utils.check_batch_cost import CheckBatchCost - managed_files = _make_managed_files_instance() - unified_input_file_id = _b64_unified_input_file_id("model-a,model-b") + managed_files, mock_prisma = _make_real_managed_files_instance() + mock_prisma.db.litellm_managedfiletable.find_first = AsyncMock(return_value=None) + unified_input_file_id = _b64_unified_input_file_id("model-a") - retrieve_path_model_name = resolve_managed_output_file_model_name( - unified_input_file_id=unified_input_file_id, - fallback_model_name="model-a", + retrieve_response = LiteLLMBatch( + id="batch-123", + completion_window="24h", + created_at=1700000000, + endpoint="/v1/chat/completions", + input_file_id=unified_input_file_id, + object="batch", + status="completed", + output_file_id="file-output-abc", + ) + retrieve_response._hidden_params = {"model_id": "model-deploy-xyz"} + + await ensure_batch_response_managed_file_ids( + response=retrieve_response, + managed_files_obj=managed_files, + prisma_client=mock_prisma, + verbose_proxy_logger=MagicMock(), + user_api_key_dict=_make_user_api_key_dict(), ) job = MagicMock() @@ -586,17 +633,12 @@ def test_cost_job_and_retrieve_paths_mint_identical_unified_output_file_ids(): "status": "completed", } cost_job_model_name = CheckBatchCost._get_managed_file_model_name( - job=job, deployment_info=MagicMock(model_name="model-a") + job=job, deployment_info=MagicMock(model_name="vertex_ai/gemini-3-pro") ) - assert retrieve_path_model_name == cost_job_model_name == "model-a,model-b" - - minted_ids = { - managed_files.get_unified_output_file_id( - output_file_id="file-output-abc", - model_id="model-deploy-xyz", - model_name=model_name, - ) - for model_name in (retrieve_path_model_name, cost_job_model_name) - } - assert len(minted_ids) == 1 + assert cost_job_model_name == "model-a" + assert retrieve_response.output_file_id == managed_files.get_unified_output_file_id( + output_file_id="file-output-abc", + model_id="model-deploy-xyz", + model_name=cost_job_model_name, + ) From 28a277e99e281052445e3568bcd1449a471447ba Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:56:50 -0700 Subject: [PATCH 26/38] refactor(guardrails): drop dead tool extraction and an Any annotation, ratchet lint budgets --- basedpyright-code-budget.json | 10 +++++----- .../chat/guardrail_translation/handler.py | 16 ---------------- .../llms/base_llm/guardrail_translation/utils.py | 2 +- ruff-strict-budget.json | 2 +- type-discipline-budget.json | 6 +++--- 5 files changed, 10 insertions(+), 26 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 27d96e415fd..8a5c78c1f6c 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -3,7 +3,7 @@ "limit": 29204 }, "reportArgumentType": { - "limit": 2635 + "limit": 2634 }, "reportAssignmentType": { "limit": 329 @@ -24,7 +24,7 @@ "limit": 19 }, "reportExplicitAny": { - "limit": 9227 + "limit": 9226 }, "reportFunctionMemberAccess": { "limit": 7 @@ -105,7 +105,7 @@ "limit": 113 }, "reportUnknownMemberType": { - "limit": 40340 + "limit": 40339 }, "reportUnknownParameterType": { "limit": 20293 @@ -117,13 +117,13 @@ "limit": 122 }, "reportUnnecessaryComparison": { - "limit": 703 + "limit": 702 }, "reportUnnecessaryContains": { "limit": 5 }, "reportUnnecessaryIsInstance": { - "limit": 865 + "limit": 864 }, "reportUntypedBaseClass": { "limit": 72 diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index c25fa624f7f..60424fb78b5 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -574,22 +574,6 @@ class AnthropicMessagesHandler(BaseTranslation): data: Final = source.get("data") return (data,) if data else () - def _extract_input_tools( - self, - tools: list[dict[str, Any]], - tools_to_check: list[ChatCompletionToolParam], - ) -> None: - """ - Extract tools from a message. - """ - ## CHECK FOR TOOLS - if tools is not None and isinstance(tools, list): - # TRANSFORM ANTHROPIC TOOLS TO OPENAI TOOLS - openai_tools: Final = self.adapter.translate_anthropic_tools_to_openai( - tools=cast(list[AllAnthropicToolsValues], tools) - ) - tools_to_check.extend(openai_tools) - async def _apply_guardrail_responses_to_input( self, messages: list[dict[str, Any]], diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index fcd504fee2f..432ac64b456 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -130,7 +130,7 @@ def openai_messages_without_tool( return tuple(m for m in messages if _message_role(m) != "tool") -def effective_scan_only_tool_results_for_guardrail(guardrail_to_apply: Any) -> bool: +def effective_scan_only_tool_results_for_guardrail(guardrail_to_apply: object) -> bool: return getattr(guardrail_to_apply, "scan_only_tool_results", None) is True diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index 421b424757b..ea20ac97e07 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -42,7 +42,7 @@ "limit": 81 }, "B010": { - "limit": 194 + "limit": 192 }, "B018": { "limit": 2 diff --git a/type-discipline-budget.json b/type-discipline-budget.json index e26ce54ede7..37964c27657 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,6 +1,6 @@ { "LIT001": { - "limit": 23343 + "limit": 23337 }, "LIT002": { "limit": 27213 @@ -15,7 +15,7 @@ "limit": 0 }, "LIT006": { - "limit": 1093 + "limit": 1092 }, "LIT007": { "limit": 0 @@ -27,7 +27,7 @@ "limit": 0 }, "LIT010": { - "limit": 16802 + "limit": 16796 }, "LIT011": { "limit": 5602 From ece652f6a7328562da075984c70bd66d5e36c855 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Wed, 5 Aug 2026 19:15:35 -0700 Subject: [PATCH 27/38] feat(ui): add the auto-router usage tab to cost optimization (#35995) --- .../AutoRouterBenchmarksTab.test.tsx | 243 +++++++++++++ .../_components/AutoRouterBenchmarksTab.tsx | 323 ++++++++++++++++++ .../_components/CostOptimizationView.test.tsx | 10 +- .../_components/CostOptimizationView.tsx | 8 +- .../_components/autoRouterBenchmarks.test.ts | 169 +++++++++ .../_components/autoRouterBenchmarks.ts | 105 ++++++ .../_components/useAutoRouterBenchmarks.ts | 11 + 7 files changed, 865 insertions(+), 4 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useAutoRouterBenchmarks.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx new file mode 100644 index 00000000000..51e9e125cb9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx @@ -0,0 +1,243 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { ApiError } from "@/lib/http/client"; + +vi.mock("./useAutoRouterBenchmarks", () => ({ useAutoRouterBenchmarks: vi.fn() })); + +import AutoRouterBenchmarksTab from "./AutoRouterBenchmarksTab"; +import type { + AutoRouterBenchmarkGroup, + AutoRouterBenchmarksResponse, + AutoRouterCacheStats, +} from "./autoRouterBenchmarks"; +import { useAutoRouterBenchmarks } from "./useAutoRouterBenchmarks"; + +type HookResult = ReturnType; + +const mockHook = (result: { data?: AutoRouterBenchmarksResponse; isPending?: boolean; error?: Error }) => { + vi.mocked(useAutoRouterBenchmarks).mockReturnValue({ + data: result.data, + isPending: result.isPending ?? false, + error: result.error ?? null, + } as unknown as HookResult); +}; + +const cache = (overrides: Partial = {}): AutoRouterCacheStats => ({ + coverage_pct: 99.6, + hit_rate_pct: 93.3, + same_model: { turns: 400, hits: 391, hit_rate_pct: 97.7 }, + first_visit: { turns: 37, hits: 9, hit_rate_pct: 24.3 }, + return_to_tier: { turns: 381, hits: 311, hit_rate_pct: 81.6 }, + unordered_turns: 0, + return_misses_expired: 19, + return_misses_within_ttl: 51, + return_misses_unknown: 0, + ttl_5m_turns: 0, + ttl_1h_turns: 818, + ...overrides, +}); + +type Totals = AutoRouterBenchmarksResponse["totals"]; + +const totals = (overrides: Partial = {}): Totals => ({ + sessions: 94, + turns: 3073, + avg_turns_per_session: 32.7, + avg_session_seconds: 7560, + avg_tokens_per_session: 5_300_000, + spend: 359.86, + saved_spend: 2174.59, + baseline_spend: 2534.45, + saved_pct: 85.8, + saved_per_session: 23.13, + cache: cache(), + ...overrides, +}); + +const group = (overrides: Partial = {}): AutoRouterBenchmarkGroup => ({ + router_name: "claude-auto", + router_type: "complexity", + ...totals(), + ...overrides, +}); + +const response = (groups: AutoRouterBenchmarkGroup[], shared: Totals = totals()): AutoRouterBenchmarksResponse => ({ + start_date: "2026-07-06", + end_date: "2026-08-05", + routers_in_scope: groups.length, + totals: shared, + groups, +}); + +const renderTab = () => render(); + +describe("AutoRouterBenchmarksTab", () => { + it("leads with total estimated savings, before the three session-shape metrics", () => { + mockHook({ data: response([group(), group({ router_name: "gpt-auto" })]) }); + renderTab(); + + const labels = screen + .getAllByText(/Total estimated savings|Avg turns per session|Avg session length|Avg tokens per session/) + .map((node) => node.textContent); + expect(labels).toEqual([ + "Total estimated savings", + "Avg turns per session", + "Avg session length", + "Avg tokens per session", + ]); + }); + + it("renders the headline numbers the tiles exist for", () => { + mockHook({ data: response([group(), group({ router_name: "gpt-auto" })]) }); + renderTab(); + + expect(screen.getByText("$2,174.59")).toBeInTheDocument(); + expect(screen.getByText("-86%")).toBeInTheDocument(); + expect(screen.getByText("Actual auto-router spend")).toBeInTheDocument(); + expect(screen.getByText("$359.86")).toBeInTheDocument(); + expect(screen.getByText("Estimated spend at highest-cost model")).toBeInTheDocument(); + expect(screen.getByText("$2,534.45")).toBeInTheDocument(); + expect(screen.getByText("32.7")).toBeInTheDocument(); + expect(screen.getByText("2.1h")).toBeInTheDocument(); + expect(screen.getByText("5.3M")).toBeInTheDocument(); + }); + + it("pairs the savings with the session count it was earned over", () => { + mockHook({ data: response([group(), group({ router_name: "gpt-auto" })]) }); + renderTab(); + + expect(screen.getByText("Total sessions")).toBeInTheDocument(); + expect(screen.getByText("94")).toBeInTheDocument(); + expect(screen.getByText("Total turns")).toBeInTheDocument(); + expect(screen.getByText("3,073")).toBeInTheDocument(); + expect(screen.getByText("Avg saved per session")).toBeInTheDocument(); + expect(screen.getByText("$23.13")).toBeInTheDocument(); + }); + + it("shows a cost increase as a positive delta rather than a saving", () => { + const overBaseline = { spend: 120, baseline_spend: 100, saved_spend: -20, saved_pct: -20 }; + const dearer = totals(overBaseline); + mockHook({ data: response([group(dearer)], dearer) }); + renderTab(); + + expect(screen.getByText("+20%")).toBeInTheDocument(); + }); + + it("renders all three cache buckets with their turn counts and hit rates", () => { + mockHook({ data: response([group()]) }); + renderTab(); + + expect(screen.getByText("Same model")).toBeInTheDocument(); + expect(screen.getByText("previous turn → same tier")).toBeInTheDocument(); + expect(screen.getByText("First visit")).toBeInTheDocument(); + expect(screen.getByText("previous turn → a tier not used yet")).toBeInTheDocument(); + expect(screen.getByText("Return to tier")).toBeInTheDocument(); + expect(screen.getByText("previous turn → a tier used earlier")).toBeInTheDocument(); + expect(screen.getByText("400")).toBeInTheDocument(); + expect(screen.getByText("37")).toBeInTheDocument(); + expect(screen.getByText("381")).toBeInTheDocument(); + expect(screen.getByText("49%")).toBeInTheDocument(); + expect(screen.getByText("5%")).toBeInTheDocument(); + expect(screen.getByText("47%")).toBeInTheDocument(); + expect(screen.getByText("97.7%")).toBeInTheDocument(); + expect(screen.getByText("24.3%")).toBeInTheDocument(); + expect(screen.getByText("81.6%")).toBeInTheDocument(); + }); + + it("summarizes the cache column from the bucketed turns, not the session turns", () => { + mockHook({ data: response([group()]) }); + renderTab(); + + expect(screen.getByText("93.3%")).toBeInTheDocument(); + expect(screen.getByText("818")).toBeInTheDocument(); + expect(screen.getByText(/turns measured/)).toBeInTheDocument(); + }); + + it("recomputes the expired-miss share from the miss counts", () => { + mockHook({ data: response([group()]) }); + renderTab(); + + expect(screen.getByText("Expired-miss")).toBeInTheDocument(); + expect(screen.getByText("27.1%")).toBeInTheDocument(); + }); + + it("hides the expired-miss row when every return turn hit", () => { + const allHits = totals({ + cache: cache({ return_to_tier: { turns: 381, hits: 381, hit_rate_pct: 100 }, return_misses_expired: 0 }), + }); + mockHook({ data: response([group(allHits)], allHits) }); + renderTab(); + + expect(screen.queryByText("Expired-miss")).not.toBeInTheDocument(); + }); + + it("mentions out-of-order turns only when there are any", () => { + const unordered = totals({ cache: cache({ unordered_turns: 12 }) }); + mockHook({ data: response([group(unordered)], unordered) }); + renderTab(); + + expect(screen.getByText(/12 turns arrived out of order across pods and are not bucketed/)).toBeInTheDocument(); + }); + + it("labels the default selection instead of leaking the __all__ sentinel", () => { + mockHook({ data: response([group()]) }); + renderTab(); + + expect(screen.getByText("All auto-routers")).toBeInTheDocument(); + expect(screen.queryByText("__all__")).not.toBeInTheDocument(); + }); + + it("says so while the benchmarks are loading", () => { + mockHook({ isPending: true }); + renderTab(); + + expect(screen.getByText("Loading auto-router usage...")).toBeInTheDocument(); + }); + + it("names the admin requirement when the proxy answers 403", () => { + mockHook({ error: new ApiError("forbidden", 403, {}) }); + renderTab(); + + expect(screen.getByText("Auto-router usage is visible to proxy admin roles only")).toBeInTheDocument(); + }); + + it("degrades to a message when the endpoint is unavailable", () => { + mockHook({ error: new ApiError("boom", 500, {}) }); + renderTab(); + + expect(screen.getByText("Auto-router usage is unavailable right now")).toBeInTheDocument(); + }); + + it("says so when there are no auto-router sessions at all", () => { + mockHook({ data: response([]) }); + renderTab(); + + expect(screen.getByText("No auto-router sessions in this window yet")).toBeInTheDocument(); + }); + + it("requests the default thirty day window and widens or narrows it from the picker", () => { + mockHook({ data: response([group()]) }); + renderTab(); + + expect(vi.mocked(useAutoRouterBenchmarks)).toHaveBeenCalledWith("sk-test", "30d"); + expect(screen.getByText("Last 30 days")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "7d" })); + expect(vi.mocked(useAutoRouterBenchmarks)).toHaveBeenCalledWith("sk-test", "7d"); + expect(screen.getByText("Last 7 days")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "24h" })); + expect(vi.mocked(useAutoRouterBenchmarks)).toHaveBeenCalledWith("sk-test", "24h"); + expect(screen.getByText("Last 24 hours")).toBeInTheDocument(); + }); + + it("keeps the window picker reachable while a window has no sessions", () => { + mockHook({ data: response([]) }); + renderTab(); + + expect(screen.getByRole("tab", { name: "30d" })).toBeInTheDocument(); + expect(screen.getByText("All auto-routers")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx new file mode 100644 index 00000000000..2b71c36c597 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx @@ -0,0 +1,323 @@ +"use client"; + +import React, { useState } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { ApiError } from "@/lib/http/client"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; + +import { + ALL_ROUTERS, + WINDOW_LABELS, + bucketRows, + bucketTurnsTotal, + durationLabel, + groupKey, + expiredMissShare, + groupLabel, + pctLabel, + viewFor, + type AutoRouterBenchmarksResponse, + type AutoRouterCacheStats, + type BenchmarkView, + type BenchmarkWindow, + type BucketRow, +} from "./autoRouterBenchmarks"; +import { usd } from "./costOptimizationUtils"; +import { useAutoRouterBenchmarks } from "./useAutoRouterBenchmarks"; + +const Message: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +

{children}

+); + +const Metric: React.FC<{ label: string; value: string }> = ({ label, value }) => ( + + + {label} + + +

{value}

+
+
+); + +const HeroCard: React.FC<{ view: BenchmarkView }> = ({ view }) => { + const stats = view.stats; + const cheaper = stats.saved_spend >= 0; + return ( + +
+
+

Total estimated savings

+
+

{usd(stats.saved_spend)}

+ + {cheaper ? "-" : "+"} + {Math.abs(stats.saved_pct).toFixed(0)}% + +
+
+ +
+
+
+
Actual auto-router spend
+
{usd(stats.spend)}
+
+
+
Estimated spend at highest-cost model
+
{usd(stats.baseline_spend)}
+
+
+
+ +
+
+
+

Total sessions

+

{stats.sessions.toLocaleString()}

+
+
+

Total turns

+

{stats.turns.toLocaleString()}

+
+
+
+
+
Avg saved per session
+
{usd(stats.saved_per_session)}
+
+
+
+
+
+ ); +}; + +const StackedTurnBar: React.FC<{ buckets: BucketRow[] }> = ({ buckets }) => { + const segments = buckets.filter((b) => b.turns > 0); + return ( +
+
+ {segments.map((b) => ( +
+ ))} +
+
+ {segments.map((b) => ( + + {b.sharePct}% + + ))} +
+
+ ); +}; + +const BucketTable: React.FC<{ buckets: BucketRow[] }> = ({ buckets }) => ( + + + + Bucket + Turns + + Hit rate + + + + {buckets.map((b) => ( + + + + + + {b.label} + {b.sublabel} + + + + + {b.turns.toLocaleString()} + + +
+
+
+ + + {pctLabel(b.hitRatePct)} + + + ))} + +
+); + +const CachingCard: React.FC<{ cache: AutoRouterCacheStats }> = ({ cache }) => { + const buckets = bucketRows(cache); + const total = bucketTurnsTotal(cache); + const expiredMissPct = expiredMissShare(cache); + return ( + +
+
+
+

Cache hit rate

+

{pctLabel(cache.hit_rate_pct)}

+
+ {expiredMissPct === null ? null : ( +
+ + + + Expired-miss +

+ } + /> + + percentage of return-to-tier cache misses caused by cache expiring + +
+
+

{pctLabel(expiredMissPct)}

+
+ )} +
+ +
+
+

Share of turns

+

+ {total.toLocaleString()} turns + measured +

+
+ + + {cache.unordered_turns > 0 && ( +

+ {cache.unordered_turns.toLocaleString()} turns arrived out of order across pods and are not bucketed +

+ )} +
+
+
+ ); +}; + +interface BenchmarksBodyProps { + isPending: boolean; + error: unknown; + data: AutoRouterBenchmarksResponse | undefined; + selectedKey: string; +} + +const BenchmarksBody: React.FC = ({ isPending, error, data, selectedKey }) => { + if (isPending) return Loading auto-router usage...; + if (error instanceof ApiError && error.status === 403) { + return Auto-router usage is visible to proxy admin roles only; + } + if (error || !data) return Auto-router usage is unavailable right now; + if (data.groups.length === 0) return No auto-router sessions in this window yet; + + const view = viewFor(data, selectedKey); + const stats = view.stats; + return ( + <> + + +
+ + + +
+ +

+ Compares your actual routed spend with the estimated cost of using only the most expensive model configured in + the auto-router. It accounts for both the cache savings from staying on one model and the added cache costs from + switching models. +

+ +
+
+

Auto-router prompt caching

+

+ every turn falls in exactly one bucket, by what the router did +

+
+ +
+ + ); +}; + +interface AutoRouterBenchmarksTabProps { + accessToken: string | null; +} + +const AutoRouterBenchmarksTab: React.FC = ({ accessToken }) => { + const [range, setRange] = useState("30d"); + const { data, isPending, error } = useAutoRouterBenchmarks(accessToken, range); + const [selectedKey, setSelectedKey] = useState(ALL_ROUTERS); + + const groups = data?.groups ?? []; + const selectedLabel = data ? viewFor(data, selectedKey).label : "All auto-routers"; + + return ( +
+
+
+

Auto-router usage

+

{WINDOW_LABELS[range]}

+
+
+ setRange(value === "7d" || value === "24h" ? value : "30d")}> + + 30d + 7d + 24h + + +
+ +
+
+
+ + +
+ ); +}; + +export default AutoRouterBenchmarksTab; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx index 42dc7719144..96ef75e8dd1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx @@ -4,19 +4,23 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("./UsageTab", () => ({ __esModule: true, default: () =>
})); vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () =>
})); vi.mock("./PromptCachingTab", () => ({ __esModule: true, default: () =>
})); +vi.mock("./AutoRouterBenchmarksTab", () => ({ + __esModule: true, + default: () =>
, +})); import CostOptimizationView from "./CostOptimizationView"; const renderView = () => render(); describe("CostOptimizationView", () => { - it("renders the three cost-optimization tabs and no autorouter tab", () => { - const { getByText, queryByText } = renderView(); + it("renders the four cost-optimization tabs", () => { + const { getByText } = renderView(); expect(getByText("Usage")).toBeInTheDocument(); expect(getByText("Prompt Compression")).toBeInTheDocument(); expect(getByText("Prompt Caching")).toBeInTheDocument(); - expect(queryByText("Autorouter")).not.toBeInTheDocument(); + expect(getByText("Auto-Router Usage")).toBeInTheDocument(); }); it("defaults to the Usage tab and switches the active tab on click", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx index f6593e80999..0d986fdcaa8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx @@ -7,6 +7,7 @@ import { Alert, Tabs } from "antd"; import UsageTab from "./UsageTab"; import PromptCompressionTab from "./PromptCompressionTab"; import PromptCachingTab from "./PromptCachingTab"; +import AutoRouterBenchmarksTab from "./AutoRouterBenchmarksTab"; import { useDailyActivityRange } from "./useDailyActivityRange"; interface CostOptimizationViewProps { @@ -34,6 +35,11 @@ const CostOptimizationView: React.FC = ({ accessToken label: "Prompt Caching", children: , }, + { + key: "autorouter-usage", + label: "Auto-Router Usage", + children: , + }, ]; return ( @@ -57,7 +63,7 @@ const CostOptimizationView: React.FC = ({ accessToken Have feedback? Join the discussion{" "} = {}): AutoRouterCacheStats => ({ + coverage_pct: 99.6, + hit_rate_pct: 93.3, + same_model: { turns: 400, hits: 391, hit_rate_pct: 97.7 }, + first_visit: { turns: 37, hits: 9, hit_rate_pct: 24.3 }, + return_to_tier: { turns: 381, hits: 311, hit_rate_pct: 81.6 }, + unordered_turns: 0, + return_misses_expired: 19, + return_misses_within_ttl: 51, + return_misses_unknown: 0, + ttl_5m_turns: 0, + ttl_1h_turns: 818, + ...overrides, +}); + +const totals = (overrides: Partial = {}) => ({ + sessions: 94, + turns: 3073, + avg_turns_per_session: 32.7, + avg_session_seconds: 7560, + avg_tokens_per_session: 5_300_000, + spend: 359.86, + saved_spend: 2174.59, + baseline_spend: 2534.45, + saved_pct: 85.8, + saved_per_session: 23.13, + cache: cache(), + ...overrides, +}); + +const group = (overrides: Partial = {}): AutoRouterBenchmarkGroup => ({ + router_name: "claude-auto", + router_type: "complexity", + ...totals(), + ...overrides, +}); + +const response = (groups: AutoRouterBenchmarkGroup[]): AutoRouterBenchmarksResponse => ({ + start_date: "2026-07-06", + end_date: "2026-08-05", + routers_in_scope: groups.length, + totals: totals(), + groups, +}); + +describe("viewFor", () => { + it("maps the all-routers selection to the server totals, never a client sum", () => { + const data = response([group(), group({ router_name: "gpt-auto", sessions: 7 })]); + const view = viewFor(data, ALL_ROUTERS); + expect(view.stats).toBe(data.totals); + expect(view.label).toBe("All auto-routers"); + }); + + it("maps a selected router to that group's slice with a scope of one", () => { + const other = group({ router_name: "gpt-auto", sessions: 7, saved_spend: 12.5 }); + const data = response([group(), other]); + const view = viewFor(data, groupKey(other)); + expect(view.stats).toBe(other); + expect(view.label).toBe("gpt-auto"); + }); + + it("falls back to the all-routers view when the selected key no longer exists", () => { + const data = response([group()]); + const view = viewFor(data, "vanished complexity"); + expect(view.stats).toBe(data.totals); + expect(view.label).toBe("All auto-routers"); + }); + + it("distinguishes two groups sharing an alias by their router type", () => { + const a = group({ router_type: "complexity" }); + const b = group({ router_type: "adaptive" }); + const data = response([a, b]); + expect(groupKey(a)).not.toBe(groupKey(b)); + expect(viewFor(data, groupKey(b)).stats).toBe(b); + expect(viewFor(data, groupKey(b)).label).toBe("claude-auto (adaptive)"); + }); +}); + +describe("groupLabel", () => { + it("uses the bare alias when it is unique", () => { + const groups = [group(), group({ router_name: "gpt-auto" })]; + expect(groupLabel(groups[0], groups)).toBe("claude-auto"); + }); + + it("appends the router type only when the alias is duplicated", () => { + const groups = [group({ router_type: "complexity" }), group({ router_type: "adaptive" })]; + expect(groupLabel(groups[0], groups)).toBe("claude-auto (complexity)"); + expect(groupLabel(groups[1], groups)).toBe("claude-auto (adaptive)"); + }); +}); + +describe("bucketRows", () => { + it("keeps the three buckets summing to the bucketed turn total", () => { + const stats = cache(); + const rows = bucketRows(stats); + expect(rows.map((r) => r.turns)).toEqual([400, 37, 381]); + expect(bucketTurnsTotal(stats)).toBe(818); + }); + + it("renders the server's per-bucket rates as-is", () => { + expect(bucketRows(cache()).map((r) => r.hitRatePct)).toEqual([97.7, 24.3, 81.6]); + }); + + it("derives each bucket's share of the measured turns", () => { + expect(bucketRows(cache()).map((r) => r.sharePct)).toEqual([49, 5, 47]); + }); + + it("reports zero shares instead of dividing by zero when nothing was bucketed", () => { + const empty = { turns: 0, hits: 0, hit_rate_pct: 0 }; + const rows = bucketRows(cache({ same_model: empty, first_visit: empty, return_to_tier: empty })); + expect(rows.map((r) => r.sharePct)).toEqual([0, 0, 0]); + }); +}); + +describe("expiredMissShare", () => { + it("recomputes the expired share from the miss counts", () => { + expect(expiredMissShare(cache())).toBeCloseTo((100 * 19) / 70); + }); + + it("is absent when every return turn hit", () => { + expect(expiredMissShare(cache({ return_to_tier: { turns: 10, hits: 10, hit_rate_pct: 100 } }))).toBeNull(); + }); +}); + +describe("windowFor", () => { + const noon = new Date("2026-08-05T12:00:00Z"); + + it("derives each picker range as UTC calendar days ending today", () => { + expect(windowFor("30d", noon)).toEqual({ start_date: "2026-07-06", end_date: "2026-08-05" }); + expect(windowFor("7d", noon)).toEqual({ start_date: "2026-07-29", end_date: "2026-08-05" }); + expect(windowFor("24h", noon)).toEqual({ start_date: "2026-08-04", end_date: "2026-08-05" }); + }); + + it("uses UTC days, not the local calendar", () => { + const lateEvening = new Date("2026-08-05T23:30:00-05:00"); + expect(windowFor("24h", lateEvening)).toEqual({ start_date: "2026-08-05", end_date: "2026-08-06" }); + }); +}); + +describe("formatting", () => { + it("renders session length in the largest sensible unit", () => { + expect(durationLabel(42)).toBe("42s"); + expect(durationLabel(150)).toBe("2.5m"); + expect(durationLabel(7560)).toBe("2.1h"); + }); + + it("renders percentages at the requested precision", () => { + expect(pctLabel(93.3)).toBe("93.3%"); + expect(pctLabel(85.8, 0)).toBe("86%"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.ts new file mode 100644 index 00000000000..8b6a4fa4105 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.ts @@ -0,0 +1,105 @@ +import type { components } from "@/lib/http/schema"; + +export type AutoRouterBenchmarksResponse = components["schemas"]["AutoRouterBenchmarksResponse"]; +export type AutoRouterBenchmarkTotals = components["schemas"]["AutoRouterBenchmarkTotals"]; +export type AutoRouterBenchmarkGroup = components["schemas"]["AutoRouterBenchmarkGroup"]; +export type AutoRouterCacheStats = components["schemas"]["AutoRouterCacheStats"]; + +export const ALL_ROUTERS = "__all__"; + +export type BenchmarkWindow = "30d" | "7d" | "24h"; + +const WINDOW_DAYS: Record = { "30d": 30, "7d": 7, "24h": 1 }; + +export const WINDOW_LABELS: Record = { + "30d": "Last 30 days", + "7d": "Last 7 days", + "24h": "Last 24 hours", +}; + +export const windowFor = (range: BenchmarkWindow, now: Date): { start_date: string; end_date: string } => ({ + start_date: new Date(now.getTime() - WINDOW_DAYS[range] * 24 * 60 * 60 * 1000).toISOString().slice(0, 10), + end_date: now.toISOString().slice(0, 10), +}); + +export interface BenchmarkView { + label: string; + stats: AutoRouterBenchmarkTotals; +} + +export const groupKey = (group: AutoRouterBenchmarkGroup): string => `${group.router_name} ${group.router_type}`; + +export const groupLabel = (group: AutoRouterBenchmarkGroup, groups: readonly AutoRouterBenchmarkGroup[]): string => { + const duplicated = groups.some((g) => g !== group && g.router_name === group.router_name); + return duplicated ? `${group.router_name} (${group.router_type})` : group.router_name; +}; + +export const viewFor = (data: AutoRouterBenchmarksResponse, selectedKey: string): BenchmarkView => { + const group = data.groups.find((g) => groupKey(g) === selectedKey); + if (selectedKey === ALL_ROUTERS || !group) { + return { label: "All auto-routers", stats: data.totals }; + } + return { label: groupLabel(group, data.groups), stats: group }; +}; + +export interface BucketRow { + key: "same_model" | "first_visit" | "return_to_tier"; + label: string; + sublabel: string; + turns: number; + sharePct: number; + hitRatePct: number; + fill: string; +} + +export const bucketTurnsTotal = (cache: AutoRouterCacheStats): number => + cache.same_model.turns + cache.first_visit.turns + cache.return_to_tier.turns; + +const sharePctOf = (turns: number, total: number): number => (total > 0 ? Math.round((100 * turns) / total) : 0); + +export const bucketRows = (cache: AutoRouterCacheStats): BucketRow[] => { + const total = bucketTurnsTotal(cache); + return [ + { + key: "same_model", + label: "Same model", + sublabel: "previous turn → same tier", + turns: cache.same_model.turns, + sharePct: sharePctOf(cache.same_model.turns, total), + hitRatePct: cache.same_model.hit_rate_pct, + fill: "bg-foreground", + }, + { + key: "first_visit", + label: "First visit", + sublabel: "previous turn → a tier not used yet", + turns: cache.first_visit.turns, + sharePct: sharePctOf(cache.first_visit.turns, total), + hitRatePct: cache.first_visit.hit_rate_pct, + fill: "bg-foreground/30", + }, + { + key: "return_to_tier", + label: "Return to tier", + sublabel: "previous turn → a tier used earlier", + turns: cache.return_to_tier.turns, + sharePct: sharePctOf(cache.return_to_tier.turns, total), + hitRatePct: cache.return_to_tier.hit_rate_pct, + fill: "bg-foreground/60", + }, + ]; +}; + +export const expiredMissShare = (cache: AutoRouterCacheStats): number | null => { + const misses = cache.return_to_tier.turns - cache.return_to_tier.hits; + if (misses <= 0) return null; + return (100 * cache.return_misses_expired) / misses; +}; + +export const pctLabel = (value: number, digits: number = 1): string => `${value.toFixed(digits)}%`; + +export const durationLabel = (seconds: number): string => { + if (seconds < 60) return `${Math.round(seconds)}s`; + if (seconds < 3600) return `${(seconds / 60).toFixed(1)}m`; + return `${(seconds / 3600).toFixed(1)}h`; +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useAutoRouterBenchmarks.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useAutoRouterBenchmarks.ts new file mode 100644 index 00000000000..ab87c5ef430 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useAutoRouterBenchmarks.ts @@ -0,0 +1,11 @@ +import { $api } from "@/lib/http/api"; + +import { windowFor, type BenchmarkWindow } from "./autoRouterBenchmarks"; + +export const useAutoRouterBenchmarks = (accessToken: string | null, range: BenchmarkWindow) => + $api.useQuery( + "get", + "/auto_router/benchmarks", + { params: { query: windowFor(range, new Date()) } }, + { enabled: Boolean(accessToken), retry: false }, + ); From 4b9872e7e890885d637bae32c62eb82998827cfe Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:53:44 -0700 Subject: [PATCH 28/38] fix(managed_files): return unified ids from unscoped file listing --- .../proxy/hooks/managed_files.py | 8 ++-- .../proxy/test_managed_files_hook.py | 39 +++++++++++++++++-- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index ec47b6ac0e6..4c0b2b1d5cd 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -383,9 +383,11 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): } ) return [ - OpenAIFileObject.model_validate(file_object.file_object) - for file_object in file_ids - if file_object.file_object is not None + OpenAIFileObject.model_validate(row.file_object).model_copy( + update={"id": row.unified_file_id} + ) + for row in file_ids + if row.file_object is not None ] async def check_managed_file_id_access( diff --git a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py index 4a4aa7aa5ea..5dc98640449 100644 --- a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py +++ b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py @@ -142,8 +142,11 @@ async def test_get_user_created_file_ids_skips_rows_without_file_object(): managed_files = _make_managed_files_instance() managed_files.prisma_client.db.litellm_managedfiletable.find_many = AsyncMock( return_value=[ - MagicMock(file_object=_make_file_object().model_dump()), - MagicMock(file_object=None), + MagicMock( + file_object=_make_file_object().model_dump(), + unified_file_id="unified-id-1", + ), + MagicMock(file_object=None, unified_file_id="unified-id-2"), ] ) @@ -151,7 +154,37 @@ async def test_get_user_created_file_ids_skips_rows_without_file_object(): _make_user_api_key_dict(), ["file-output-abc"] ) - assert [file.id for file in files] == ["file-output-abc"] + assert [file.id for file in files] == ["unified-id-1"] + + +@pytest.mark.asyncio +async def test_get_user_created_file_ids_remaps_stored_raw_provider_id_to_unified_id(): + """ + Rows registered from batch outputs store the provider's file object, whose + id is the raw provider id (e.g. file-abc). Listing must return the row's + unified_file_id so callers get ids that work on the managed routes. + + Regression test for https://github.com/BerriAI/litellm/issues/35362. + """ + unified_id = "bGl0ZWxsbV9wcm94eTt1bmlmaWVkX2lkLGRlYWRiZWVm" + raw_provider_object = _make_file_object("file-raw-provider-123") + managed_files = _make_managed_files_instance() + managed_files.prisma_client.db.litellm_managedfiletable.find_many = AsyncMock( + return_value=[ + MagicMock( + file_object=raw_provider_object.model_dump(), + unified_file_id=unified_id, + ), + ] + ) + + files = await managed_files.get_user_created_file_ids( + _make_user_api_key_dict(), ["file-raw-provider-123"] + ) + + assert [file.id for file in files] == [unified_id] + assert files[0].filename == raw_provider_object.filename + assert files[0].purpose == raw_provider_object.purpose @pytest.mark.asyncio From c2998dea7510a3b656c06d54dbcdae769927b83d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:49:15 -0700 Subject: [PATCH 29/38] fix(guardrails): guard tools write-back under scan_only_tool_results and warn on role-filtered no-op scans --- .../chat/guardrail_translation/handler.py | 2 +- .../chat/guardrail_translation/handler.py | 2 +- .../guardrail_hooks/bedrock_guardrails.py | 8 +++ .../panw_prisma_airs/panw_prisma_airs.py | 12 +++++ .../test_anthropic_guardrail_handler.py | 34 ++++++++++++ .../test_openai_guardrail_handler.py | 54 +++++++++++++++++++ .../test_bedrock_guardrails.py | 37 +++++++++++++ .../guardrail_hooks/test_panw_prisma_airs.py | 28 ++++++++++ 8 files changed, 175 insertions(+), 2 deletions(-) diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index 60424fb78b5..184e0f6a343 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -387,7 +387,7 @@ class AnthropicMessagesHandler(BaseTranslation): guardrailed_texts: Final = guardrailed_inputs.get("texts", []) guardrailed_tools: Final = guardrailed_inputs.get("tools") - if guardrailed_tools is not None: + if guardrailed_tools is not None and not scan_only_tool_results: # Convert tools back from OpenAI format to Anthropic format anthropic_config: Final = AnthropicConfig() anthropic_tools: Final[list[AllAnthropicToolsValues]] = [] diff --git a/litellm/llms/openai/chat/guardrail_translation/handler.py b/litellm/llms/openai/chat/guardrail_translation/handler.py index 9d7fe6ce2a8..dc2a06d67fc 100644 --- a/litellm/llms/openai/chat/guardrail_translation/handler.py +++ b/litellm/llms/openai/chat/guardrail_translation/handler.py @@ -143,7 +143,7 @@ class OpenAIChatCompletionsHandler(BaseTranslation): guardrailed_texts: Final = guardrailed_inputs.get("texts", []) guardrailed_tool_calls: Final = guardrailed_inputs.get("tool_calls", []) guardrailed_tools: Final = guardrailed_inputs.get("tools") - if guardrailed_tools is not None: + if guardrailed_tools is not None and not scan_only_tool_results: data["tools"] = guardrailed_tools guardrailed_structured_messages: Final = guardrailed_inputs.get("structured_messages") diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index f7a5c7559b1..8193069fd82 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -26,6 +26,9 @@ from litellm.caching import DualCache from litellm.exceptions import ModifyResponseException from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.litellm_core_utils.core_helpers import redact_nested_match_and_regex_keys +from litellm.llms.base_llm.guardrail_translation.utils import ( + effective_scan_only_tool_results_for_guardrail, +) from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, @@ -523,6 +526,11 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): latest_user_index: Final = self._find_latest_message_index(structured_messages, target_role="user") if latest_user_index is None: + if effective_scan_only_tool_results_for_guardrail(self): + verbose_proxy_logger.warning( + "Bedrock Guardrail: experimental_use_latest_role_message_only scans only the latest " + "user message, so scan_only_tool_results leaves nothing to scan for this request" + ) verbose_proxy_logger.debug("Bedrock Guardrail: no user-role message in request, skipping INPUT scan") return ApplyGuardrailMessageSelection(None, None, True, skip_scan=True) diff --git a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py index ae1478a9210..a96a0070eef 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py +++ b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py @@ -22,6 +22,9 @@ from litellm.integrations.custom_guardrail import ( CustomGuardrail, log_guardrail_information, ) +from litellm.llms.base_llm.guardrail_translation.utils import ( + effective_scan_only_tool_results_for_guardrail, +) from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, @@ -1716,6 +1719,15 @@ class PanwPrismaAirsHandler(CustomGuardrail): # - latest-user extraction returned None (no user / count mismatch) if scannable_indices is None: scannable_indices = self._get_scannable_text_indices(texts, structured_messages) + if ( + scannable_indices is not None + and not scannable_indices + and effective_scan_only_tool_results_for_guardrail(self) + ): + verbose_proxy_logger.warning( + "PANW Prisma AIRS scans only user, system, and developer messages, " + "so scan_only_tool_results leaves nothing to scan for this request" + ) for i, text in enumerate(texts): if not text or not text.strip(): diff --git a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py index a016e1a2deb..c7dedff0663 100644 --- a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py @@ -882,6 +882,40 @@ class TestAnthropicMessagesScanOnlyToolResults: ) assert data["messages"][2]["content"][0]["text"] == "sibling POISON text" + @pytest.mark.asyncio + async def test_guardrail_synthesized_tools_never_replace_scoped_out_request_tools(self): + handler = AnthropicMessagesHandler() + guardrail = ToolAppendingGuardrail(guardrail_name="tool-appending") + guardrail.scan_only_tool_results = True + original_tools = [ + { + "name": "get_weather", + "description": "Get the weather at a specific location", + "input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}, + } + ] + data = { + "model": "claude-sonnet-4-5", + "tools": original_tools, + "messages": [ + {"role": "user", "content": "what's the weather?"}, + { + "role": "assistant", + "content": [{"type": "tool_use", "id": "tu1", "name": "get_weather", "input": {}}], + }, + { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "tu1", "content": "sunny"}], + }, + ], + } + + await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) + + assert data["tools"] == original_tools, ( + "tools the guardrail synthesized without seeing the request's tools must not replace them" + ) + @pytest.mark.asyncio async def test_guardrail_is_not_called_when_the_request_has_no_tool_results(self): handler = AnthropicMessagesHandler() diff --git a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py index 907da66e5bf..269afef69cd 100644 --- a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py @@ -1253,6 +1253,31 @@ class StructuredRedactionGuardrail(CustomGuardrail): return inputs +class ToolSynthesizingGuardrail(CustomGuardrail): + """Appends its own function tool to whatever tools it was given, like a + retrieval/recovery guardrail that injects a tool the model can later call.""" + + def __init__(self): + super().__init__(guardrail_name="tool-synthesizing") + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[Any] = None, + ) -> GenericGuardrailAPIInputs: + tools = list(inputs.get("tools") or []) + tools.append( + { + "type": "function", + "function": {"name": "injected_retrieve", "parameters": {"type": "object", "properties": {}}}, + } + ) + inputs["tools"] = tools + return inputs + + class TestScanOnlyToolResults: def _bedrock_guardrail(self): from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import BedrockGuardrail @@ -1348,6 +1373,35 @@ class TestScanOnlyToolResults: "function definitions must stay out of a tool-results-only scan" ) + @pytest.mark.parametrize("scan_only_tool_results", [True, False]) + @pytest.mark.asyncio + async def test_guardrail_synthesized_tools_never_replace_scoped_out_request_tools(self, scan_only_tool_results): + handler = OpenAIChatCompletionsHandler() + guardrail = ToolSynthesizingGuardrail() + guardrail.scan_only_tool_results = scan_only_tool_results + original_tools = [ + { + "type": "function", + "function": {"name": "read_file", "parameters": {"type": "object", "properties": {}}}, + } + ] + data = { + "messages": [ + {"role": "user", "content": "read the report"}, + {"role": "tool", "tool_call_id": "call_1", "content": "TOOL-RESULT"}, + ], + "tools": original_tools, + } + + await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) + + if scan_only_tool_results: + assert data["tools"] == original_tools, ( + "tools the guardrail synthesized without seeing the request's tools must not replace them" + ) + else: + assert [t["function"]["name"] for t in data["tools"]] == ["read_file", "injected_retrieve"] + @pytest.mark.asyncio async def test_structured_write_back_keeps_out_of_scope_messages(self): handler = OpenAIChatCompletionsHandler() diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py index 65d6e33588f..76a695ce3fd 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py @@ -3670,3 +3670,40 @@ async def test_moderation_hook_honors_the_mcp_event_type(mode, call_type, should "the scan must be logged under the event it actually ran for, so guardrail logs, " "OTel spans, and Langfuse metadata do not misclassify MCP enforcement as an LLM call" ) + + +class TestScanOnlyToolResultsWithLatestRoleFilter: + @pytest.mark.asyncio + async def test_warns_and_skips_when_scoped_payload_has_no_user_message(self): + """scan_only_tool_results hands Bedrock a tool-role-only payload, but + experimental_use_latest_role_message_only scans only the latest user + message: the silent no-op must warn.""" + guardrail = BedrockGuardrail( + guardrail_name="bedrock-latest-role-scoped", + guardrailIdentifier="test-guardrail", + guardrailVersion="DRAFT", + default_on=True, + experimental_use_latest_role_message_only=True, + ) + guardrail.scan_only_tool_results = True + inputs = { + "texts": ["TOOL-RESULT"], + "structured_messages": [{"role": "tool", "tool_call_id": "call_1", "content": "TOOL-RESULT"}], + } + + with ( + patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api, + patch( + "litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails.verbose_proxy_logger.warning" + ) as mock_warning, + ): + result = await guardrail.apply_guardrail( + inputs=inputs, + request_data={"litellm_call_id": "test-call-id"}, + input_type="request", + ) + + mock_api.assert_not_called() + assert result["texts"] == ["TOOL-RESULT"] + warning_text = " ".join(str(arg) for c in mock_warning.call_args_list for arg in c.args) + assert "scan_only_tool_results" in warning_text diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py index 431a7aa6f02..2f0fd51539d 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py @@ -1696,6 +1696,34 @@ class TestPanwAirsApplyGuardrail: request_data=request_data, guardrail_name=handler.guardrail_name ) + @pytest.mark.asyncio + async def test_apply_guardrail_warns_when_tool_results_scope_leaves_nothing_scannable(self, handler): + """scan_only_tool_results hands PANW a tool-role-only payload, but PANW's role + filter only scans user/system/developer rows: the silent no-op must warn.""" + handler.scan_only_tool_results = True + inputs: GenericGuardrailAPIInputs = { + "texts": ["TOOL-RESULT"], + "structured_messages": [{"role": "tool", "tool_call_id": "call_1", "content": "TOOL-RESULT"}], + } + request_data = {"litellm_call_id": "test-call-id", "model": "gpt-4"} + + with ( + patch.object(handler, "_call_panw_api", new_callable=AsyncMock) as mock_api, + patch( + "litellm.proxy.guardrails.guardrail_hooks.panw_prisma_airs.panw_prisma_airs.verbose_proxy_logger.warning" + ) as mock_warning, + ): + result = await handler.apply_guardrail( + inputs=inputs, + request_data=request_data, + input_type="request", + ) + + mock_api.assert_not_called() + assert result["texts"] == ["TOOL-RESULT"] + warning_text = " ".join(str(arg) for c in mock_warning.call_args_list for arg in c.args) + assert "scan_only_tool_results" in warning_text + @pytest.mark.asyncio async def test_apply_guardrail_block(self, handler): """Test block action raises HTTPException(400).""" From 0bae9708a729943a26b5e08313f08aabc5f3cab9 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:57:50 -0700 Subject: [PATCH 30/38] fix(arize_phoenix): lowercase OTLP/gRPC auth metadata key (#34883) --- litellm/integrations/arize/arize_phoenix.py | 3 +- .../integrations/arize/test_arize_phoenix.py | 40 +++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/litellm/integrations/arize/arize_phoenix.py b/litellm/integrations/arize/arize_phoenix.py index e13fc0184a4..5b52c59cae2 100644 --- a/litellm/integrations/arize/arize_phoenix.py +++ b/litellm/integrations/arize/arize_phoenix.py @@ -430,7 +430,8 @@ class ArizePhoenixLogger(OpenTelemetry): otlp_auth_headers = None if api_key is not None: - otlp_auth_headers = f"Authorization=Bearer {api_key}" + auth_header_key = "authorization" if protocol == "otlp_grpc" else "Authorization" + otlp_auth_headers = f"{auth_header_key}=Bearer {api_key}" elif "app.phoenix.arize.com" in endpoint: raise ValueError("PHOENIX_API_KEY must be set when using Phoenix Cloud (app.phoenix.arize.com).") diff --git a/tests/test_litellm/integrations/arize/test_arize_phoenix.py b/tests/test_litellm/integrations/arize/test_arize_phoenix.py index afd83f81ce0..9f79534242c 100644 --- a/tests/test_litellm/integrations/arize/test_arize_phoenix.py +++ b/tests/test_litellm/integrations/arize/test_arize_phoenix.py @@ -37,8 +37,8 @@ class TestArizePhoenixConfig(unittest.TestCase): # Call the function to get the configuration config = ArizePhoenixLogger.get_arize_phoenix_config() - # Verify the configuration - now uses standard Authorization Bearer format - self.assertEqual(config.otlp_auth_headers, "Authorization=Bearer test_api_key") + # gRPC metadata keys must be lowercase, so the auth header key is lowercased + self.assertEqual(config.otlp_auth_headers, "authorization=Bearer test_api_key") self.assertEqual(config.endpoint, "grpc://test.endpoint") self.assertEqual(config.protocol, "otlp_grpc") @@ -136,7 +136,7 @@ class TestArizePhoenixConfig(unittest.TestCase): "PHOENIX_COLLECTOR_ENDPOINT": "grpc://localhost:6006", "PHOENIX_API_KEY": "test_api_key", }, - "Authorization=Bearer test_api_key", + "authorization=Bearer test_api_key", "grpc://localhost:6006", "otlp_grpc", id="explicit grpc endpoint with grpc:// prefix", @@ -215,6 +215,40 @@ def test_get_arize_phoenix_config_expection_on_missing_api_key(monkeypatch, env_ ArizePhoenixLogger.get_arize_phoenix_config() +@pytest.mark.parametrize( + "collector_endpoint, expected_key", + [ + pytest.param("grpc://localhost:6006", "authorization", id="grpc prefix"), + pytest.param("http://localhost:4317", "authorization", id="grpc port 4317"), + pytest.param("http://localhost:6006", "Authorization", id="http"), + ], +) +def test_get_arize_phoenix_config_auth_header_key_casing( + monkeypatch, collector_endpoint, expected_key +): + """Regression for #34882: gRPC metadata keys must be lowercase. + + HTTP headers are case-insensitive, but the OTLP/gRPC exporter rejects an + uppercase ``Authorization`` metadata key, so span export silently fails. + """ + for key in [ + "PHOENIX_API_KEY", + "PHOENIX_COLLECTOR_ENDPOINT", + "PHOENIX_COLLECTOR_HTTP_ENDPOINT", + ]: + monkeypatch.delenv(key, raising=False) + + monkeypatch.setenv("PHOENIX_API_KEY", "test_api_key") + monkeypatch.setenv("PHOENIX_COLLECTOR_ENDPOINT", collector_endpoint) + + config = ArizePhoenixLogger.get_arize_phoenix_config() + + assert config.otlp_auth_headers == f"{expected_key}=Bearer test_api_key" + header_key = config.otlp_auth_headers.split("=", 1)[0] + if config.protocol == "otlp_grpc": + assert header_key == header_key.lower() + + # --------------------------------------------------------------------------- # Per-project routing via Resource (not span attributes) # --------------------------------------------------------------------------- From 7c621b31410dd116971db2c405d9cff55ed3b660 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Wed, 5 Aug 2026 21:03:36 -0700 Subject: [PATCH 31/38] fix(auto-router): accept every reminder marker pair a harness emits (#36029) * fix(auto-router): accept every reminder marker pair a harness emits reminder_markers held one (open, close) pair, so a harness that wraps injected context differently per agent type only got the slice of traffic using the configured envelope stripped. Every other agent type kept hitting the original bug: its reminder-only turn never stripped to empty, won "newest human ask", and the harness blob got classified in place of the real question, choosing the tier and therefore the spend. The field now takes a list of ReminderMarkerPair, following the KeywordTierRule pattern already in this file so each pair validates itself and errors point at reminder_markers.N.close rather than a bare index. Blocks from different pairs can nest, which the gap construction could not handle: resuming the kept text at an inner block's end walks back inside the enclosing block and leaks its remainder. Running the block ends through a maximum collapses nested and overlapping spans without a separate merge pass, and stays linear in block count, which a fold over a growing tuple of merged spans would not. A single pair's ends already increase, so the maximum is the identity and the default path is byte-identical: verified against the shipped function over 200k generated inputs, and every existing reminder test passes unchanged. The prior single-pair config shape is rejected loudly at startup and at /model/new rather than silently stripping nothing. * docs(auto-router): document reminder_markers in the complexity router README * chore(ui): regenerate dashboard API types for the reminder_markers shape --------- Co-authored-by: Abhimanyu Kapur <38531241+akapur99@users.noreply.github.com> --- .../complexity_router/README.md | 21 ++ .../complexity_router/__init__.py | 2 + .../complexity_router/complexity_router.py | 71 ++++-- .../complexity_router/config.py | 47 ++-- .../router_strategy/test_complexity_router.py | 222 +++++++++++++++++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 27 ++- 6 files changed, 336 insertions(+), 54 deletions(-) diff --git a/litellm/router_strategy/complexity_router/README.md b/litellm/router_strategy/complexity_router/README.md index b1fdb0044be..259933dbb9e 100644 --- a/litellm/router_strategy/complexity_router/README.md +++ b/litellm/router_strategy/complexity_router/README.md @@ -171,6 +171,27 @@ If 2+ reasoning markers are detected in the user message, the request is automat Reasoning markers in the system prompt do **not** trigger the reasoning override. This prevents system prompts like "Think step by step before answering" from forcing all requests to the reasoning tier. +### Harness Reminder Blocks + +Agent harnesses inject their own context into the conversation as ordinary message text. That text is plumbing, not something a human asked for, so the router strips complete reminder blocks before classifying and picking a tier. A turn that is nothing but a reminder block strips to empty and is skipped, and the router falls back to the last real ask instead + +By default a block is anything between `` and ``. `reminder_markers` replaces that with your harness's own delimiters. Many harnesses use a different envelope per agent type, so list every pair you emit: + +```yaml +model_list: + - model_name: smart-router + litellm_params: + model: auto_router/complexity_router + complexity_router_config: + reminder_markers: + - open: "<<>>" + close: "<<>>" + - open: "[[SUBAGENT_CONTEXT_BEGIN]]" + close: "[[SUBAGENT_CONTEXT_END]]" +``` + +Setting `reminder_markers` replaces the built-in `` pair rather than adding to it, so list that pair too if your harness also emits it. Matching is case-insensitive. Blocks that nest or overlap across pairs are stripped whole. An unclosed delimiter is not a block and is left in place, which keeps prose that merely mentions a delimiter from being eaten + ### Code Detection Technical code keywords are detected case-insensitively and include: diff --git a/litellm/router_strategy/complexity_router/__init__.py b/litellm/router_strategy/complexity_router/__init__.py index 1830ff506e9..aa618cc807e 100644 --- a/litellm/router_strategy/complexity_router/__init__.py +++ b/litellm/router_strategy/complexity_router/__init__.py @@ -16,6 +16,7 @@ from litellm.router_strategy.complexity_router.config import ( DEFAULT_COMPLEXITY_CONFIG, ComplexityRouterConfig, ComplexityTier, + ReminderMarkerPair, ) __all__ = [ @@ -24,5 +25,6 @@ __all__ = [ "ComplexityRouter", "ComplexityRouterConfig", "ComplexityTier", + "ReminderMarkerPair", "classification_system_prompt", ] diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index 7bbe01191e3..a69509fc37a 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -19,7 +19,7 @@ import asyncio import random import re from collections.abc import Iterator, Mapping, Sequence -from itertools import islice +from itertools import accumulate, islice from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, NamedTuple, cast @@ -233,6 +233,7 @@ def _effective_turn_off_message_logging(request_kwargs: Mapping[str, Any] | None _REMINDER_OPEN: Final = "" _REMINDER_CLOSE: Final = "" +_DEFAULT_REMINDER_MARKERS: Final = ((_REMINDER_OPEN, _REMINDER_CLOSE),) _TRUNCATION_MARKER: Final = "..." @@ -253,10 +254,8 @@ def _message_text(content: object) -> str: return content if isinstance(content, str) else "" -def _reminder_block_spans( - lowered: str, open_marker: str = _REMINDER_OPEN, close_marker: str = _REMINDER_CLOSE -) -> Iterator[tuple[int, int]]: - """Span of each complete reminder block, left to right. +def _reminder_block_spans(lowered: str, open_marker: str, close_marker: str) -> Iterator[tuple[int, int]]: + """Span of each complete reminder block for one marker pair, left to right. Literal `str.find`, not a regex: the delimiters are fixed strings, and `.*?` retried its lazy quantifier from every opening tag, so repeated unclosed tags were quadratic @@ -272,17 +271,36 @@ def _reminder_block_spans( yield start, cursor -def _strip_reminder_blocks(text: str, open_marker: str = _REMINDER_OPEN, close_marker: str = _REMINDER_CLOSE) -> str: - """Remove every complete reminder block from text, keeping everything written around them.""" - spans: Final = tuple(_reminder_block_spans(text.lower(), open_marker, close_marker)) +def _strip_reminder_blocks(text: str, marker_pairs: tuple[tuple[str, str], ...] = _DEFAULT_REMINDER_MARKERS) -> str: + """Remove every complete reminder block from text, keeping everything written around them. + + Blocks from different pairs can nest or overlap, which the gap construction below would + otherwise mishandle: an inner block's end would resume the kept text partway through the outer + block, leaking the rest of that block into the classified ask. Running the block ends through a + maximum resumes each gap past the furthest block seen so far, which collapses nested and + overlapping spans without a separate merge pass. A single pair's ends already increase, so the + maximum is the identity there and the default path is byte-identical to a plain scan. + + Deliberately linear in both the text and the block count. This runs pre-routing on input any + keyholder controls, and both a regex scan and a fold that rebuilds a growing tuple of merged + spans go quadratic on inputs that are cheap to send. + """ + lowered: Final = text.lower() + spans: Final = tuple( + sorted( + span + for open_marker, close_marker in marker_pairs + for span in _reminder_block_spans(lowered, open_marker, close_marker) + ) + ) if not spans: return text.strip() - keep_from: Final = (0, *(end for _, end in spans)) + keep_from: Final = (0, *accumulate((end for _, end in spans), max)) keep_to: Final = (*(start for start, _ in spans), len(text)) return " ".join(kept for a, b in zip(keep_from, keep_to) if (kept := text[a:b].strip())) -def _human_text(content: object, open_marker: str = _REMINDER_OPEN, close_marker: str = _REMINDER_CLOSE) -> str: +def _human_text(content: object, marker_pairs: tuple[tuple[str, str], ...] = _DEFAULT_REMINDER_MARKERS) -> str: """Message content as the text a human wrote, with complete reminder blocks removed. Harnesses inject reminders as ordinary text alongside the live ask, so the block is stripped and @@ -291,18 +309,18 @@ def _human_text(content: object, open_marker: str = _REMINDER_OPEN, close_marker one, and this same string drives escalation keywords and keyword_tier_rules, which choose the model and therefore the spend. An unclosed tag is not a block and is left intact. """ - return _strip_reminder_blocks(_message_text(content), open_marker, close_marker) + return _strip_reminder_blocks(_message_text(content), marker_pairs) def _iter_human_asks_newest_first( - messages: Sequence[Mapping[str, object]], markers: tuple[str, str] = (_REMINDER_OPEN, _REMINDER_CLOSE) + messages: Sequence[Mapping[str, object]], + marker_pairs: tuple[tuple[str, str], ...] = _DEFAULT_REMINDER_MARKERS, ) -> Iterator[str]: """Yield user-turn texts that carry a real human ask, newest first, with harness noise removed.""" - open_marker, close_marker = markers return ( text for msg in reversed(messages) - if msg.get("role") == "user" and (text := _human_text(msg.get("content"), open_marker, close_marker)) + if msg.get("role") == "user" and (text := _human_text(msg.get("content"), marker_pairs)) ) @@ -341,7 +359,8 @@ def _conversation_is_continuing(messages: Sequence[Mapping[str, object]] | None) def _newest_turn_ask( - messages: Sequence[Mapping[str, object]], markers: tuple[str, str] = (_REMINDER_OPEN, _REMINDER_CLOSE) + messages: Sequence[Mapping[str, object]], + marker_pairs: tuple[tuple[str, str], ...] = _DEFAULT_REMINDER_MARKERS, ) -> str | None: """The human ask on the newest user turn, or None when that turn carries only plumbing. @@ -352,12 +371,12 @@ def _newest_turn_ask( newest_user_turn: Final = next((msg for msg in reversed(messages) if msg.get("role") == "user"), None) if newest_user_turn is None: return None - return _human_text(newest_user_turn.get("content"), *markers) or None + return _human_text(newest_user_turn.get("content"), marker_pairs) or None def _extract_current_ask_and_system_prompt( messages: Sequence[Mapping[str, object]], - markers: tuple[str, str] = (_REMINDER_OPEN, _REMINDER_CLOSE), + marker_pairs: tuple[tuple[str, str], ...] = _DEFAULT_REMINDER_MARKERS, ) -> tuple[str | None, str | None]: """The last real human ask and the last system prompt; either is None if absent. @@ -365,7 +384,7 @@ def _extract_current_ask_and_system_prompt( the caller routes to its default model. That is the correct answer rather than a gap to fill: filling it would hand tier selection to harness-injected text. """ - current_ask: Final = next(_iter_human_asks_newest_first(messages, markers), None) + current_ask: Final = next(_iter_human_asks_newest_first(messages, marker_pairs), None) system_prompt: Final = next( ( text @@ -385,7 +404,7 @@ def _truncate(text: str, limit: int) -> str: def _iter_context_turns_newest_first( messages: Sequence[Mapping[str, object]], include_assistant: bool, - markers: tuple[str, str] = (_REMINDER_OPEN, _REMINDER_CLOSE), + marker_pairs: tuple[tuple[str, str], ...] = _DEFAULT_REMINDER_MARKERS, ) -> Iterator[tuple[str, str]]: """Yield (role, text) for turns eligible as classifier context, newest first. @@ -401,7 +420,7 @@ def _iter_context_turns_newest_first( for msg in reversed(messages) if isinstance(role := msg.get("role"), str) and role in roles - and (text := _human_text(msg.get("content"), *markers)) + and (text := _human_text(msg.get("content"), marker_pairs)) ) @@ -411,7 +430,7 @@ def _extract_prior_turns( window_size: int, per_turn_chars: int, include_assistant: bool, - markers: tuple[str, str] = (_REMINDER_OPEN, _REMINDER_CLOSE), + marker_pairs: tuple[tuple[str, str], ...] = _DEFAULT_REMINDER_MARKERS, ) -> tuple[tuple[str, str], ...]: """Up to window_size turns other than current_ask, oldest first, as (role, text). @@ -431,7 +450,7 @@ def _extract_prior_turns( prior: Final = islice( ( turn - for turn in _iter_context_turns_newest_first(messages, include_assistant, markers) + for turn in _iter_context_turns_newest_first(messages, include_assistant, marker_pairs) if turn[1] != current_ask ), window_size, @@ -556,7 +575,11 @@ class ComplexityRouter(CustomLogger): if self.config.escalation_keywords is not None else DEFAULT_ESCALATION_KEYWORDS ) - self._reminder_markers: tuple[str, str] = self.config.reminder_markers or (_REMINDER_OPEN, _REMINDER_CLOSE) + self._reminder_markers: tuple[tuple[str, str], ...] = ( + tuple((pair.open, pair.close) for pair in self.config.reminder_markers) + if self.config.reminder_markers + else _DEFAULT_REMINDER_MARKERS + ) # Lazily built on first semantic request and cached for reuse (route # embeddings are static, only the prompt is embedded per request). The lock @@ -993,7 +1016,7 @@ class ComplexityRouter(CustomLogger): window_size=self.config.classifier_context_window_size, per_turn_chars=self.config.classifier_context_per_turn_chars, include_assistant=include_assistant, - markers=self._reminder_markers, + marker_pairs=self._reminder_markers, ) if context_enabled else () diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py index f9d3bd9ae67..69609a973b0 100644 --- a/litellm/router_strategy/complexity_router/config.py +++ b/litellm/router_strategy/complexity_router/config.py @@ -59,6 +59,30 @@ class KeywordTierRule(BaseModel): return self +class ReminderMarkerPair(BaseModel): + """One open/close delimiter pair a harness wraps injected context in. + + Normalizing here rather than at the scan is what makes matching case-insensitive: markers reach + the scan already lowered, so it lowercases only the haystack and never the needles. Stripping + keeps YAML indentation whitespace from becoming part of the delimiter. + """ + + open: str = Field(description="Opening delimiter, e.g. ''") + close: str = Field(description="Closing delimiter, e.g. ''") + + @model_validator(mode="after") + def _normalize(self) -> "ReminderMarkerPair": + open_marker: Final = self.open.strip().lower() + close_marker: Final = self.close.strip().lower() + if not open_marker or not close_marker: + raise ValueError("reminder_markers entries must not be blank") + if open_marker == close_marker: + raise ValueError("reminder_markers open and close must be different strings") + self.open = open_marker + self.close = close_marker + return self + + # ─── Default Keyword Lists ─── # Note: Keywords should be full words/phrases to avoid substring false positives. # The matching logic uses word boundary detection for single-word keywords. @@ -498,12 +522,15 @@ class ComplexityRouterConfig(BaseModel): description="RoutingPlugin instances that narrow the classified tier's candidate models before selection", ) - reminder_markers: tuple[str, str] | None = Field( + reminder_markers: tuple[ReminderMarkerPair, ...] | None = Field( default=None, + min_length=1, description=( - "Override the (open, close) marker pair used to recognize and strip harness-injected " - "reminder blocks before classification. Defaults to Claude Code's convention, " - "('', ''), when unset. Matching is case-insensitive." + "Override the delimiter pairs used to recognize and strip harness-injected reminder " + "blocks before classification. A harness that wraps injected context differently per " + "agent type (main, subagent, cron) lists every pair it emits. Replaces, rather than " + "adds to, the built-in default of ('', ''), so a " + "harness that also emits that pair lists it too. Matching is case-insensitive." ), ) @@ -601,18 +628,6 @@ class ComplexityRouterConfig(BaseModel): ) return self - @model_validator(mode="after") - def _normalize_reminder_markers(self) -> "ComplexityRouterConfig": - if self.reminder_markers is None: - return self - open_marker, close_marker = (marker.strip().lower() for marker in self.reminder_markers) - if not open_marker or not close_marker: - raise ValueError("reminder_markers entries must not be blank") - if open_marker == close_marker: - raise ValueError("reminder_markers open and close must be different strings") - self.reminder_markers = (open_marker, close_marker) - return self - def tier_label(self, tier: ComplexityTier) -> str: """Operator-facing display name for a tier, falling back to its canonical name.""" return self.tier_labels.get(tier, "").strip() or tier.value diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index b3f1e929741..8e9e32f5898 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -112,6 +112,52 @@ class TestComplexityRouterInit: assert router.config.tiers["SIMPLE"] == "gpt-4o-mini" assert router.config.tiers["REASONING"] == "o1-preview" + def test_configured_marker_pairs_reach_the_ask_extraction(self, mock_router_instance, basic_config): + """Marker pairs configured in YAML must actually reach the code that strips them. + + The config field, the validator and the scan were each covered on their own, but nothing + exercised config.reminder_markers -> self._reminder_markers, so the router could have parsed + a valid config and still classified on unstripped text. Asserting through the extraction the + router feeds its classifier is what makes that wiring a regression rather than a silent gap. + """ + from litellm.router_strategy.complexity_router.complexity_router import ( + _extract_current_ask_and_system_prompt, + ) + + ask = "Derive the amortized complexity of a splay tree access" + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + **basic_config, + "reminder_markers": [ + {"open": "<<>>", "close": "<<>>"}, + {"open": "[[SUBAGENT_BEGIN]]", "close": "[[SUBAGENT_END]]"}, + ], + }, + ) + + assert router._reminder_markers == ( + ("<<>>", "<<>>"), + ("[[subagent_begin]]", "[[subagent_end]]"), + ) + messages = [ + {"role": "user", "content": ask}, + {"role": "assistant", "content": "Working on it."}, + {"role": "user", "content": "[[SUBAGENT_BEGIN]]Budget: 42 tokens remaining.[[SUBAGENT_END]]"}, + ] + assert _extract_current_ask_and_system_prompt(messages, router._reminder_markers)[0] == ask + + def test_unconfigured_marker_pairs_fall_back_to_the_builtin_default(self, mock_router_instance, basic_config): + """A config that never mentions reminder_markers keeps stripping .""" + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config=basic_config, + ) + + assert router._reminder_markers == (("", ""),) + def test_init_without_config(self, mock_router_instance): """Test initialization without configuration uses defaults.""" router = ComplexityRouter( @@ -2991,17 +3037,68 @@ class TestSemanticConfigValidation: def test_reminder_markers_are_normalized(self): """Markers are stripped and lowercased, matching how the built-in constants are compared.""" config = ComplexityRouterConfig( - reminder_markers=(" <<>> ", "<<>>"), + reminder_markers=[{"open": " <<>> ", "close": "<<>>"}], ) - assert config.reminder_markers == ("<<>>", "<<>>") + assert config.reminder_markers is not None + assert (config.reminder_markers[0].open, config.reminder_markers[0].close) == ( + "<<>>", + "<<>>", + ) + + def test_reminder_markers_keep_every_configured_pair_in_order(self): + """Every pair a harness emits survives validation, not just the first.""" + config = ComplexityRouterConfig( + reminder_markers=[ + {"open": "<<>>", "close": "<<>>"}, + {"open": "[[SUBAGENT_BEGIN]]", "close": "[[SUBAGENT_END]]"}, + {"open": "%%CRON_BEGIN%%", "close": "%%CRON_END%%"}, + ], + ) + assert config.reminder_markers is not None + assert [(pair.open, pair.close) for pair in config.reminder_markers] == [ + ("<<>>", "<<>>"), + ("[[subagent_begin]]", "[[subagent_end]]"), + ("%%cron_begin%%", "%%cron_end%%"), + ] def test_reminder_markers_reject_blank_entry(self): with pytest.raises(ValidationError, match="must not be blank"): - ComplexityRouterConfig(reminder_markers=("", "<<>>")) + ComplexityRouterConfig(reminder_markers=[{"open": "", "close": "<<>>"}]) def test_reminder_markers_reject_identical_open_and_close(self): with pytest.raises(ValidationError, match="must be different"): - ComplexityRouterConfig(reminder_markers=("<<>>", "<<>>")) + ComplexityRouterConfig(reminder_markers=[{"open": "<<>>", "close": "<<>>"}]) + + def test_reminder_markers_reject_a_bad_pair_anywhere_in_the_list(self): + """Validation runs per pair, so a broken entry after a good one is still caught.""" + with pytest.raises(ValidationError, match="must be different"): + ComplexityRouterConfig( + reminder_markers=[ + {"open": "<<>>", "close": "<<>>"}, + {"open": "<<>>", "close": "<<>>"}, + ], + ) + + def test_reminder_markers_reject_empty_list(self): + """An explicitly empty list is ambiguous, so it fails loudly instead of silently defaulting. + + Left to fall through, an empty list resolves to the built-in pair, which + reads as "strip nothing" in the config and does the opposite. Matching on the length error + keeps this from passing for some unrelated reason if the field type changes. + """ + with pytest.raises(ValidationError, match="at least 1 item"): + ComplexityRouterConfig(reminder_markers=[]) + + def test_reminder_markers_reject_the_old_flat_pair_form(self): + """The pre-list shape is rejected loudly rather than silently routing on unstripped text. + + reminder_markers took a bare (open, close) string pair before it took a list of pairs. A + config still using that shape must fail validation at startup and at /model/new write time, + because the alternative -- accepting it and stripping nothing -- hands tier selection, and + therefore spend, to harness-injected text without any signal that it happened. + """ + with pytest.raises(ValidationError, match="valid dictionary or instance of ReminderMarkerPair"): + ComplexityRouterConfig(reminder_markers=("", "")) class _StubEncoder: @@ -4306,7 +4403,6 @@ class TestRoutingDecisionContents: # The score is still recorded, but the cause is what says it did not decide. assert decision["score"] < decision["tier_boundaries"]["complex_reasoning"] - @pytest.mark.asyncio async def test_an_unrenamed_router_writes_no_tier_label(self, complexity_router): """Renaming is opt-in, so a deployment that never renamed must gain no new key. @@ -4919,12 +5015,73 @@ class TestContextAwareClassifier: """ from litellm.router_strategy.complexity_router.complexity_router import _extract_current_ask_and_system_prompt - markers = ("<<>>", "<<>>") - follow_up_reminder = f"{markers[0]}Budget: 42 tokens remaining. Do not mention this.{markers[1]}" + pair = ("<<>>", "<<>>") + follow_up_reminder = f"{pair[0]}Budget: 42 tokens remaining. Do not mention this.{pair[1]}" messages = [_ASKED, _ANSWERED, {"role": "user", "content": follow_up_reminder}] assert _extract_current_ask_and_system_prompt(messages)[0] == follow_up_reminder - assert _extract_current_ask_and_system_prompt(messages, markers)[0] == _ASK + assert _extract_current_ask_and_system_prompt(messages, (pair,))[0] == _ASK + + def test_every_configured_marker_pair_is_stripped_not_just_the_first(self): + """One deployment serves a harness whose agent types each use a different envelope. + + Main agent, subagent and cron wrap injected context in different open/close pairs, and they + all route through the same auto-router. When only one pair could be configured, the other + agent types kept hitting the original bug: their reminder-only turn never stripped to empty, + won "newest human ask", and the harness blob got classified in place of the real question. + Each pair in turn must be skipped, so this fails if only the first configured pair is used. + """ + from litellm.router_strategy.complexity_router.complexity_router import _extract_current_ask_and_system_prompt + + pairs = ( + ("<<>>", "<<>>"), + ("[[subagent_begin]]", "[[subagent_end]]"), + ("%%cron_begin%%", "%%cron_end%%"), + ) + for open_marker, close_marker in pairs: + reminder_only_turn = f"{open_marker}Budget: 42 tokens remaining.{close_marker}" + messages = [_ASKED, _ANSWERED, {"role": "user", "content": reminder_only_turn}] + + assert _extract_current_ask_and_system_prompt(messages, pairs)[0] == _ASK, open_marker + + def test_a_block_nested_inside_another_pairs_block_does_not_leak(self): + """Nested blocks from two pairs must strip whole, not resume inside the outer block. + + Spans are collected per pair and can nest. Resuming the kept text at each block's own end + walks backwards into the enclosing block, so the outer block's remainder (and its dangling + close marker) survive into the classified ask. That is harness text choosing the tier, and + therefore the spend. Overlapping and disjoint spans strip correctly either way, so this + nested case is what pins the behavior. + """ + from litellm.router_strategy.complexity_router.complexity_router import _strip_reminder_blocks + + pairs = (("<<>>", "<<>>"), ("[[subagent_begin]]", "[[subagent_end]]")) + nested = "<<>>budget[[subagent_begin]]inner[[subagent_end]]do not mention<<>>" + + assert _strip_reminder_blocks(f"{nested} what is a splay tree?", pairs) == "what is a splay tree?" + + def test_overlapping_blocks_from_two_pairs_strip_whole(self): + """Interleaved (not nested) blocks still strip everything they jointly cover.""" + from litellm.router_strategy.complexity_router.complexity_router import _strip_reminder_blocks + + pairs = (("<<>>", "<<>>"), ("[[subagent_begin]]", "[[subagent_end]]")) + overlapping = "<<>>a[[subagent_begin]]b<<>>c[[subagent_end]]" + + assert _strip_reminder_blocks(f"{overlapping} what is a splay tree?", pairs) == "what is a splay tree?" + + def test_an_unclosed_marker_in_one_pair_does_not_suppress_another_pairs_blocks(self): + """Each pair scans independently, so one pair's dangling opener is not a global stop. + + An unclosed tag ends that pair's scan by design and is left intact as prose. It must not + also swallow a different pair's complete block, which would put harness text back in front + of the classifier. + """ + from litellm.router_strategy.complexity_router.complexity_router import _strip_reminder_blocks + + pairs = (("<<>>", "<<>>"), ("[[subagent_begin]]", "[[subagent_end]]")) + text = "<<>> why is [[subagent_begin]]noise[[subagent_end]] my tag stripped?" + + assert _strip_reminder_blocks(text, pairs) == "<<>> why is my tag stripped?" @pytest.mark.parametrize( "messages,current_ask,window,per_turn_chars,include_assistant,expected", @@ -5084,6 +5241,28 @@ class TestContextAwareClassifier: assert _extract_prior_turns(messages, current_ask, window, per_turn_chars, include_assistant) == expected + def test_prior_turn_context_strips_every_configured_pair(self): + """The classifier's context window is stripped with the same pairs as the ask. + + Prior turns are quoted verbatim into the LLM classifier payload, so a pair that is honored + when picking the ask but ignored when building context puts the harness blob back in front + of the classifier through the other door. This covers the _extract_prior_turns call the ask + extraction tests never reach. + """ + from litellm.router_strategy.complexity_router.complexity_router import _extract_prior_turns + + pairs = (("<<>>", "<<>>"), ("[[subagent_begin]]", "[[subagent_end]]")) + messages = [ + {"role": "user", "content": "[[subagent_begin]]budget blob[[subagent_end]]what about b-trees?"}, + {"role": "user", "content": "<<>>other blob<<>>and heaps?"}, + {"role": "user", "content": "current ask"}, + ] + + assert _extract_prior_turns(messages, "current ask", 5, 200, False, pairs) == ( + ("user", "what about b-trees?"), + ("user", "and heaps?"), + ) + def test_reminder_scan_is_linear_on_adversarial_input(self): """Unclosed reminder tags must not make stripping superlinear. @@ -5105,6 +5284,29 @@ class TestContextAwareClassifier: assert elapsed < 1.0, f"stripping {len(adversarial)} chars took {elapsed:.2f}s; scan is not linear" assert result == adversarial + def test_reminder_scan_stays_linear_in_block_count_across_pairs(self): + """Many *complete* blocks across several pairs must not go quadratic either. + + Collapsing nested and overlapping spans is required for correctness once more than one pair + is configured, and the obvious way to write it -- folding merged spans into a growing tuple + -- is quadratic in block count. Unlike the unclosed-tag case above, these blocks all close, + so they actually produce spans. This input is a few hundred KB, which any keyholder can send + pre-routing, and it fails loudly if the collapse is ever rewritten as a fold. + """ + import time + + from litellm.router_strategy.complexity_router.complexity_router import _strip_reminder_blocks + + pairs = (("", ""), ("", "")) + adversarial = "xy" * 25_000 + + start = time.perf_counter() + result = _strip_reminder_blocks(f"{adversarial} what is a splay tree?", pairs) + elapsed = time.perf_counter() - start + + assert elapsed < 1.0, f"stripping {50_000} blocks took {elapsed:.2f}s; collapse is not linear" + assert result == "what is a splay tree?" + @pytest.mark.asyncio async def test_llm_classifier_includes_prior_turns_context(self, llm_complexity_router, mock_router_instance): """Test that the LLM classifier receives prior-turn context in the user message.""" @@ -5761,7 +5963,9 @@ class TestCustomClassifierSystemPrompt: @pytest.mark.asyncio async def test_custom_prompt_is_sent_verbatim_as_the_system_role(self, mock_router_instance, llm_classifier_config): - custom = "Classify the data sensitivity: SIMPLE=public, MEDIUM=internal, COMPLEX=confidential, REASONING=regulated." + custom = ( + "Classify the data sensitivity: SIMPLE=public, MEDIUM=internal, COMPLEX=confidential, REASONING=regulated." + ) router = ComplexityRouter( model_name="test-complexity-router", litellm_router_instance=mock_router_instance, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 65434407f74..959c9921896 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -31560,6 +31560,26 @@ export interface components { /** Review Notes */ review_notes?: string | null; }; + /** + * ReminderMarkerPair + * @description One open/close delimiter pair a harness wraps injected context in. + * + * Normalizing here rather than at the scan is what makes matching case-insensitive: markers reach + * the scan already lowered, so it lowercases only the haystack and never the needles. Stripping + * keeps YAML indentation whitespace from becoming part of the delimiter. + */ + ReminderMarkerPair: { + /** + * Close + * @description Closing delimiter, e.g. '' + */ + close: string; + /** + * Open + * @description Opening delimiter, e.g. '' + */ + open: string; + }; /** * RequestComplexityRouterConfig * @description The part of a complexity-router config a request can carry. @@ -31672,12 +31692,9 @@ export interface components { reasoning_keywords?: string[] | null; /** * Reminder Markers - * @description Override the (open, close) marker pair used to recognize and strip harness-injected reminder blocks before classification. Defaults to Claude Code's convention, ('', ''), when unset. Matching is case-insensitive. + * @description Override the delimiter pairs used to recognize and strip harness-injected reminder blocks before classification. A harness that wraps injected context differently per agent type (main, subagent, cron) lists every pair it emits. Replaces, rather than adds to, the built-in default of ('', ''), so a harness that also emits that pair lists it too. Matching is case-insensitive. */ - reminder_markers?: [ - string, - string - ] | null; + reminder_markers?: components["schemas"]["ReminderMarkerPair"][] | null; /** * Return Raw Model Name * @description Return the resolved raw model name in the response model field instead of the client-requested complexity-router alias From 3d275d97feacc8a0e2a0d35bbd0f108a93e9971d Mon Sep 17 00:00:00 2001 From: Michael Cusack Date: Wed, 5 Aug 2026 21:49:31 -0700 Subject: [PATCH 32/38] fix(router): return model and Bedrock batch fields in deployment credentials get_deployment_credentials_with_provider dropped s3_region_name, s3_encryption_key_id, and aws_batch_role_arn because CredentialLiteLLMParams never declared them, and it never returned the deployment's model, so proxy batch creation against Bedrock failed with "LiteLLM doesn't support custom_llm_provider=bedrock for 'create_batch'" or "AWS IAM role ARN is required" (#25104) Provider-only file and batch calls keep their no-model contract: get_team_provider_credentials strips the model key so a provider-scoped request is not pinned to an arbitrary matching deployment --- .../openai_files_endpoints/common_utils.py | 2 +- litellm/router.py | 4 ++- litellm/types/router.py | 8 ++--- tests/test_litellm/test_router.py | 36 +++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 8 +++++ 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 080b8b80ae4..0f6494051cf 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -373,7 +373,7 @@ def get_team_provider_credentials( def _provider_credentials(model_id: str) -> dict | None: credentials: Final = llm_router.get_deployment_credentials_with_provider(model_id=model_id, team_id=team_id) if credentials is not None and credentials.get("custom_llm_provider") == custom_llm_provider: - return credentials + return {key: value for key, value in credentials.items() if key != "model"} return None # 1. Prefer the team's own BYOK deployment, matched by model_info.team_id. diff --git a/litellm/router.py b/litellm/router.py index c4a16521fa7..1edb80da7ce 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -8738,7 +8738,7 @@ class Router: Example: credentials = router.get_deployment_credentials_with_provider("gpt-4o-litellm") - # Returns: {"api_key": "sk-...", "custom_llm_provider": "openai", ...} + # Returns: {"api_key": "sk-...", "custom_llm_provider": "openai", "model": "gpt-4o", ...} """ # Try to get deployment by model_id first deployment = self.get_deployment(model_id=model_id) @@ -8797,6 +8797,8 @@ class Router: # Remove the credential name since we've resolved it credentials.pop("litellm_credential_name", None) + credentials["model"] = deployment.litellm_params.model + # Add custom_llm_provider if deployment.litellm_params.custom_llm_provider: credentials["custom_llm_provider"] = deployment.litellm_params.custom_llm_provider diff --git a/litellm/types/router.py b/litellm/types/router.py index 83757daa4dd..8b4b547bdcc 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -200,6 +200,9 @@ class CredentialLiteLLMParams(BaseModel): aws_bedrock_runtime_endpoint: str | None = None aws_bedrock_project_id: str | None = None s3_bucket_name: str | None = None + s3_region_name: str | None = None + s3_encryption_key_id: str | None = None + aws_batch_role_arn: str | None = None ## IBM WATSONX ## watsonx_region_name: str | None = None @@ -272,11 +275,6 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): quality_router_config: dict | None = None quality_router_default_model: str | None = None - # Batch/File API Params - s3_bucket_name: str | None = None - s3_encryption_key_id: str | None = None - gcs_bucket_name: str | None = None - # Vector Store Params vector_store_id: str | None = None milvus_text_field: str | None = None diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 4dec574b9f3..b910cc3c5fc 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -4024,6 +4024,42 @@ def test_get_deployment_credentials_with_provider_resolves_credential_name(): litellm.credential_list = [] +def test_get_deployment_credentials_with_provider_bedrock_batch_fields(): + """ + Test that get_deployment_credentials_with_provider returns the deployment's + model and the Bedrock batch/S3 fields (s3_region_name, s3_encryption_key_id, + aws_batch_role_arn) instead of silently dropping them (#25104). + """ + router = litellm.Router( + model_list=[ + { + "model_name": "bedrock-batch-model", + "litellm_params": { + "model": "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + "aws_region_name": "us-west-2", + "s3_bucket_name": "my-batch-bucket", + "s3_region_name": "us-east-1", + "s3_encryption_key_id": "arn:aws:kms:us-west-2:123:key/abc", + "aws_batch_role_arn": "arn:aws:iam::123:role/batch-role", + }, + } + ], + ) + + credentials = router.get_deployment_credentials_with_provider( + model_id="bedrock-batch-model" + ) + + assert credentials is not None + assert credentials["custom_llm_provider"] == "bedrock" + assert credentials["model"] == "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" + assert credentials["aws_region_name"] == "us-west-2" + assert credentials["s3_bucket_name"] == "my-batch-bucket" + assert credentials["s3_region_name"] == "us-east-1" + assert credentials["s3_encryption_key_id"] == "arn:aws:kms:us-west-2:123:key/abc" + assert credentials["aws_batch_role_arn"] == "arn:aws:iam::123:role/batch-role" + + def _team_wildcard_model(api_key: str, model_id: str = "team-wildcard-id") -> dict: return { "model_name": f"model_name_team-1_{model_id}", diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 0064b1a7d87..397d219b448 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -26716,6 +26716,8 @@ export interface components { auto_router_max_input_chars?: number | null; /** Aws Access Key Id */ aws_access_key_id?: string | null; + /** Aws Batch Role Arn */ + aws_batch_role_arn?: string | null; /** Aws Bedrock Project Id */ aws_bedrock_project_id?: string | null; /** Aws Bedrock Runtime Endpoint */ @@ -26949,6 +26951,8 @@ export interface components { s3_bucket_name?: string | null; /** S3 Encryption Key Id */ s3_encryption_key_id?: string | null; + /** S3 Region Name */ + s3_region_name?: string | null; /** Search Context Cost Per Query */ search_context_cost_per_query?: { [key: string]: unknown; @@ -35316,6 +35320,8 @@ export interface components { auto_router_max_input_chars?: number | null; /** Aws Access Key Id */ aws_access_key_id?: string | null; + /** Aws Batch Role Arn */ + aws_batch_role_arn?: string | null; /** Aws Bedrock Project Id */ aws_bedrock_project_id?: string | null; /** Aws Bedrock Runtime Endpoint */ @@ -35549,6 +35555,8 @@ export interface components { s3_bucket_name?: string | null; /** S3 Encryption Key Id */ s3_encryption_key_id?: string | null; + /** S3 Region Name */ + s3_region_name?: string | null; /** Search Context Cost Per Query */ search_context_cost_per_query?: { [key: string]: unknown; From 86890654c5b96bdada40fc8e35b2812d8fd61284 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Wed, 5 Aug 2026 22:33:54 -0700 Subject: [PATCH 33/38] fix(proxy): include today's UTC bucket when a daily activity range ends at the caller's current day (#36051) * fix(proxy): include today's UTC bucket when a daily activity range ends at the caller's current day * fix(proxy): gate the current-UTC-day extension behind an opt-in param sent by the cost optimization dashboard * fix(ui): label cost optimization savings dates as UTC days --- .../common_daily_activity.py | 55 +++++++++++----- .../internal_user_endpoints.py | 8 +++ .../test_common_daily_activity.py | 62 +++++++++++++++++++ .../_components/UsageTab.test.tsx | 4 +- .../_components/UsageTab.tsx | 3 +- .../useDailyActivityRange.test.tsx | 4 +- .../_components/useDailyActivityRange.ts | 2 +- .../src/components/networking.tsx | 2 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 + 9 files changed, 121 insertions(+), 21 deletions(-) diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 9af65b50c7f..7a30f6b799a 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -1,6 +1,6 @@ import asyncio from collections.abc import Awaitable, Callable, Mapping, Sequence -from datetime import datetime +from datetime import datetime, timedelta, timezone from types import SimpleNamespace from typing import TYPE_CHECKING, Final, Protocol @@ -422,26 +422,46 @@ def _adjust_dates_for_timezone( start_date: str, end_date: str, timezone_offset_minutes: int | None, + include_current_utc_day: bool = False, + utc_now: datetime | None = None, ) -> tuple[str, str]: """ - Pass-through for the local date range; the timezone offset is intentionally ignored here. + Map a caller-local date range onto UTC bucket keys, extending only the live end. The aggregation table (e.g. LiteLLM_DailyUserSpend) stores spend in whole-UTC-day - buckets keyed on date as YYYY-MM-DD. Any conversion from a local date range to a - UTC date range using only date arithmetic must round to whole UTC days, allowing up - to 24h of slop at each boundary. The previous implementation expanded the SQL range - by an extra full UTC day on whichever side the offset pointed, which pulled in 24h - of unrelated bucket data per boundary and produced approximately 100% over-counting - on single-day queries (e.g. IST May 29 returning UTC May 28 + UTC May 29 in full). + buckets keyed on date as YYYY-MM-DD. Any conversion of an interior local-day + boundary using only date arithmetic must round to whole UTC days, allowing up to + 24h of slop at each boundary. A previous implementation expanded the SQL range by + an extra full UTC day on whichever side the offset pointed, which pulled in 24h of + unrelated bucket data per boundary and produced approximately 100% over-counting on + single-day queries (e.g. IST May 29 returning UTC May 28 + UTC May 29 in full). Sums of single-day queries then exceeded the equivalent multi-day aggregate, which - is mathematically impossible. + is mathematically impossible. Historical dates therefore stay a pass-through: the + local date is the UTC bucket key, trading boundary slop for monotonic, additive + results. Hour-level buckets or pro-rata weighting would fix that properly; both + require data the current schema does not store. - Treating the local date as the UTC date trades a small one-time boundary slop for - correct, monotonic, additive results across single-day and multi-day queries. A - later fix can introduce hour-level buckets or pro-rata weighting on adjacent UTC - days; both require data the current schema does not store. + The end boundary is different when the range reaches the caller's current day. A + caller west of UTC asking for a range ending "today" is asking for data up to now, + but once UTC has rolled past their local midnight, everything they sent since then + sits in the next UTC bucket, which the pass-through excludes: a PT dashboard goes + stale every evening from 5pm until local midnight, showing $0 for anything that + only started accruing that evening. Extending such a range to today's UTC bucket + cannot over-count, because the only part of that bucket outside the caller's range + is the future, and the future is empty. ``timezone_offset_minutes`` follows the + JS ``Date.getTimezoneOffset`` convention: UTC minus local, positive west of UTC. + + The extension is strictly opt-in via ``include_current_utc_day`` so a consumer + whose axis or reconciliation expects the range to stop at the requested end date + keeps today's byte-for-byte behaviour; the cost optimization dashboard opts in. """ - return start_date, end_date + if not include_current_utc_day or timezone_offset_minutes is None: + return start_date, end_date + now: Final = utc_now if utc_now is not None else datetime.now(timezone.utc) + caller_local_today: Final = (now - timedelta(minutes=timezone_offset_minutes)).date().isoformat() + if end_date < caller_local_today: + return start_date, end_date + return start_date, max(end_date, now.date().isoformat()) def _build_where_conditions( @@ -454,10 +474,13 @@ def _build_where_conditions( api_key: str | list[str] | None, exclude_entity_ids: list[str] | None = None, timezone_offset_minutes: int | None = None, + include_current_utc_day: bool = False, ) -> dict[str, "_WhereValue"]: """Build prisma where clause for daily activity queries.""" # Adjust dates for timezone if provided - adjusted_start, adjusted_end = _adjust_dates_for_timezone(start_date, end_date, timezone_offset_minutes) + adjusted_start, adjusted_end = _adjust_dates_for_timezone( + start_date, end_date, timezone_offset_minutes, include_current_utc_day + ) where_conditions: Final[dict[str, _WhereValue]] = { "date": { @@ -903,6 +926,7 @@ async def get_daily_activity( exclude_entity_ids: list[str] | None = None, metadata_metrics_func: Callable[[Sequence[DailySpendRecord]], SpendMetrics] | None = None, timezone_offset_minutes: int | None = None, + include_current_utc_day: bool = False, resolve_entity_metadata: Callable[[Sequence[DailySpendRecord]], Awaitable[dict[str, dict[str, object]]]] | None = None, ) -> SpendAnalyticsPaginatedResponse: @@ -936,6 +960,7 @@ async def get_daily_activity( api_key=api_key, exclude_entity_ids=exclude_entity_ids, timezone_offset_minutes=timezone_offset_minutes, + include_current_utc_day=include_current_utc_day, ) # Get total count for pagination diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index dcec33f1cb2..640a735c916 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -2650,6 +2650,13 @@ async def get_user_daily_activity( description="Timezone offset in minutes from UTC (e.g., 480 for PST). " "Matches JavaScript's Date.getTimezoneOffset() convention.", ), + include_current_utc_day: bool = fastapi.Query( + default=False, + description="When the range ends on the caller's current local day, extend it to " + "today's UTC bucket so spend written after the caller's local midnight (in UTC " + "terms) is included. Requires the timezone parameter. Historical ranges are " + "never extended.", + ), user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ) -> SpendAnalyticsPaginatedResponse: """ @@ -2711,6 +2718,7 @@ async def get_user_daily_activity( page=page, page_size=page_size, timezone_offset_minutes=timezone, + include_current_utc_day=include_current_utc_day, resolve_entity_metadata=lambda records: _resolve_user_email_metadata(prisma_client, records), ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py index f2749be5d6e..469e0d340f0 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py @@ -1,6 +1,8 @@ import os import sys +from datetime import datetime, timezone from types import SimpleNamespace +from typing import Final from unittest.mock import AsyncMock, MagicMock import pytest @@ -870,6 +872,66 @@ class TestAdjustDatesForTimezone: assert per_day_ends == days +class TestAdjustDatesForTimezoneLiveEnd: + """ + Regression tests for the stale-evening bug: a caller west of UTC whose range + ends on their local "today" was capped at that local date's UTC bucket, so + once UTC rolled past their local midnight (5pm PT), everything sent that + evening sat in the next UTC bucket and the dashboard reported $0 for it + until local midnight. A range that reaches the caller's current day and + opts in via include_current_utc_day must extend to today's UTC bucket; the + only part of that bucket outside the range is the future, which is empty, + so the extension cannot over-count. Callers that do not opt in keep the + pass-through byte for byte. + """ + + PT_EVENING_UTC: Final = datetime(2026, 8, 6, 4, 30, tzinfo=timezone.utc) + + def test_pt_evening_range_ending_today_extends_to_utc_today(self): + start, end = _adjust_dates_for_timezone( + "2026-07-06", "2026-08-05", 420, include_current_utc_day=True, utc_now=self.PT_EVENING_UTC + ) + assert (start, end) == ("2026-07-06", "2026-08-06") + + def test_without_opt_in_live_range_keeps_pass_through(self): + start, end = _adjust_dates_for_timezone( + "2026-07-06", "2026-08-05", 420, utc_now=self.PT_EVENING_UTC + ) + assert (start, end) == ("2026-07-06", "2026-08-05") + + def test_pt_historical_range_is_untouched(self): + start, end = _adjust_dates_for_timezone( + "2026-07-01", "2026-08-04", 420, include_current_utc_day=True, utc_now=self.PT_EVENING_UTC + ) + assert (start, end) == ("2026-07-01", "2026-08-04") + + def test_east_of_utc_local_today_already_covers_utc_today(self): + ist_evening_utc: Final = datetime(2026, 8, 5, 17, 0, tzinfo=timezone.utc) + start, end = _adjust_dates_for_timezone( + "2026-07-07", "2026-08-06", -330, include_current_utc_day=True, utc_now=ist_evening_utc + ) + assert (start, end) == ("2026-07-07", "2026-08-06") + + def test_missing_offset_stays_pass_through_even_for_live_range(self): + start, end = _adjust_dates_for_timezone( + "2026-07-06", "2026-08-05", None, include_current_utc_day=True, utc_now=self.PT_EVENING_UTC + ) + assert (start, end) == ("2026-07-06", "2026-08-05") + + def test_utc_caller_range_ending_today_is_unchanged(self): + utc_noon: Final = datetime(2026, 8, 5, 12, 0, tzinfo=timezone.utc) + start, end = _adjust_dates_for_timezone( + "2026-07-06", "2026-08-05", 0, include_current_utc_day=True, utc_now=utc_noon + ) + assert (start, end) == ("2026-07-06", "2026-08-05") + + def test_future_end_date_extends_no_further_than_requested(self): + start, end = _adjust_dates_for_timezone( + "2026-07-06", "2026-08-09", 420, include_current_utc_day=True, utc_now=self.PT_EVENING_UTC + ) + assert (start, end) == ("2026-07-06", "2026-08-09") + + class TestBuildAggregatedSqlQuery: """ Asserts the SQL emitted by the aggregated query path stays anchored to the diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx index fe3e792eeee..96d4644804b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx @@ -209,9 +209,9 @@ describe("UsageTab", () => { it("says what the line means and over what range", async () => { const { getByText, getByRole } = renderWith(twoDays()); - expect(getByText("Running total saved · Jul 1 – Jul 14")).toBeInTheDocument(); + expect(getByText("Running total saved · Jul 1 – Jul 14 (UTC)")).toBeInTheDocument(); await userEvent.click(getByRole("tab", { name: "Per day" })); - expect(getByText("Saved per day · Jul 1 – Jul 14")).toBeInTheDocument(); + expect(getByText("Saved per day · Jul 1 – Jul 14 (UTC)")).toBeInTheDocument(); }); it("builds the per-driver donut from the range totals, not the running total", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx index b6287602210..bd9d4f3c873 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx @@ -141,7 +141,7 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { const rangeLabel = formatRangeLabel(startTime ?? undefined, endTime ?? undefined); const savingsSubtitle = [ accumulation === "cumulative" ? "Running total saved" : `Saved ${intervalLabel.toLowerCase()}`, - rangeLabel, + rangeLabel && `${rangeLabel} (UTC)`, ] .filter(Boolean) .join(" \u00b7 "); @@ -179,6 +179,7 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { return (
+ Spend is bucketed by UTC day
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx index 9fd27d80c37..e26a3629e8c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx @@ -22,13 +22,13 @@ describe("useDailyActivityRange", () => { it("queries every user's activity for an admin", () => { renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin")); - expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), null]); + expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), null, true]); }); it("scopes the query to the caller for a non-admin", () => { renderHook(() => useDailyActivityRange("test-token", "u1", "internal_user")); - expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), "u1"]); + expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), "u1", true]); }); it("stays disabled until an access token is available", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts index 1c3f706726e..3a2a38c5955 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts @@ -35,7 +35,7 @@ export const useDailyActivityRange = ( const { data, loading, isFetchingMore } = usePaginatedDailyActivity({ fetchFn: userDailyActivityCall, - args: [accessToken, startTime, endTime, effectiveUserId], + args: [accessToken, startTime, endTime, effectiveUserId, true], enabled: !!accessToken && !!startTime && !!endTime, }); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 03106528a80..17a5ca37990 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1388,6 +1388,7 @@ export const userDailyActivityCall = async ( endTime: Date, page: number = 1, userId: string | null = null, + includeCurrentUtcDay: boolean = false, ) => { /** * Get daily user activity on proxy @@ -1400,6 +1401,7 @@ export const userDailyActivityCall = async ( page, extraQueryParams: { user_id: userId, + include_current_utc_day: includeCurrentUtcDay ? "true" : undefined, }, }); }; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 0064b1a7d87..b67083ea90f 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -53883,6 +53883,8 @@ export interface operations { page_size?: number; /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ timezone?: number | null; + /** @description When the range ends on the caller's current local day, extend it to today's UTC bucket so spend written after the caller's local midnight (in UTC terms) is included. Requires the timezone parameter. Historical ranges are never extended. */ + include_current_utc_day?: boolean; }; header?: never; path?: never; From 34fc8d2ee71f60b15cb097c90ca1649cc6377076 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Wed, 5 Aug 2026 22:34:55 -0700 Subject: [PATCH 34/38] fix: expired-miss share over all measured turns + cost-optimization tab labels (#36037) * fix(ui): make the expired-miss stat row a focusable tooltip trigger * fix: auto-router expired-miss percentage and cost-optimization tab labels - change expired-miss percentage denominator from return-to-tier misses to all measured turns (same_model + first_visit + return_to_tier). when auto-routers flip tiers rapidly within TTL, return-to-tier turns become hits and disappear from the miss count; the old metric reported only the rare failure population. the new metric contextualizes that population as a share of overall coverage - rename usage tab from 'Usage' to 'Overall' - rename auto-router-usage tab from 'Auto-Router Usage' to 'Auto-Router' - update component and unit tests to match new semantics --- .../AutoRouterBenchmarksTab.test.tsx | 30 +++++++++++++-- .../_components/AutoRouterBenchmarksTab.tsx | 38 ++++++++++--------- .../_components/CostOptimizationView.test.tsx | 10 ++--- .../_components/CostOptimizationView.tsx | 4 +- .../_components/autoRouterBenchmarks.test.ts | 21 ++++++++-- .../_components/autoRouterBenchmarks.ts | 6 +-- 6 files changed, 75 insertions(+), 34 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx index 51e9e125cb9..a5767383307 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx @@ -155,21 +155,45 @@ describe("AutoRouterBenchmarksTab", () => { expect(screen.getByText(/turns measured/)).toBeInTheDocument(); }); - it("recomputes the expired-miss share from the miss counts", () => { + it("computes the expired-miss share over every measured turn, not just return-to-tier misses", () => { mockHook({ data: response([group()]) }); renderTab(); expect(screen.getByText("Expired-miss")).toBeInTheDocument(); - expect(screen.getByText("27.1%")).toBeInTheDocument(); + expect(screen.getByText("2.3%")).toBeInTheDocument(); }); - it("hides the expired-miss row when every return turn hit", () => { + it("exposes the whole expired-miss row as a focusable tooltip trigger", () => { + mockHook({ data: response([group()]) }); + renderTab(); + + const trigger = screen.getByRole("button", { name: /Expired-miss/ }); + expect(trigger).toHaveTextContent("2.3%"); + }); + + it("shows a zero expired-miss share, rather than hiding the row, when every return turn hit", () => { const allHits = totals({ cache: cache({ return_to_tier: { turns: 381, hits: 381, hit_rate_pct: 100 }, return_misses_expired: 0 }), }); mockHook({ data: response([group(allHits)], allHits) }); renderTab(); + const trigger = screen.getByRole("button", { name: /Expired-miss/ }); + expect(trigger).toHaveTextContent("0.0%"); + }); + + it("hides the expired-miss row only when no turns were measured at all", () => { + const empty = { turns: 0, hits: 0, hit_rate_pct: 0 }; + const nothingMeasured = { + same_model: empty, + first_visit: empty, + return_to_tier: empty, + return_misses_expired: 0, + }; + const noTurns = totals({ cache: cache(nothingMeasured) }); + mockHook({ data: response([group(noTurns)], noTurns) }); + renderTab(); + expect(screen.queryByText("Expired-miss")).not.toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx index 2b71c36c597..ff0f52940b2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx @@ -183,23 +183,27 @@ const CachingCard: React.FC<{ cache: AutoRouterCacheStats }> = ({ cache }) => {

{pctLabel(cache.hit_rate_pct)}

{expiredMissPct === null ? null : ( -
- - - - Expired-miss -

- } - /> - - percentage of return-to-tier cache misses caused by cache expiring - -
-
-

{pctLabel(expiredMissPct)}

-
+ + + + } + > + + Expired-miss + + {pctLabel(expiredMissPct)} + + + share of all measured turns that missed cache because a return to an earlier tier came after its TTL + lapsed + + + )}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx index 96ef75e8dd1..33c64ecf18a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx @@ -17,21 +17,21 @@ describe("CostOptimizationView", () => { it("renders the four cost-optimization tabs", () => { const { getByText } = renderView(); - expect(getByText("Usage")).toBeInTheDocument(); + expect(getByText("Overall")).toBeInTheDocument(); expect(getByText("Prompt Compression")).toBeInTheDocument(); expect(getByText("Prompt Caching")).toBeInTheDocument(); - expect(getByText("Auto-Router Usage")).toBeInTheDocument(); + expect(getByText("Auto-Router")).toBeInTheDocument(); }); - it("defaults to the Usage tab and switches the active tab on click", () => { + it("defaults to the Overall tab and switches the active tab on click", () => { const { getByRole } = renderView(); - expect(getByRole("tab", { name: "Usage" })).toHaveAttribute("aria-selected", "true"); + expect(getByRole("tab", { name: "Overall" })).toHaveAttribute("aria-selected", "true"); expect(getByRole("tab", { name: "Prompt Compression" })).toHaveAttribute("aria-selected", "false"); fireEvent.click(getByRole("tab", { name: "Prompt Compression" })); - expect(getByRole("tab", { name: "Usage" })).toHaveAttribute("aria-selected", "false"); + expect(getByRole("tab", { name: "Overall" })).toHaveAttribute("aria-selected", "false"); expect(getByRole("tab", { name: "Prompt Compression" })).toHaveAttribute("aria-selected", "true"); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx index 0d986fdcaa8..6af1e8d0441 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx @@ -22,7 +22,7 @@ const CostOptimizationView: React.FC = ({ accessToken const items = [ { key: "usage", - label: "Usage", + label: "Overall", children: , }, { @@ -37,7 +37,7 @@ const CostOptimizationView: React.FC = ({ accessToken }, { key: "autorouter-usage", - label: "Auto-Router Usage", + label: "Auto-Router", children: , }, ]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.test.ts index 201a71ae4be..57c059b5524 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.test.ts @@ -131,12 +131,25 @@ describe("bucketRows", () => { }); describe("expiredMissShare", () => { - it("recomputes the expired share from the miss counts", () => { - expect(expiredMissShare(cache())).toBeCloseTo((100 * 19) / 70); + it("computes the expired share over every measured turn, not just return-to-tier misses", () => { + expect(expiredMissShare(cache())).toBeCloseTo((100 * 19) / 818); }); - it("is absent when every return turn hit", () => { - expect(expiredMissShare(cache({ return_to_tier: { turns: 10, hits: 10, hit_rate_pct: 100 } }))).toBeNull(); + it("is zero, not absent, when every return turn hit", () => { + expect( + expiredMissShare(cache({ return_to_tier: { turns: 10, hits: 10, hit_rate_pct: 100 }, return_misses_expired: 0 })), + ).toBe(0); + }); + + it("is absent only when no turns were measured at all", () => { + const empty = { turns: 0, hits: 0, hit_rate_pct: 0 }; + const nothingMeasured = { + same_model: empty, + first_visit: empty, + return_to_tier: empty, + return_misses_expired: 0, + }; + expect(expiredMissShare(cache(nothingMeasured))).toBeNull(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.ts index 8b6a4fa4105..00793548278 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.ts @@ -91,9 +91,9 @@ export const bucketRows = (cache: AutoRouterCacheStats): BucketRow[] => { }; export const expiredMissShare = (cache: AutoRouterCacheStats): number | null => { - const misses = cache.return_to_tier.turns - cache.return_to_tier.hits; - if (misses <= 0) return null; - return (100 * cache.return_misses_expired) / misses; + const total = bucketTurnsTotal(cache); + if (total <= 0) return null; + return (100 * cache.return_misses_expired) / total; }; export const pctLabel = (value: number, digits: number = 1): string => `${value.toFixed(digits)}%`; From 4f7d1fce3a1b6f469c9809140c824d83895c535e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:13:15 -0700 Subject: [PATCH 35/38] fix(proxy): fall back to the SDK when a queued response's deployment is missing --- .../common_utils/check_responses_cost.py | 2 +- .../test_check_responses_cost.py | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py index 5a587de12e9..27837b0b5e4 100644 --- a/enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py +++ b/enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py @@ -52,7 +52,7 @@ class CheckResponsesCost: live in the config; the row then never leaves ``queued``. """ model_id: Optional[str] = ResponsesAPIRequestUtils.get_model_id_from_response_id(response_id) - if model_id is None: + if model_id is None or self.llm_router.get_deployment(model_id=model_id) is None: return await litellm.aget_responses(response_id=response_id, litellm_metadata=litellm_metadata) router_response = await self.llm_router.aget_responses( response_id=response_id, litellm_metadata=litellm_metadata diff --git a/tests/proxy_unit_tests/test_check_responses_cost.py b/tests/proxy_unit_tests/test_check_responses_cost.py index 16ad5c07919..1faf8692b46 100644 --- a/tests/proxy_unit_tests/test_check_responses_cost.py +++ b/tests/proxy_unit_tests/test_check_responses_cost.py @@ -627,6 +627,65 @@ class TestCheckResponsesCost: mock_sdk_aget.assert_called_once() mock_llm_router.aget_responses.assert_not_called() + @pytest.mark.asyncio + async def test_missing_deployment_falls_back_to_sdk( + self, check_responses_cost_instance, mock_prisma_client, mock_llm_router + ): + """ + An encoded id whose deployment was removed from the router must fall back + to the SDK so provider env credentials can still retrieve it, instead of + failing every poll cycle until stale expiration. + """ + from litellm.responses.utils import ResponsesAPIRequestUtils + + encoded_response_id = ResponsesAPIRequestUtils._build_responses_api_response_id( + custom_llm_provider="openai", + model_id="deployment-deleted", + response_id="resp_upstream_789", + ) + + mock_job = MagicMock() + mock_job.unified_object_id = encoded_response_id + mock_job.created_by = "test-user" + mock_job.id = "job-missing-deployment" + mock_job.file_object = {"model": "gpt-5", "id": encoded_response_id} + + mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock( + return_value=[mock_job] + ) + mock_prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock( + return_value=0 + ) + mock_llm_router.get_deployment = MagicMock(return_value=None) + mock_llm_router.aget_responses = AsyncMock( + side_effect=AssertionError("router has no deployment for this model_id") + ) + + mock_response = ResponsesAPIResponse( + id=encoded_response_id, + object="response", + status="completed", + created_at=int(datetime.now().timestamp()), + output=[], + usage=None, + ) + + with patch("litellm.aget_responses", new_callable=AsyncMock) as mock_sdk_aget: + mock_sdk_aget.return_value = mock_response + await check_responses_cost_instance.check_responses_cost() + + mock_llm_router.get_deployment.assert_called_once_with(model_id="deployment-deleted") + mock_llm_router.aget_responses.assert_not_called() + mock_sdk_aget.assert_called_once() + assert mock_sdk_aget.call_args[1]["response_id"] == encoded_response_id + + calls = ( + mock_prisma_client.db.litellm_managedobjecttable.update_many.call_args_list + ) + assert len(calls) == 1 + assert calls[0][1]["data"]["status"] == "completed" + assert calls[0][1]["where"]["id"]["in"] == ["job-missing-deployment"] + @pytest.mark.asyncio async def test_check_responses_cost_with_incomplete_response( self, check_responses_cost_instance, mock_prisma_client From 7d745521bf94331cc5e0ec069e9c2f7d8d443303 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:46:40 -0700 Subject: [PATCH 36/38] fix(guardrails): merge synthesized tools under scan_only_tool_results and reject role-filtered no-op combos at init --- litellm/integrations/custom_guardrail.py | 10 +++ .../chat/guardrail_translation/handler.py | 14 +++- .../base_llm/guardrail_translation/utils.py | 43 +++++++++++- .../chat/guardrail_translation/handler.py | 14 +++- .../guardrail_hooks/bedrock_guardrails.py | 3 + .../panw_prisma_airs/panw_prisma_airs.py | 3 + .../proxy/guardrails/guardrail_registry.py | 12 ++++ .../test_anthropic_guardrail_handler.py | 8 ++- .../test_openai_guardrail_handler.py | 68 +++++++++++++++++-- .../guardrails/test_guardrail_registry.py | 57 ++++++++++++++++ 10 files changed, 216 insertions(+), 16 deletions(-) diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 20f3aa430e9..7c8c9aeb248 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -714,6 +714,16 @@ class CustomGuardrail(CustomLogger): return result + def supports_scan_only_tool_results(self) -> bool: + """Whether this guardrail can scan tool-result content. + + Guardrails whose own role filtering only ever scans human-authored + messages override this to return False, so configuring them with + ``scan_only_tool_results`` is rejected at initialization instead of + silently scanning nothing on every request. + """ + return True + def should_run_guardrail( self, data, diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index 184e0f6a343..aef01765e6e 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -26,10 +26,12 @@ from litellm.llms.anthropic.experimental_pass_through.adapters.transformation im ) from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation from litellm.llms.base_llm.guardrail_translation.utils import ( + anthropic_tool_name, effective_scan_only_tool_results_for_guardrail, effective_skip_system_message_for_guardrail, effective_skip_tool_message_for_guardrail, merge_guardrailed_scoped_messages, + merge_returned_tools_into_request_tools, scoped_structured_message_indices, ) from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import ( @@ -387,7 +389,7 @@ class AnthropicMessagesHandler(BaseTranslation): guardrailed_texts: Final = guardrailed_inputs.get("texts", []) guardrailed_tools: Final = guardrailed_inputs.get("tools") - if guardrailed_tools is not None and not scan_only_tool_results: + if guardrailed_tools is not None: # Convert tools back from OpenAI format to Anthropic format anthropic_config: Final = AnthropicConfig() anthropic_tools: Final[list[AllAnthropicToolsValues]] = [] @@ -396,7 +398,15 @@ class AnthropicMessagesHandler(BaseTranslation): if converted_tool is not None: anthropic_tools.append(converted_tool) # Note: MCP servers are handled separately in the main transformation - data["tools"] = anthropic_tools + data["tools"] = ( + merge_returned_tools_into_request_tools( + request_tools=data.get("tools"), + returned_tools=anthropic_tools, + tool_name=anthropic_tool_name, + ) + if scan_only_tool_results + else anthropic_tools + ) guardrailed_structured_messages: Final = guardrailed_inputs.get("structured_messages") if ( diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index 432ac64b456..bdfe15ca9a6 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -1,8 +1,8 @@ from __future__ import annotations import json -from collections.abc import Iterator, Sequence -from typing import Any, Final +from collections.abc import Callable, Iterator, Sequence +from typing import Any, Final, TypeVar from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicUsage from litellm.types.llms.openai import AllMessageValues @@ -167,6 +167,45 @@ def scoped_structured_message_indices( ) +ToolT = TypeVar("ToolT") + + +def openai_tool_name(tool: object) -> str | None: + if not isinstance(tool, dict): + return None + function: Final = tool.get("function") + if isinstance(function, dict): + function_name: Final = function.get("name") + return function_name if isinstance(function_name, str) else None + flat_name: Final = tool.get("name") + return flat_name if isinstance(flat_name, str) else None + + +def anthropic_tool_name(tool: object) -> str | None: + name: Final = tool.get("name") if isinstance(tool, dict) else None + return name if isinstance(name, str) else None + + +def merge_returned_tools_into_request_tools( + request_tools: Sequence[ToolT] | None, + returned_tools: Sequence[ToolT], + tool_name: Callable[[ToolT], str | None], +) -> list[ToolT]: + """Union of the request's tools and guardrail-returned tools, keyed by name. + + Under ``scan_only_tool_results`` the guardrail never saw the request's + tools, so a returned list can neither replace them (it would drop every + user-defined function) nor be discarded (it may carry a tool the guardrail + synthesized and told the model to call, like Compresr's retrieve tool). + Keep every request tool and append only returned tools whose names aren't + already taken. + """ + originals: Final = tuple(request_tools or ()) + taken_names: Final = frozenset(name for tool in originals if (name := tool_name(tool)) is not None) + additions: Final = tuple(tool for tool in returned_tools if tool_name(tool) not in taken_names) + return [*originals, *additions] + + def merge_guardrailed_scoped_messages( full_messages: Sequence[AllMessageValues], scoped_indices: Sequence[int], diff --git a/litellm/llms/openai/chat/guardrail_translation/handler.py b/litellm/llms/openai/chat/guardrail_translation/handler.py index dc2a06d67fc..4f0f69866ad 100644 --- a/litellm/llms/openai/chat/guardrail_translation/handler.py +++ b/litellm/llms/openai/chat/guardrail_translation/handler.py @@ -27,6 +27,8 @@ from litellm.llms.base_llm.guardrail_translation.utils import ( effective_skip_system_message_for_guardrail, effective_skip_tool_message_for_guardrail, merge_guardrailed_scoped_messages, + merge_returned_tools_into_request_tools, + openai_tool_name, role_out_of_guardrail_scope, scoped_structured_message_indices, ) @@ -143,8 +145,16 @@ class OpenAIChatCompletionsHandler(BaseTranslation): guardrailed_texts: Final = guardrailed_inputs.get("texts", []) guardrailed_tool_calls: Final = guardrailed_inputs.get("tool_calls", []) guardrailed_tools: Final = guardrailed_inputs.get("tools") - if guardrailed_tools is not None and not scan_only_tool_results: - data["tools"] = guardrailed_tools + if guardrailed_tools is not None: + data["tools"] = ( + merge_returned_tools_into_request_tools( + request_tools=tools, + returned_tools=guardrailed_tools, + tool_name=openai_tool_name, + ) + if scan_only_tool_results + else guardrailed_tools + ) guardrailed_structured_messages: Final = guardrailed_inputs.get("structured_messages") if ( diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 8193069fd82..e9e729fb118 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -405,6 +405,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): grounding.append(block) return grounding + def supports_scan_only_tool_results(self) -> bool: + return self.experimental_use_latest_role_message_only is not True + def _prepare_guardrail_messages_for_role( self, messages: list[AllMessageValues] | None, diff --git a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py index a96a0070eef..13ced0ac06c 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py +++ b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py @@ -1564,6 +1564,9 @@ class PanwPrismaAirsHandler(CustomGuardrail): return scannable + def supports_scan_only_tool_results(self) -> bool: + return False + @staticmethod def _get_scannable_text_indices( texts: list[str], diff --git a/litellm/proxy/guardrails/guardrail_registry.py b/litellm/proxy/guardrails/guardrail_registry.py index e9e61283c1a..15e884c939c 100644 --- a/litellm/proxy/guardrails/guardrail_registry.py +++ b/litellm/proxy/guardrails/guardrail_registry.py @@ -14,6 +14,9 @@ from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.llms.base_llm.guardrail_translation.utils import ( + effective_scan_only_tool_results_for_guardrail, +) from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import ( BedrockGuardrail, ) @@ -493,6 +496,15 @@ class InMemoryGuardrailHandler: "scan_only_tool_results", ): setattr(custom_guardrail_callback, scoping_param, getattr(litellm_params, scoping_param, None)) + if ( + effective_scan_only_tool_results_for_guardrail(custom_guardrail_callback) + and not custom_guardrail_callback.supports_scan_only_tool_results() + ): + raise ValueError( + f"Guardrail {guardrail['guardrail_name']}: scan_only_tool_results is enabled, but this " + "guardrail's role filtering never scans tool results, so no request content would ever " + "be scanned. Remove scan_only_tool_results or the guardrail's role-filtering option." + ) configured_run_in_parallel: Final = getattr(litellm_params, "run_in_parallel", None) if configured_run_in_parallel is not None: custom_guardrail_callback.run_in_parallel = bool(configured_run_in_parallel) diff --git a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py index c7dedff0663..c7a30f7f954 100644 --- a/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py @@ -883,7 +883,7 @@ class TestAnthropicMessagesScanOnlyToolResults: assert data["messages"][2]["content"][0]["text"] == "sibling POISON text" @pytest.mark.asyncio - async def test_guardrail_synthesized_tools_never_replace_scoped_out_request_tools(self): + async def test_guardrail_synthesized_tools_are_appended_without_replacing_request_tools(self): handler = AnthropicMessagesHandler() guardrail = ToolAppendingGuardrail(guardrail_name="tool-appending") guardrail.scan_only_tool_results = True @@ -912,9 +912,11 @@ class TestAnthropicMessagesScanOnlyToolResults: await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) - assert data["tools"] == original_tools, ( - "tools the guardrail synthesized without seeing the request's tools must not replace them" + assert [t["name"] for t in data["tools"]] == ["get_weather", "injected_tool"], ( + "a tool the guardrail synthesized must reach the model, converted to Anthropic format, " + "without the request's own tools being replaced or dropped" ) + assert data["tools"][0] == original_tools[0] @pytest.mark.asyncio async def test_guardrail_is_not_called_when_the_request_has_no_tool_results(self): diff --git a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py index 269afef69cd..deabee12497 100644 --- a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py @@ -1278,6 +1278,35 @@ class ToolSynthesizingGuardrail(CustomGuardrail): return inputs +class ToolNameCollidingGuardrail(CustomGuardrail): + """Returns a tool reusing a request tool's name plus a genuinely new tool.""" + + def __init__(self): + super().__init__(guardrail_name="tool-name-colliding") + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[Any] = None, + ) -> GenericGuardrailAPIInputs: + inputs["tools"] = [ + { + "type": "function", + "function": { + "name": "read_file", + "parameters": {"type": "object", "properties": {"hijacked": {"type": "string"}}}, + }, + }, + { + "type": "function", + "function": {"name": "injected_retrieve", "parameters": {"type": "object", "properties": {}}}, + }, + ] + return inputs + + class TestScanOnlyToolResults: def _bedrock_guardrail(self): from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import BedrockGuardrail @@ -1375,7 +1404,9 @@ class TestScanOnlyToolResults: @pytest.mark.parametrize("scan_only_tool_results", [True, False]) @pytest.mark.asyncio - async def test_guardrail_synthesized_tools_never_replace_scoped_out_request_tools(self, scan_only_tool_results): + async def test_guardrail_synthesized_tools_are_appended_without_replacing_request_tools( + self, scan_only_tool_results + ): handler = OpenAIChatCompletionsHandler() guardrail = ToolSynthesizingGuardrail() guardrail.scan_only_tool_results = scan_only_tool_results @@ -1395,12 +1426,35 @@ class TestScanOnlyToolResults: await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) - if scan_only_tool_results: - assert data["tools"] == original_tools, ( - "tools the guardrail synthesized without seeing the request's tools must not replace them" - ) - else: - assert [t["function"]["name"] for t in data["tools"]] == ["read_file", "injected_retrieve"] + assert [t["function"]["name"] for t in data["tools"]] == ["read_file", "injected_retrieve"], ( + "a tool the guardrail synthesized (like a recovery/retrieve tool) must reach the model " + "without the request's own tools being replaced or dropped" + ) + assert data["tools"][0] == original_tools[0] + + @pytest.mark.asyncio + async def test_returned_tool_name_collisions_keep_the_request_schema(self): + handler = OpenAIChatCompletionsHandler() + guardrail = ToolNameCollidingGuardrail() + guardrail.scan_only_tool_results = True + original_read_file = { + "type": "function", + "function": {"name": "read_file", "parameters": {"type": "object", "properties": {}}}, + } + data = { + "messages": [ + {"role": "user", "content": "read the report"}, + {"role": "tool", "tool_call_id": "call_1", "content": "TOOL-RESULT"}, + ], + "tools": [original_read_file], + } + + await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) + + assert [t["function"]["name"] for t in data["tools"]] == ["read_file", "injected_retrieve"] + assert data["tools"][0] == original_read_file, ( + "a returned tool reusing a request tool's name must not replace the request's schema" + ) @pytest.mark.asyncio async def test_structured_write_back_keeps_out_of_scope_messages(self): diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py b/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py index 6bd109f0f95..3053664ef27 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py @@ -558,3 +558,60 @@ def test_reinitialized_judge_guardrail_uses_lazy_router_provider(): finally: for cb_list, snapshot in zip(lists, snapshots): cb_list[:] = snapshot + + +class TestScanOnlyToolResultsInitRefusal: + """A guardrail whose role filtering never scans tool results must be rejected at + initialization when configured with scan_only_tool_results, instead of booting a + proxy that silently scans nothing on every request.""" + + def _initialize(self, name: str, params: dict): + lists = _all_callback_lists() + snapshots = [list(cb_list) for cb_list in lists] + try: + return InMemoryGuardrailHandler().initialize_guardrail( + guardrail={"guardrail_name": name, "litellm_params": params}, + ) + finally: + for cb_list, snapshot in zip(lists, snapshots): + cb_list[:] = snapshot + + def test_panw_prisma_airs_with_scan_only_tool_results_is_rejected(self): + with pytest.raises(ValueError, match="never scans tool results"): + self._initialize( + "panw-scan-only-combo", + { + "guardrail": "panw_prisma_airs", + "mode": "pre_call", + "api_key": "test-key", + "profile_name": "test-profile", + "scan_only_tool_results": True, + }, + ) + + def test_bedrock_latest_role_with_scan_only_tool_results_is_rejected(self): + with pytest.raises(ValueError, match="never scans tool results"): + self._initialize( + "bedrock-latest-role-scan-only-combo", + { + "guardrail": "bedrock", + "mode": "pre_call", + "guardrailIdentifier": "gr-1", + "guardrailVersion": "1", + "experimental_use_latest_role_message_only": True, + "scan_only_tool_results": True, + }, + ) + + def test_bedrock_without_latest_role_accepts_scan_only_tool_results(self): + result = self._initialize( + "bedrock-scan-only-ok", + { + "guardrail": "bedrock", + "mode": "pre_call", + "guardrailIdentifier": "gr-1", + "guardrailVersion": "1", + "scan_only_tool_results": True, + }, + ) + assert result is not None From 28ff7f3f0b68d77428f175e54099b9b590226081 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:52:16 -0700 Subject: [PATCH 37/38] fix(guardrails): scan function-role results and dedupe returned tools Under scan_only_tool_results, legacy OpenAI function-role messages now count as tool results, and duplicate names among guardrail-returned tools keep only the first occurrence. CustomGuardrail.structured_messages_cover_full_request lets CrowdStrike AIDR declare that its writeback already rebuilds the whole conversation, so handlers install it as-is instead of merging it into the full message list a second time and duplicating out-of-scope rows. Lint budget ceilings ratchet down to match the tree --- basedpyright-code-budget.json | 18 ++--- litellm/integrations/custom_guardrail.py | 13 ++++ .../chat/guardrail_translation/handler.py | 4 +- .../base_llm/guardrail_translation/utils.py | 11 ++- .../chat/guardrail_translation/handler.py | 12 ++- .../crowdstrike_aidr/crowdstrike_aidr.py | 4 + ruff-strict-budget.json | 2 +- .../test_openai_guardrail_handler.py | 78 +++++++++++++++++++ type-discipline-budget.json | 6 +- 9 files changed, 127 insertions(+), 21 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 8a5c78c1f6c..d98e6c6c911 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -9,10 +9,10 @@ "limit": 329 }, "reportAttributeAccessIssue": { - "limit": 516 + "limit": 514 }, "reportCallIssue": { - "limit": 123 + "limit": 117 }, "reportConstantRedefinition": { "limit": 40 @@ -24,7 +24,7 @@ "limit": 19 }, "reportExplicitAny": { - "limit": 9226 + "limit": 9225 }, "reportFunctionMemberAccess": { "limit": 7 @@ -99,25 +99,25 @@ "limit": 0 }, "reportUnknownArgumentType": { - "limit": 45242 + "limit": 45145 }, "reportUnknownLambdaType": { "limit": 113 }, "reportUnknownMemberType": { - "limit": 40339 + "limit": 39881 }, "reportUnknownParameterType": { - "limit": 20293 + "limit": 20258 }, "reportUnknownVariableType": { - "limit": 31796 + "limit": 31429 }, "reportUnnecessaryCast": { "limit": 122 }, "reportUnnecessaryComparison": { - "limit": 702 + "limit": 701 }, "reportUnnecessaryContains": { "limit": 5 @@ -126,7 +126,7 @@ "limit": 864 }, "reportUntypedBaseClass": { - "limit": 72 + "limit": 0 }, "reportUntypedFunctionDecorator": { "limit": 33 diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 7c8c9aeb248..2e91e082bd4 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -724,6 +724,19 @@ class CustomGuardrail(CustomLogger): """ return True + def structured_messages_cover_full_request(self) -> bool: + """Whether returned ``structured_messages`` span the whole request. + + Translation handlers hand guardrails only the in-scope subset of the + conversation and merge a returned ``structured_messages`` list back + into the full request. A guardrail that already rebuilds the complete + conversation itself (like CrowdStrike AIDR with its skip filters + active) overrides this to return True so the handler installs the + returned list as-is instead of merging it a second time, which would + duplicate the out-of-scope messages. + """ + return False + def should_run_guardrail( self, data, diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index aef01765e6e..88db9fae912 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -415,7 +415,9 @@ class AnthropicMessagesHandler(BaseTranslation): ): self._write_back_structured_messages( data, - merge_guardrailed_scoped_messages( + guardrailed_structured_messages + if guardrail_to_apply.structured_messages_cover_full_request() + else merge_guardrailed_scoped_messages( full_messages=full_structured_messages, scoped_indices=scoped_message_indices, guardrailed_scoped=guardrailed_structured_messages, diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index bdfe15ca9a6..f1ddf21cd3c 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -145,7 +145,7 @@ def role_out_of_guardrail_scope( return True if skip_tool_message and role == "tool": return True - return scan_only_tool_results and role != "tool" + return scan_only_tool_results and role not in ("tool", "function") def scoped_structured_message_indices( @@ -198,11 +198,16 @@ def merge_returned_tools_into_request_tools( user-defined function) nor be discarded (it may carry a tool the guardrail synthesized and told the model to call, like Compresr's retrieve tool). Keep every request tool and append only returned tools whose names aren't - already taken. + already taken by a request tool or an earlier returned tool. """ originals: Final = tuple(request_tools or ()) taken_names: Final = frozenset(name for tool in originals if (name := tool_name(tool)) is not None) - additions: Final = tuple(tool for tool in returned_tools if tool_name(tool) not in taken_names) + additions: Final = tuple( + tool + for index, tool in enumerate(returned_tools) + if (name := tool_name(tool)) not in taken_names + and (name is None or all(tool_name(earlier) != name for earlier in returned_tools[:index])) + ) return [*originals, *additions] diff --git a/litellm/llms/openai/chat/guardrail_translation/handler.py b/litellm/llms/openai/chat/guardrail_translation/handler.py index 4f0f69866ad..e411dc497fc 100644 --- a/litellm/llms/openai/chat/guardrail_translation/handler.py +++ b/litellm/llms/openai/chat/guardrail_translation/handler.py @@ -161,10 +161,14 @@ class OpenAIChatCompletionsHandler(BaseTranslation): guardrailed_structured_messages is not None and guardrailed_structured_messages is not original_structured_messages ): - data["messages"] = merge_guardrailed_scoped_messages( - full_messages=structured_messages or [], - scoped_indices=scoped_message_indices, - guardrailed_scoped=guardrailed_structured_messages, + data["messages"] = ( + guardrailed_structured_messages + if guardrail_to_apply.structured_messages_cover_full_request() + else merge_guardrailed_scoped_messages( + full_messages=structured_messages or [], + scoped_indices=scoped_message_indices, + guardrailed_scoped=guardrailed_structured_messages, + ) ) else: # Step 3: Map guardrail responses back to original message structure diff --git a/litellm/proxy/guardrails/guardrail_hooks/crowdstrike_aidr/crowdstrike_aidr.py b/litellm/proxy/guardrails/guardrail_hooks/crowdstrike_aidr/crowdstrike_aidr.py index 15ddd5e3458..b1bf9159607 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/crowdstrike_aidr/crowdstrike_aidr.py +++ b/litellm/proxy/guardrails/guardrail_hooks/crowdstrike_aidr/crowdstrike_aidr.py @@ -362,6 +362,10 @@ class CrowdStrikeAIDRHandler(CustomGuardrail): tail: Final = guard_output.messages[-num_assistant_messages:] if num_assistant_messages > 0 else [] return [_extract_text_from_message(msg) for msg in tail] + @override + def structured_messages_cover_full_request(self) -> bool: + return effective_skip_system_message_for_guardrail(self) or effective_skip_tool_message_for_guardrail(self) + def _writeback_messages( self, structured_messages: list[AllMessageValues], diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index ea20ac97e07..65c98f6aab3 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -42,7 +42,7 @@ "limit": 81 }, "B010": { - "limit": 192 + "limit": 190 }, "B018": { "limit": 2 diff --git a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py index deabee12497..2e75f29b1c5 100644 --- a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py @@ -1307,6 +1307,38 @@ class ToolNameCollidingGuardrail(CustomGuardrail): return inputs +class DuplicateToolReturningGuardrail(CustomGuardrail): + """Returns the same synthesized tool name twice, second copy with a different schema.""" + + def __init__(self): + super().__init__(guardrail_name="duplicate-tool-returning") + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[Any] = None, + ) -> GenericGuardrailAPIInputs: + inputs["tools"] = [ + { + "type": "function", + "function": { + "name": "injected_retrieve", + "parameters": {"type": "object", "properties": {"first": {"type": "string"}}}, + }, + }, + { + "type": "function", + "function": { + "name": "injected_retrieve", + "parameters": {"type": "object", "properties": {"second": {"type": "string"}}}, + }, + }, + ] + return inputs + + class TestScanOnlyToolResults: def _bedrock_guardrail(self): from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import BedrockGuardrail @@ -1351,6 +1383,28 @@ class TestScanOnlyToolResults: scanned = [m["content"] for m in mock_api.call_args.kwargs["messages"]] assert scanned == ["TOOL-RESULT-scanned"] + @pytest.mark.asyncio + async def test_legacy_function_role_results_are_scanned(self): + from unittest.mock import AsyncMock, patch + + handler = OpenAIChatCompletionsHandler() + guardrail = self._bedrock_guardrail() + data = { + "messages": [ + {"role": "user", "content": "USER-PROMPT-not-scanned"}, + {"role": "function", "name": "read_file", "content": "FUNCTION-RESULT-scanned"}, + {"role": "tool", "tool_call_id": "call_1", "content": "TOOL-RESULT-scanned"}, + ] + } + with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api: + mock_api.return_value = {"action": "NONE", "output": [], "outputs": []} + await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) + assert mock_api.call_count == 1 + scanned = [m["content"] for m in mock_api.call_args.kwargs["messages"]] + assert scanned == ["FUNCTION-RESULT-scanned", "TOOL-RESULT-scanned"], ( + "a tool result sent with the legacy function role must not bypass the scoped scan" + ) + @pytest.mark.parametrize("flag_value", [None, "false", 0, object()]) @pytest.mark.asyncio async def test_scope_narrows_only_when_the_flag_is_actually_true(self, flag_value): @@ -1456,6 +1510,30 @@ class TestScanOnlyToolResults: "a returned tool reusing a request tool's name must not replace the request's schema" ) + @pytest.mark.asyncio + async def test_duplicate_returned_tool_names_keep_only_the_first(self): + handler = OpenAIChatCompletionsHandler() + guardrail = DuplicateToolReturningGuardrail() + guardrail.scan_only_tool_results = True + original_read_file = { + "type": "function", + "function": {"name": "read_file", "parameters": {"type": "object", "properties": {}}}, + } + data = { + "messages": [ + {"role": "user", "content": "read the report"}, + {"role": "tool", "tool_call_id": "call_1", "content": "TOOL-RESULT"}, + ], + "tools": [original_read_file], + } + + await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) + + assert [t["function"]["name"] for t in data["tools"]] == ["read_file", "injected_retrieve"], ( + "two returned tools sharing a name must not both be forwarded to the provider" + ) + assert data["tools"][1]["function"]["parameters"]["properties"] == {"first": {"type": "string"}} + @pytest.mark.asyncio async def test_structured_write_back_keeps_out_of_scope_messages(self): handler = OpenAIChatCompletionsHandler() diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 37964c27657..8064e63f1aa 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,6 +1,6 @@ { "LIT001": { - "limit": 23337 + "limit": 23332 }, "LIT002": { "limit": 27213 @@ -15,7 +15,7 @@ "limit": 0 }, "LIT006": { - "limit": 1092 + "limit": 1091 }, "LIT007": { "limit": 0 @@ -27,7 +27,7 @@ "limit": 0 }, "LIT010": { - "limit": 16796 + "limit": 16792 }, "LIT011": { "limit": 5602 From 14d4897e55a8223ee3b2815b0b4e038caa3c0f61 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:58:06 -0700 Subject: [PATCH 38/38] fix(guardrails): refuse scan_only_tool_results combos that scan nothing Prompt Security drops tool and function rows unless check_tool_results is on, so it now reports scan-only support from that setting and the registry refuses the pairing at boot. Pairing scan_only_tool_results with skip_tool_message_in_guardrail excludes every message, so guardrail initialization now rejects that combination too. --- .../prompt_security/prompt_security.py | 3 ++ .../proxy/guardrails/guardrail_registry.py | 15 +++++-- .../guardrails/test_guardrail_registry.py | 42 +++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py index 743ad888949..1a2c46f306c 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py +++ b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py @@ -74,6 +74,9 @@ class PromptSecurityGuardrail(CustomGuardrail): super().__init__(**kwargs) + def supports_scan_only_tool_results(self) -> bool: + return self.check_tool_results + @log_guardrail_information async def apply_guardrail( self, diff --git a/litellm/proxy/guardrails/guardrail_registry.py b/litellm/proxy/guardrails/guardrail_registry.py index 15e884c939c..9f70ed63dcb 100644 --- a/litellm/proxy/guardrails/guardrail_registry.py +++ b/litellm/proxy/guardrails/guardrail_registry.py @@ -16,6 +16,7 @@ from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.llms.base_llm.guardrail_translation.utils import ( effective_scan_only_tool_results_for_guardrail, + effective_skip_tool_message_for_guardrail, ) from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import ( BedrockGuardrail, @@ -496,15 +497,21 @@ class InMemoryGuardrailHandler: "scan_only_tool_results", ): setattr(custom_guardrail_callback, scoping_param, getattr(litellm_params, scoping_param, None)) - if ( - effective_scan_only_tool_results_for_guardrail(custom_guardrail_callback) - and not custom_guardrail_callback.supports_scan_only_tool_results() - ): + scan_only_tool_results_enabled: Final = effective_scan_only_tool_results_for_guardrail( + custom_guardrail_callback + ) + if scan_only_tool_results_enabled and not custom_guardrail_callback.supports_scan_only_tool_results(): raise ValueError( f"Guardrail {guardrail['guardrail_name']}: scan_only_tool_results is enabled, but this " "guardrail's role filtering never scans tool results, so no request content would ever " "be scanned. Remove scan_only_tool_results or the guardrail's role-filtering option." ) + if scan_only_tool_results_enabled and effective_skip_tool_message_for_guardrail(custom_guardrail_callback): + raise ValueError( + f"Guardrail {guardrail['guardrail_name']}: scan_only_tool_results and " + "skip_tool_message_in_guardrail are enabled together, which excludes every message from " + "scanning, so no request content would ever be scanned. Remove one of the two." + ) configured_run_in_parallel: Final = getattr(litellm_params, "run_in_parallel", None) if configured_run_in_parallel is not None: custom_guardrail_callback.run_in_parallel = bool(configured_run_in_parallel) diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py b/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py index 3053664ef27..729dbce6b9a 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py @@ -615,3 +615,45 @@ class TestScanOnlyToolResultsInitRefusal: }, ) assert result is not None + + def test_prompt_security_default_tool_filtering_rejects_scan_only_tool_results(self, monkeypatch): + monkeypatch.delenv("PROMPT_SECURITY_CHECK_TOOL_RESULTS", raising=False) + with pytest.raises(ValueError, match="never scans tool results"): + self._initialize( + "prompt-security-scan-only-combo", + { + "guardrail": "prompt_security", + "mode": "pre_call", + "api_key": "test-key", + "api_base": "https://ps.example.com", + "scan_only_tool_results": True, + }, + ) + + def test_prompt_security_check_tool_results_accepts_scan_only_tool_results(self, monkeypatch): + monkeypatch.setenv("PROMPT_SECURITY_CHECK_TOOL_RESULTS", "true") + result = self._initialize( + "prompt-security-scan-only-ok", + { + "guardrail": "prompt_security", + "mode": "pre_call", + "api_key": "test-key", + "api_base": "https://ps.example.com", + "scan_only_tool_results": True, + }, + ) + assert result is not None + + def test_skip_tool_message_with_scan_only_tool_results_is_rejected(self): + with pytest.raises(ValueError, match="skip_tool_message_in_guardrail are enabled together"): + self._initialize( + "bedrock-skip-tool-scan-only-combo", + { + "guardrail": "bedrock", + "mode": "pre_call", + "guardrailIdentifier": "gr-1", + "guardrailVersion": "1", + "skip_tool_message_in_guardrail": True, + "scan_only_tool_results": True, + }, + )