From 16a7e0ce8fe83402b4ed00d8b02ae2475f6cd906 Mon Sep 17 00:00:00 2001 From: Chesars Date: Wed, 26 Nov 2025 17:01:23 -0300 Subject: [PATCH 001/380] fix: filter empty SSE lines in BaseModelResponseIterator to prevent extra empty chunks When streaming with stream_options={"include_usage": True}, xAI and other providers using BaseLLMHTTPHandler were returning an extra empty chunk after the usage chunk. This was caused by empty SSE lines (separators between events) being processed as empty GenericStreamingChunks. The fix adds a loop in __next__ and __anext__ to skip empty lines before processing, ensuring only meaningful SSE data events are converted to chunks. Fixes #17136 --- litellm/llms/base_llm/base_model_iterator.py | 89 +++++++++++--------- 1 file changed, 50 insertions(+), 39 deletions(-) diff --git a/litellm/llms/base_llm/base_model_iterator.py b/litellm/llms/base_llm/base_model_iterator.py index 6953b1c5878..62cd503a89e 100644 --- a/litellm/llms/base_llm/base_model_iterator.py +++ b/litellm/llms/base_llm/base_model_iterator.py @@ -125,26 +125,32 @@ class BaseModelResponseIterator: ) def __next__(self): - try: - chunk = self.response_iterator.__next__() - except StopIteration: - raise StopIteration - except ValueError as e: - raise RuntimeError(f"Error receiving chunk from stream: {e}") + while True: + try: + chunk = self.response_iterator.__next__() + except StopIteration: + raise StopIteration + except ValueError as e: + raise RuntimeError(f"Error receiving chunk from stream: {e}") - try: - str_line = chunk - if isinstance(chunk, bytes): # Handle binary data - str_line = chunk.decode("utf-8") # Convert bytes to string - index = str_line.find("data:") - if index != -1: - str_line = str_line[index:] - # chunk is a str at this point - return self._handle_string_chunk(str_line=str_line) - except StopIteration: - raise StopIteration - except ValueError as e: - raise RuntimeError(f"Error parsing chunk: {e},\nReceived chunk: {chunk}") + try: + str_line = chunk + if isinstance(chunk, bytes): # Handle binary data + str_line = chunk.decode("utf-8") # Convert bytes to string + index = str_line.find("data:") + if index != -1: + str_line = str_line[index:] + + # Skip empty lines (common in SSE streams between events) + if not str_line or not str_line.strip(): + continue + + # chunk is a str at this point + return self._handle_string_chunk(str_line=str_line) + except StopIteration: + raise StopIteration + except ValueError as e: + raise RuntimeError(f"Error parsing chunk: {e},\nReceived chunk: {chunk}") # Async iterator def __aiter__(self): @@ -152,30 +158,35 @@ class BaseModelResponseIterator: return self async def __anext__(self): - try: - chunk = await self.async_response_iterator.__anext__() + while True: + try: + chunk = await self.async_response_iterator.__anext__() - except StopAsyncIteration: - raise StopAsyncIteration - except ValueError as e: - raise RuntimeError(f"Error receiving chunk from stream: {e}") + except StopAsyncIteration: + raise StopAsyncIteration + except ValueError as e: + raise RuntimeError(f"Error receiving chunk from stream: {e}") - try: - str_line = chunk - if isinstance(chunk, bytes): # Handle binary data - str_line = chunk.decode("utf-8") # Convert bytes to string - index = str_line.find("data:") - if index != -1: - str_line = str_line[index:] + try: + str_line = chunk + if isinstance(chunk, bytes): # Handle binary data + str_line = chunk.decode("utf-8") # Convert bytes to string + index = str_line.find("data:") + if index != -1: + str_line = str_line[index:] - # chunk is a str at this point - chunk = self._handle_string_chunk(str_line=str_line) + # Skip empty lines (common in SSE streams between events) + if not str_line or not str_line.strip(): + continue - return chunk - except StopAsyncIteration: - raise StopAsyncIteration - except ValueError as e: - raise RuntimeError(f"Error parsing chunk: {e},\nReceived chunk: {chunk}") + # chunk is a str at this point + chunk = self._handle_string_chunk(str_line=str_line) + + return chunk + except StopAsyncIteration: + raise StopAsyncIteration + except ValueError as e: + raise RuntimeError(f"Error parsing chunk: {e},\nReceived chunk: {chunk}") class MockResponseIterator: # for returning ai21 streaming responses From 8c128edb5d3790096376c086f9fa1027f6344e08 Mon Sep 17 00:00:00 2001 From: Chesars Date: Wed, 26 Nov 2025 17:05:40 -0300 Subject: [PATCH 002/380] test: add unit tests for BaseModelResponseIterator empty SSE line filtering Tests verify that empty lines between SSE events are properly filtered and don't produce extra empty chunks in streaming responses. --- .../llms/base_llm/test_base_model_iterator.py | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/test_litellm/llms/base_llm/test_base_model_iterator.py diff --git a/tests/test_litellm/llms/base_llm/test_base_model_iterator.py b/tests/test_litellm/llms/base_llm/test_base_model_iterator.py new file mode 100644 index 00000000000..d5166c4690e --- /dev/null +++ b/tests/test_litellm/llms/base_llm/test_base_model_iterator.py @@ -0,0 +1,117 @@ +""" +Tests for BaseModelResponseIterator - specifically testing that empty SSE lines are filtered +""" + +import pytest +from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator +from litellm.types.utils import GenericStreamingChunk, ModelResponseStream + + +class TestBaseModelResponseIterator: + """Test cases for BaseModelResponseIterator empty line filtering""" + + def test_filter_empty_sse_lines_sync(self): + """ + Test that empty SSE lines (common between SSE events) are filtered out + and don't produce empty chunks. + + This fixes the bug where providers using BaseLLMHTTPHandler (like xAI) + would return extra empty chunks when streaming with include_usage=True. + + Related: GitHub Issue #17136 + """ + # Simulate SSE stream with empty lines between events (normal SSE format) + sse_lines = [ + 'data: {"id":"1","choices":[{"delta":{"content":"Hello"}}]}', + '', # Empty line (SSE separator) + 'data: {"id":"1","choices":[{"delta":{"content":" World"}}]}', + '', # Empty line (SSE separator) + 'data: {"id":"1","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5}}', + '', # Empty line (SSE separator) + 'data: [DONE]', + '', # Empty line after DONE + ] + + iterator = BaseModelResponseIterator( + streaming_response=iter(sse_lines), + sync_stream=True + ) + + chunks = list(iterator) + + # Should have 4 chunks: 2 content + 1 usage + 1 DONE + # Empty lines should be filtered out + assert len(chunks) == 4, f"Expected 4 chunks, got {len(chunks)}" + + # Verify no empty/None chunks were included + # The base iterator returns ModelResponseStream objects + for i, chunk in enumerate(chunks): + assert chunk is not None, f"Chunk {i} should not be None" + + def test_filter_whitespace_only_lines_sync(self): + """Test that lines with only whitespace are also filtered""" + sse_lines = [ + 'data: {"id":"1","choices":[{"delta":{"content":"Hi"}}]}', + ' ', # Whitespace only + '\t', # Tab only + 'data: [DONE]', + ] + + iterator = BaseModelResponseIterator( + streaming_response=iter(sse_lines), + sync_stream=True + ) + + chunks = list(iterator) + + # Should have 2 chunks: 1 content + 1 DONE + assert len(chunks) == 2, f"Expected 2 chunks, got {len(chunks)}" + + def test_valid_chunks_not_filtered_sync(self): + """Test that valid data chunks are not filtered""" + sse_lines = [ + 'data: {"id":"1","choices":[{"delta":{"content":"A"}}]}', + 'data: {"id":"1","choices":[{"delta":{"content":"B"}}]}', + 'data: {"id":"1","choices":[{"delta":{"content":"C"}}]}', + 'data: [DONE]', + ] + + iterator = BaseModelResponseIterator( + streaming_response=iter(sse_lines), + sync_stream=True + ) + + chunks = list(iterator) + + # All 4 chunks should be present + assert len(chunks) == 4, f"Expected 4 chunks, got {len(chunks)}" + + +@pytest.mark.asyncio +async def test_filter_empty_sse_lines_async(): + """ + Test async version: empty SSE lines should be filtered out + """ + async def async_sse_generator(): + lines = [ + 'data: {"id":"1","choices":[{"delta":{"content":"Hello"}}]}', + '', # Empty line + 'data: {"id":"1","choices":[{"delta":{"content":" World"}}]}', + '', # Empty line + 'data: [DONE]', + '', # Empty line + ] + for line in lines: + yield line + + iterator = BaseModelResponseIterator( + streaming_response=async_sse_generator(), + sync_stream=False + ) + + chunks = [] + async for chunk in iterator: + chunks.append(chunk) + + # Should have 3 chunks: 2 content + 1 DONE + assert len(chunks) == 3, f"Expected 3 chunks, got {len(chunks)}" From ba1b466480a000b559fde36c246c9d31392af1ce Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 7 Feb 2026 18:16:01 -0800 Subject: [PATCH 003/380] fix to ensure budget duration is being inherited from budget tier for keys --- .../proxy/common_utils/reset_budget_job.py | 36 +++++ .../key_management_endpoints.py | 7 + .../common_utils/test_reset_budget_job.py | 116 +++++++++++++++ .../test_key_management_endpoints.py | 140 ++++++++++++++++++ 4 files changed, 299 insertions(+) diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index fb600cee26b..6f038d127f6 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -69,6 +69,38 @@ class ResetBudgetJob: }, ) + async def reset_budget_for_keys_linked_to_budgets( + self, budgets_to_reset: List[LiteLLM_BudgetTableFull] + ): + """ + Resets the spend for keys linked to budget tiers that are being reset. + + This handles keys that have budget_id but no budget_duration set on the key + itself (e.g. keys created before the fix to inherit budget_duration from + the linked budget tier). + + Keys that have their own budget_duration are already handled by + reset_budget_for_litellm_keys() and are excluded here to avoid + double-resetting. + """ + budget_ids = [ + budget.budget_id + for budget in budgets_to_reset + if budget.budget_id is not None + ] + if not budget_ids: + return + + return await self.prisma_client.db.litellm_verificationtoken.update_many( + where={ + "budget_id": {"in": budget_ids}, + "budget_duration": None, # only keys without their own reset schedule + }, + data={ + "spend": 0, + }, + ) + async def reset_budget_for_litellm_budget_table(self): """ Resets the budget for all LiteLLM End-Users (Customers), and Team Members if their budget has expired @@ -112,6 +144,10 @@ class ResetBudgetJob: budgets_to_reset=budgets_to_reset ) + await self.reset_budget_for_keys_linked_to_budgets( + budgets_to_reset=budgets_to_reset + ) + if endusers_to_reset is not None and len(endusers_to_reset) > 0: for enduser in endusers_to_reset: try: diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 2eb6cf65281..b62ce329548 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -594,6 +594,13 @@ async def _common_key_generation_helper( # noqa: PLR0915 if "budget_duration" in data_json: data_json["key_budget_duration"] = data_json.pop("budget_duration", None) + elif _budget_id is not None and prisma_client is not None: + # Inherit budget_duration from linked budget tier if not explicitly set on the key + budget_row = await prisma_client.db.litellm_budgettable.find_unique( + where={"budget_id": _budget_id} + ) + if budget_row is not None and budget_row.budget_duration is not None: + data_json["key_budget_duration"] = budget_row.budget_duration if user_api_key_dict.user_id is not None: data_json["created_by"] = user_api_key_dict.user_id diff --git a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py index a059a3adcb1..f63c77c1fc8 100644 --- a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py +++ b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py @@ -25,9 +25,21 @@ class MockLiteLLMTeamMembership: return {"count": 1} +class MockLiteLLMVerificationToken: + def __init__(self): + self.update_many_calls: List[Dict[str, Any]] = [] + + async def update_many( + self, where: Dict[str, Any], data: Dict[str, Any] + ) -> Dict[str, Any]: + self.update_many_calls.append({"where": where, "data": data}) + return {"count": 1} + + class MockDB: def __init__(self): self.litellm_teammembership = MockLiteLLMTeamMembership() + self.litellm_verificationtoken = MockLiteLLMVerificationToken() class MockPrismaClient: @@ -320,3 +332,107 @@ def test_reset_budget_all(reset_budget_job, mock_prisma_client): assert mock_prisma_client.updated_data["user"][0].spend == 0.0 assert mock_prisma_client.updated_data["team"][0].spend == 0.0 assert mock_prisma_client.updated_data["enduser"][0].spend == 0.0 + + +def test_reset_budget_for_keys_linked_to_budgets(reset_budget_job, mock_prisma_client): + """ + Test that when a budget tier is reset, keys linked to that budget + (via budget_id) that don't have their own budget_duration also get + their spend reset. + + This covers the case where keys were created with budget_id but + budget_duration was not inherited to the key (pre-fix keys). + """ + from litellm.proxy._types import LiteLLM_BudgetTableFull + + now = datetime.now(timezone.utc) + + # Create a budget tier that is due for reset + test_budget = type( + "LiteLLM_BudgetTableFull", + (), + { + "max_budget": 10.0, + "budget_duration": "7d", + "budget_reset_at": now - timedelta(hours=1), + "budget_id": "7d-budget-tier", + "created_at": now - timedelta(days=7), + }, + ) + + budgets_to_reset = [test_budget] + + # Run the method + asyncio.run( + reset_budget_job.reset_budget_for_keys_linked_to_budgets( + budgets_to_reset=budgets_to_reset + ) + ) + + # Verify that update_many was called on litellm_verificationtoken + calls = mock_prisma_client.db.litellm_verificationtoken.update_many_calls + assert len(calls) == 1, f"Expected 1 update_many call, got {len(calls)}" + + # Verify the where clause filters by budget_id and null budget_duration + call = calls[0] + assert call["where"]["budget_id"] == {"in": ["7d-budget-tier"]} + assert call["where"]["budget_duration"] is None + + # Verify spend is reset to 0 + assert call["data"]["spend"] == 0 + + +def test_reset_budget_for_keys_linked_to_budgets_empty( + reset_budget_job, mock_prisma_client +): + """ + Test that when there are no budgets to reset, no update is performed + on the verification token table. + """ + # Run with empty list + asyncio.run( + reset_budget_job.reset_budget_for_keys_linked_to_budgets( + budgets_to_reset=[] + ) + ) + + # Verify no update_many calls were made + calls = mock_prisma_client.db.litellm_verificationtoken.update_many_calls + assert len(calls) == 0 + + +def test_budget_table_reset_also_resets_linked_keys( + reset_budget_job, mock_prisma_client +): + """ + Integration-style test: when reset_budget_for_litellm_budget_table runs, + it should also reset spend for keys linked to the expiring budget tiers + (in addition to end-users and team members). + """ + now = datetime.now(timezone.utc) + + test_budget = type( + "LiteLLM_BudgetTableFull", + (), + { + "max_budget": 10.0, + "budget_duration": "7d", + "budget_reset_at": now - timedelta(hours=1), + "budget_id": "7d-budget-tier", + "created_at": now - timedelta(days=7), + }, + ) + + mock_prisma_client.data["budget"] = [test_budget] + + # Run the full budget table reset + asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table()) + + # Verify that keys linked to the budget were also reset + calls = mock_prisma_client.db.litellm_verificationtoken.update_many_calls + assert len(calls) == 1, ( + "Expected reset_budget_for_litellm_budget_table to also reset keys " + f"linked to expiring budgets, but got {len(calls)} update_many calls" + ) + assert calls[0]["where"]["budget_id"] == {"in": ["7d-budget-tier"]} + assert calls[0]["data"]["spend"] == 0 diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 39f8d1cccb0..472504871ed 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -5559,3 +5559,143 @@ async def test_validate_key_list_check_key_hash_not_found(): assert exc_info.value.code == "403" or exc_info.value.code == 403 assert "Key Hash not found" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_key_inherits_budget_duration_from_budget_tier(): + """ + Test that when a key is created with budget_id pointing to a budget tier + that has budget_duration, the key inherits budget_duration from the tier + even when budget_duration is not explicitly set on the key request. + + This verifies the fix for the bug where keys created with budget_id + would have null budget_duration and budget_reset_at, causing the + budget reset job to never reset their spend. + """ + from unittest.mock import AsyncMock, MagicMock, patch + + # Mock the budget tier lookup to return a budget with budget_duration="7d" + mock_budget_row = MagicMock() + mock_budget_row.budget_duration = "7d" + + mock_prisma = MagicMock() + mock_prisma.db.litellm_budgettable.find_unique = AsyncMock( + return_value=mock_budget_row + ) + + mock_generate_key = AsyncMock( + return_value={ + "key": "sk-test-key", + "expires": None, + "user_id": "test-user", + "team_id": None, + } + ) + + with patch( + "litellm.proxy.proxy_server.prisma_client", mock_prisma + ), patch( + "litellm.proxy.proxy_server.llm_router", None + ), patch( + "litellm.proxy.proxy_server.premium_user", False + ), patch( + "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" + ), patch( + "litellm.proxy.management_endpoints.key_management_endpoints.generate_key_helper_fn", + mock_generate_key, + ): + await _common_key_generation_helper( + data=GenerateKeyRequest( + budget_id="7d-budget-tier", + max_budget=10.0, + # NOTE: budget_duration is intentionally NOT set here + ), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-1234", + user_id="admin-user", + ), + litellm_changed_by=None, + team_table=None, + ) + + # Verify generate_key_helper_fn was called + mock_generate_key.assert_awaited_once() + call_kwargs = mock_generate_key.call_args.kwargs + + # The key should have inherited key_budget_duration from the budget tier + assert call_kwargs.get("key_budget_duration") == "7d", ( + "key_budget_duration should be inherited from the linked budget tier " + f"but got: {call_kwargs.get('key_budget_duration')}" + ) + + # Verify the budget tier was looked up with the correct budget_id + mock_prisma.db.litellm_budgettable.find_unique.assert_awaited_once_with( + where={"budget_id": "7d-budget-tier"} + ) + + +@pytest.mark.asyncio +async def test_key_does_not_override_explicit_budget_duration(): + """ + Test that when a key is created with both budget_id and an explicit + budget_duration, the explicit budget_duration takes precedence over + the budget tier's budget_duration. + """ + from unittest.mock import AsyncMock, MagicMock, patch + + mock_prisma = MagicMock() + # The budget tier has budget_duration="7d" + mock_budget_row = MagicMock() + mock_budget_row.budget_duration = "7d" + mock_prisma.db.litellm_budgettable.find_unique = AsyncMock( + return_value=mock_budget_row + ) + + mock_generate_key = AsyncMock( + return_value={ + "key": "sk-test-key", + "expires": None, + "user_id": "test-user", + "team_id": None, + } + ) + + with patch( + "litellm.proxy.proxy_server.prisma_client", mock_prisma + ), patch( + "litellm.proxy.proxy_server.llm_router", None + ), patch( + "litellm.proxy.proxy_server.premium_user", False + ), patch( + "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" + ), patch( + "litellm.proxy.management_endpoints.key_management_endpoints.generate_key_helper_fn", + mock_generate_key, + ): + await _common_key_generation_helper( + data=GenerateKeyRequest( + budget_id="7d-budget-tier", + max_budget=10.0, + budget_duration="30d", # explicit budget_duration should take precedence + ), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-1234", + user_id="admin-user", + ), + litellm_changed_by=None, + team_table=None, + ) + + mock_generate_key.assert_awaited_once() + call_kwargs = mock_generate_key.call_args.kwargs + + # The explicit budget_duration should take precedence + assert call_kwargs.get("key_budget_duration") == "30d", ( + "Explicit budget_duration should take precedence over the budget tier's value " + f"but got: {call_kwargs.get('key_budget_duration')}" + ) + + # The budget tier should NOT have been looked up since budget_duration was explicit + mock_prisma.db.litellm_budgettable.find_unique.assert_not_awaited() From cf14f0c8214951b63593e2e19e50652f253ba5c9 Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 14 Feb 2026 10:53:58 -0800 Subject: [PATCH 004/380] change logic to match Kriish's input --- .../key_management_endpoints.py | 11 ++---- .../test_key_management_endpoints.py | 38 +++++++------------ 2 files changed, 18 insertions(+), 31 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index b62ce329548..ab610a77b4d 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -592,15 +592,12 @@ async def _common_key_generation_helper( # noqa: PLR0915 if _budget_id is not None: data_json["budget_id"] = _budget_id + # Only set budget_duration on key when explicitly provided. Keys with budget_id + # but no explicit budget_duration follow their linked budget tier's schedule; + # reset_budget_for_keys_linked_to_budgets() resets them when the tier resets. + # This avoids duplicating budget_duration on keys so tier updates apply automatically. if "budget_duration" in data_json: data_json["key_budget_duration"] = data_json.pop("budget_duration", None) - elif _budget_id is not None and prisma_client is not None: - # Inherit budget_duration from linked budget tier if not explicitly set on the key - budget_row = await prisma_client.db.litellm_budgettable.find_unique( - where={"budget_id": _budget_id} - ) - if budget_row is not None and budget_row.budget_duration is not None: - data_json["key_budget_duration"] = budget_row.budget_duration if user_api_key_dict.user_id is not None: data_json["created_by"] = user_api_key_dict.user_id diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 472504871ed..21be2ad69e8 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -5562,26 +5562,19 @@ async def test_validate_key_list_check_key_hash_not_found(): @pytest.mark.asyncio -async def test_key_inherits_budget_duration_from_budget_tier(): +async def test_key_with_budget_id_does_not_store_budget_duration(): """ - Test that when a key is created with budget_id pointing to a budget tier - that has budget_duration, the key inherits budget_duration from the tier - even when budget_duration is not explicitly set on the key request. + Test that when a key is created with budget_id but without explicit + budget_duration, the key does NOT get budget_duration stored on it. - This verifies the fix for the bug where keys created with budget_id - would have null budget_duration and budget_reset_at, causing the - budget reset job to never reset their spend. + Keys with budget_id follow their linked budget tier's reset schedule; + reset_budget_for_keys_linked_to_budgets() resets them when the tier resets. + This avoids duplicating budget_duration on keys so tier updates apply + automatically to all linked keys. """ from unittest.mock import AsyncMock, MagicMock, patch - # Mock the budget tier lookup to return a budget with budget_duration="7d" - mock_budget_row = MagicMock() - mock_budget_row.budget_duration = "7d" - mock_prisma = MagicMock() - mock_prisma.db.litellm_budgettable.find_unique = AsyncMock( - return_value=mock_budget_row - ) mock_generate_key = AsyncMock( return_value={ @@ -5619,20 +5612,17 @@ async def test_key_inherits_budget_duration_from_budget_tier(): team_table=None, ) - # Verify generate_key_helper_fn was called mock_generate_key.assert_awaited_once() call_kwargs = mock_generate_key.call_args.kwargs - # The key should have inherited key_budget_duration from the budget tier - assert call_kwargs.get("key_budget_duration") == "7d", ( - "key_budget_duration should be inherited from the linked budget tier " - f"but got: {call_kwargs.get('key_budget_duration')}" + # Key should NOT have key_budget_duration - it follows the budget tier's schedule + assert call_kwargs.get("key_budget_duration") is None, ( + "key_budget_duration should be None for budget-linked keys without explicit " + f"budget_duration; got: {call_kwargs.get('key_budget_duration')}" ) - # Verify the budget tier was looked up with the correct budget_id - mock_prisma.db.litellm_budgettable.find_unique.assert_awaited_once_with( - where={"budget_id": "7d-budget-tier"} - ) + # No budget tier lookup - we don't copy budget_duration onto the key + mock_prisma.db.litellm_budgettable.find_unique.assert_not_called() @pytest.mark.asyncio @@ -5698,4 +5688,4 @@ async def test_key_does_not_override_explicit_budget_duration(): ) # The budget tier should NOT have been looked up since budget_duration was explicit - mock_prisma.db.litellm_budgettable.find_unique.assert_not_awaited() + mock_prisma.db.litellm_budgettable.find_unique.assert_not_called() From f79a8f7809ceaa1b2650c98ebdb95bc126a5beac Mon Sep 17 00:00:00 2001 From: Shivam Rawat <161387515+shivamrawat1@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:42:17 -0800 Subject: [PATCH 005/380] Update litellm/proxy/common_utils/reset_budget_job.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- litellm/proxy/common_utils/reset_budget_job.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index 6f038d127f6..c63309da1dc 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -95,11 +95,13 @@ class ResetBudgetJob: where={ "budget_id": {"in": budget_ids}, "budget_duration": None, # only keys without their own reset schedule + "spend": {"gt": 0}, # only reset keys that have accumulated spend }, data={ "spend": 0, }, ) + ) async def reset_budget_for_litellm_budget_table(self): """ From ea87dd216281155bc60da8a2751ae69122a97cce Mon Sep 17 00:00:00 2001 From: Shivam Rawat <161387515+shivamrawat1@users.noreply.github.com> Date: Sat, 14 Feb 2026 19:01:35 -0800 Subject: [PATCH 006/380] Update litellm/proxy/common_utils/reset_budget_job.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- litellm/proxy/common_utils/reset_budget_job.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index c63309da1dc..b926fe28bed 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -101,7 +101,6 @@ class ResetBudgetJob: "spend": 0, }, ) - ) async def reset_budget_for_litellm_budget_table(self): """ From 2d9508ec96253557c97e6d6cc229d0a1d9e702d8 Mon Sep 17 00:00:00 2001 From: Shivam Rawat <161387515+shivamrawat1@users.noreply.github.com> Date: Sat, 14 Feb 2026 19:09:26 -0800 Subject: [PATCH 007/380] Update litellm/proxy/common_utils/reset_budget_job.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- litellm/proxy/common_utils/reset_budget_job.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index b926fe28bed..4933e679d71 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -76,13 +76,14 @@ class ResetBudgetJob: Resets the spend for keys linked to budget tiers that are being reset. This handles keys that have budget_id but no budget_duration set on the key - itself (e.g. keys created before the fix to inherit budget_duration from - the linked budget tier). + itself. Keys with budget_id rely on their linked budget tier's reset schedule + rather than having their own budget_duration. Keys that have their own budget_duration are already handled by reset_budget_for_litellm_keys() and are excluded here to avoid double-resetting. """ + """ budget_ids = [ budget.budget_id for budget in budgets_to_reset From 0117b35a6bc1c2877f074794664e9ece1294114d Mon Sep 17 00:00:00 2001 From: shivam Date: Mon, 16 Feb 2026 09:59:19 -0800 Subject: [PATCH 008/380] added more tests, fixed tests --- .../proxy/common_utils/reset_budget_job.py | 3 +- .../test_proxy_budget_reset.py | 16 +++++++ .../common_utils/test_reset_budget_job.py | 43 +++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index 4933e679d71..8ce73d29c84 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -83,7 +83,6 @@ class ResetBudgetJob: reset_budget_for_litellm_keys() and are excluded here to avoid double-resetting. """ - """ budget_ids = [ budget.budget_id for budget in budgets_to_reset @@ -617,4 +616,4 @@ class ResetBudgetJob: await ResetBudgetJob._reset_budget_common( item=key, current_time=current_time, item_type="key" ) - return key + return key \ No newline at end of file diff --git a/tests/litellm_utils_tests/test_proxy_budget_reset.py b/tests/litellm_utils_tests/test_proxy_budget_reset.py index 7cddde30421..34423a88da4 100644 --- a/tests/litellm_utils_tests/test_proxy_budget_reset.py +++ b/tests/litellm_utils_tests/test_proxy_budget_reset.py @@ -229,6 +229,10 @@ async def test_reset_budget_endusers_partial_failure(): prisma_client.get_data.side_effect = get_data_mock prisma_client.update_data = AsyncMock() + # Mock db.litellm_verificationtoken.update_many (used by reset_budget_for_keys_linked_to_budgets) + prisma_client.db.litellm_verificationtoken.update_many = AsyncMock( + return_value={"count": 0} + ) proxy_logging_obj = MagicMock() proxy_logging_obj.service_logging_obj = MagicMock() @@ -389,6 +393,10 @@ async def test_reset_budget_continues_other_categories_on_failure(): prisma_client.get_data = AsyncMock(side_effect=fake_get_data) prisma_client.update_data = AsyncMock() + # Mock db.litellm_verificationtoken.update_many (used by reset_budget_for_keys_linked_to_budgets) + prisma_client.db.litellm_verificationtoken.update_many = AsyncMock( + return_value={"count": 0} + ) proxy_logging_obj = MagicMock() proxy_logging_obj.service_logging_obj = MagicMock() @@ -863,6 +871,10 @@ async def test_service_logger_endusers_success(): prisma_client = MagicMock() prisma_client.get_data = AsyncMock(side_effect=fake_get_data) prisma_client.update_data = AsyncMock() + # Mock db.litellm_verificationtoken.update_many (used by reset_budget_for_keys_linked_to_budgets) + prisma_client.db.litellm_verificationtoken.update_many = AsyncMock( + return_value={"count": 0} + ) proxy_logging_obj = MagicMock() proxy_logging_obj.service_logging_obj = MagicMock() @@ -938,6 +950,10 @@ async def test_service_logger_endusers_failure(): prisma_client = MagicMock() prisma_client.get_data = AsyncMock(side_effect=fake_get_data) prisma_client.update_data = AsyncMock() + # Mock db.litellm_verificationtoken.update_many (used by reset_budget_for_keys_linked_to_budgets) + prisma_client.db.litellm_verificationtoken.update_many = AsyncMock( + return_value={"count": 0} + ) proxy_logging_obj = MagicMock() proxy_logging_obj.service_logging_obj = MagicMock() diff --git a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py index f63c77c1fc8..f975460836a 100644 --- a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py +++ b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py @@ -382,6 +382,49 @@ def test_reset_budget_for_keys_linked_to_budgets(reset_budget_job, mock_prisma_c assert call["data"]["spend"] == 0 +def test_reset_budget_for_keys_linked_to_budgets_excludes_keys_with_own_budget_duration( + reset_budget_job, mock_prisma_client +): + """ + Test that keys with BOTH budget_id AND budget_duration are excluded from + reset_budget_for_keys_linked_to_budgets. Such keys have their own reset + schedule and are handled only by reset_budget_for_litellm_keys(). The + budget_duration=None filter ensures they are NOT double-reset when the + linked budget tier expires. + """ + now = datetime.now(timezone.utc) + + test_budget = type( + "LiteLLM_BudgetTableFull", + (), + { + "max_budget": 10.0, + "budget_duration": "7d", + "budget_reset_at": now - timedelta(hours=1), + "budget_id": "7d-budget-tier", + "created_at": now - timedelta(days=7), + }, + ) + + budgets_to_reset = [test_budget] + + asyncio.run( + reset_budget_job.reset_budget_for_keys_linked_to_budgets( + budgets_to_reset=budgets_to_reset + ) + ) + + calls = mock_prisma_client.db.litellm_verificationtoken.update_many_calls + assert len(calls) == 1 + call = calls[0] + + # Critical: budget_duration must be None so keys with their own budget_duration + # (e.g. key has budget_id="X" AND budget_duration=60) are excluded. + # Those keys are reset only by reset_budget_for_litellm_keys() - no double-reset. + assert call["where"]["budget_duration"] is None + assert call["where"]["budget_id"] == {"in": ["7d-budget-tier"]} + + def test_reset_budget_for_keys_linked_to_budgets_empty( reset_budget_job, mock_prisma_client ): From 9f7f19067079977ae6d8fd3ecbd82a6f39362f02 Mon Sep 17 00:00:00 2001 From: shivam Date: Mon, 16 Feb 2026 10:24:06 -0800 Subject: [PATCH 009/380] resolved greptile comment --- tests/litellm_utils_tests/test_proxy_budget_reset.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/litellm_utils_tests/test_proxy_budget_reset.py b/tests/litellm_utils_tests/test_proxy_budget_reset.py index 34423a88da4..34b2043261c 100644 --- a/tests/litellm_utils_tests/test_proxy_budget_reset.py +++ b/tests/litellm_utils_tests/test_proxy_budget_reset.py @@ -1042,6 +1042,9 @@ async def test_reset_budget_for_litellm_team_members_called(): prisma_client.db.litellm_teammembership.update_many = AsyncMock( return_value={"count": 2} ) + prisma_client.db.litellm_verificationtoken.update_many = AsyncMock( + return_value={"count": 0} + ) proxy_logging_obj = MagicMock() proxy_logging_obj.service_logging_obj = MagicMock() From 8d5db4f712cf94eeacee130eb3557b910155096d Mon Sep 17 00:00:00 2001 From: jtsaw <166962251+jtsaw@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:10:50 -0800 Subject: [PATCH 010/380] fix handling of ResponseApplyPatchToolCall in completion bridge (#20913) * fix handling of ResponseApplyPatchToolCall in completion bridge * refactor * style: fix black formatting * fix: clean up lint errors in test file (unused imports, print statements, formatting) * refactor: extract _map_optional_params_to_responses_api to fix PLR0915 * what * this linter cannot be me * revert cause idk what's going on * weird * idk why this got removed * revert more stuff * revert pt 3 --- .../transformation.py | 19 +- .../transformation.py | 77 +++++--- ...responses_transformation_transformation.py | 174 ++++++++++++++++-- 3 files changed, 225 insertions(+), 45 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index e546a0dbb02..5de9a489854 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -401,6 +401,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): ResponseOutputMessage, ResponseReasoningItem, ) + from openai.types.responses.response_output_item import ResponseApplyPatchToolCall from litellm.types.utils import Choices, Message @@ -457,6 +458,18 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): accumulated_tool_calls.append(tool_call_dict) tool_call_index += 1 + elif isinstance(item, ResponseApplyPatchToolCall): + from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, + ) + + tool_call_dict = LiteLLMCompletionResponsesConfig.convert_apply_patch_tool_call_to_chat_completion_tool_call( + tool_call_item=item, + index=tool_call_index, + ) + accumulated_tool_calls.append(tool_call_dict) + tool_call_index += 1 + elif isinstance(item, dict) and handle_raw_dict_callback is not None: # Handle raw dict responses (e.g., from GPT-5 Codex) choice, index = handle_raw_dict_callback(item=item, index=index) @@ -533,7 +546,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): raw_response.usage ), ) - + # Preserve hidden params from the ResponsesAPIResponse, especially the headers # which contain important provider information like x-request-id raw_response_hidden_params = getattr(raw_response, "_hidden_params", {}) @@ -550,7 +563,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): model_response._hidden_params[key] = merged_headers else: model_response._hidden_params[key] = value - + return model_response def get_model_response_iterator( @@ -855,7 +868,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): return {"format": {"type": "text"}} return None - + @staticmethod def _convert_annotations_to_chat_format( annotations: Optional[List[Any]], diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index b8379b28c30..8daa8e49d1e 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -291,14 +291,14 @@ class LiteLLMCompletionResponsesConfig: ) _messages = litellm_completion_request.get("messages") or [] session_messages = chat_completion_session.get("messages") or [] - + # If session messages are empty (e.g., no database in test environment), # we still need to process the new input messages # Store original _messages before combining for safety check original_new_messages = _messages.copy() if _messages else [] - + combined_messages = session_messages + _messages - + # Fix: Ensure tool_results have corresponding tool_calls in previous assistant message # Pass tools parameter to help reconstruct tool_calls if not in cache tools = litellm_completion_request.get("tools") or [] @@ -306,7 +306,7 @@ class LiteLLMCompletionResponsesConfig: messages=combined_messages, tools=tools ) - + # Safety check: Ensure we don't end up with empty messages # This can happen when using previous_response_id without a database (e.g., in tests) # and session messages are empty but new input messages exist @@ -337,7 +337,7 @@ class LiteLLMCompletionResponsesConfig: model=litellm_completion_request.get("model", ""), llm_provider=litellm_completion_request.get("custom_llm_provider", ""), ) - + litellm_completion_request["messages"] = combined_messages litellm_completion_request["litellm_trace_id"] = chat_completion_session.get( "litellm_session_id" @@ -385,8 +385,8 @@ class LiteLLMCompletionResponsesConfig: ######################################################### # If Input Item is a Tool Call Output, add it to the tool_call_output_messages list - # preserving the ordering of tool call outputs. Some models require the tool - # result to immediately follow the assistant tool call. + # preserving the ordering of tool call outputs. Some models require the tool + # result to immediately follow the assistant tool call. ######################################################### if LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output( input_item=_input @@ -743,47 +743,47 @@ class LiteLLMCompletionResponsesConfig: ) -> List[Union[AllMessageValues, GenericChatCompletionMessage, ChatCompletionResponseMessage]]: """ Ensure that tool_result messages have corresponding tool_calls in the previous assistant message. - + This is critical for Anthropic API which requires that each tool_result block has a corresponding tool_use block in the previous assistant message. - + Args: messages: List of messages that may include tool_result messages tools: Optional list of tools that can be used to reconstruct tool_calls if not in cache - + Returns: List of messages with tool_calls added to assistant messages when needed """ if not messages: return messages - + # Create a deep copy to avoid modifying the original import copy fixed_messages = copy.deepcopy(messages) messages_to_remove = [] - + # Count non-tool messages to avoid removing all messages # This prevents empty messages list when using previous_response_id without a database non_tool_messages_count = sum( 1 for msg in fixed_messages if msg.get("role") != "tool" ) - + for i, message in enumerate(fixed_messages): # Only process tool messages - check role first to narrow the type if message.get("role") != "tool": continue - + # At this point, we know it's a tool message, so it should have tool_call_id # Use get() with default to safely access tool_call_id tool_call_id_raw = message.get("tool_call_id") if isinstance(message, dict) else getattr(message, "tool_call_id", None) tool_call_id: str = ( str(tool_call_id_raw) if tool_call_id_raw is not None else "" ) - + prev_assistant_idx = LiteLLMCompletionResponsesConfig._find_previous_assistant_idx( fixed_messages, i ) - + # Try to recover empty tool_call_id from previous assistant message if not tool_call_id and prev_assistant_idx is not None: prev_assistant = fixed_messages[prev_assistant_idx] @@ -798,7 +798,7 @@ class LiteLLMCompletionResponsesConfig: message_dict["tool_call_id"] = tool_call_id elif hasattr(message, "tool_call_id"): setattr(message, "tool_call_id", tool_call_id) - + # Only remove messages with empty tool_call_id if we have other non-tool messages # This prevents ending up with an empty messages list when using previous_response_id # without a database (e.g., in tests where session messages are empty) @@ -810,7 +810,7 @@ class LiteLLMCompletionResponsesConfig: # If no non-tool messages, keep the tool message even with empty call_id # The API will return a proper error message about the missing tool_use block continue - + # Check if the previous assistant message has the corresponding tool_call # This needs to run for ALL tool messages with a valid tool_call_id, # not just those that had an empty tool_call_id initially @@ -819,12 +819,12 @@ class LiteLLMCompletionResponsesConfig: tool_calls = LiteLLMCompletionResponsesConfig._get_tool_calls_list( prev_assistant ) - + if not LiteLLMCompletionResponsesConfig._check_tool_call_exists( tool_calls, tool_call_id ): _tool_use_definition = TOOL_CALLS_CACHE.get_cache(key=tool_call_id) - + if not _tool_use_definition and tools: _tool_use_definition = ( LiteLLMCompletionResponsesConfig._reconstruct_tool_call_from_tools( @@ -849,11 +849,11 @@ class LiteLLMCompletionResponsesConfig: LiteLLMCompletionResponsesConfig._add_tool_call_to_assistant( prev_assistant, tool_call_chunk ) - + # Remove messages with empty tool_call_id that couldn't be fixed for idx in reversed(messages_to_remove): fixed_messages.pop(idx) - + return fixed_messages @staticmethod @@ -1454,6 +1454,39 @@ class LiteLLMCompletionResponsesConfig: return tool_call_dict + @staticmethod + def convert_apply_patch_tool_call_to_chat_completion_tool_call( + tool_call_item: Any, + index: int = 0, + ) -> Dict[str, Any]: + """ + Convert ResponseApplyPatchToolCall to ChatCompletionToolCallChunk format. + + The operation (create_file / update_file / delete_file) is serialised + as JSON so it appears in function.arguments, just like any other + tool call. + + Args: + tool_call_item: ResponseApplyPatchToolCall object with call_id and operation + index: The index of this tool call + + Returns: + Dictionary in ChatCompletionToolCallChunk format + """ + import json + + operation_dict = tool_call_item.operation.model_dump() + tool_call_dict: Dict[str, Any] = { + "id": tool_call_item.call_id, + "function": { + "name": "apply_patch", + "arguments": json.dumps(operation_dict), + }, + "type": "function", + "index": index, + } + return tool_call_dict + @staticmethod def transform_chat_completion_response_to_responses_api_response( request_input: Union[str, ResponseInputParam], diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index f8a082ee30c..25e8a1f3304 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -1012,11 +1012,11 @@ def test_multiple_tool_calls_in_single_choice(): def test_map_reasoning_effort_adds_summary_detailed(): """ Test that _map_reasoning_effort behavior with reasoning_auto_summary flag. - + By default (flag=False), summary should NOT be added to avoid: 1. Breaking for users without verified OpenAI orgs (400 errors) 2. Making requests more expensive by including summary reasoning tokens - + When flag is enabled (flag=True or env var), summary="detailed" is added. """ import os @@ -1030,64 +1030,64 @@ def test_map_reasoning_effort_adds_summary_detailed(): # Test all string effort levels - DEFAULT BEHAVIOR (no summary) effort_levels = ["none", "low", "medium", "high", "xhigh", "minimal"] - + # Save original flag value original_flag = litellm.reasoning_auto_summary original_env = os.environ.get("LITELLM_REASONING_AUTO_SUMMARY") - + try: # Test 1: Default behavior (flag=False, no env var) - NO summary litellm.reasoning_auto_summary = False if "LITELLM_REASONING_AUTO_SUMMARY" in os.environ: del os.environ["LITELLM_REASONING_AUTO_SUMMARY"] - + for effort in effort_levels: result = handler._map_reasoning_effort(effort) - + assert result is not None, f"Result should not be None for effort={effort}" assert result["effort"] == effort, f"Effort should be {effort}" assert "summary" not in result, f"Summary should NOT be present by default for effort={effort}" - + print(f"✓ reasoning_effort='{effort}' correctly maps to effort='{effort}' (no summary by default)") - + # Test 2: With flag enabled - summary IS added litellm.reasoning_auto_summary = True - + for effort in effort_levels: result = handler._map_reasoning_effort(effort) - + assert result is not None, f"Result should not be None for effort={effort}" assert result["effort"] == effort, f"Effort should be {effort}" assert result["summary"] == "detailed", f"Summary should be 'detailed' when flag is enabled for effort={effort}" - + print(f"✓ reasoning_effort='{effort}' correctly maps to effort='{effort}', summary='detailed' (flag enabled)") - + # Test 3: With env var enabled (flag disabled) - summary IS added litellm.reasoning_auto_summary = False os.environ["LITELLM_REASONING_AUTO_SUMMARY"] = "true" - + result = handler._map_reasoning_effort("high") assert result["summary"] == "detailed", "Summary should be 'detailed' when env var is enabled" print("✓ LITELLM_REASONING_AUTO_SUMMARY env var works correctly") - + # Test 4: Dict input is passed through as-is (no modification) litellm.reasoning_auto_summary = False if "LITELLM_REASONING_AUTO_SUMMARY" in os.environ: del os.environ["LITELLM_REASONING_AUTO_SUMMARY"] - + dict_input = {"effort": "high", "summary": "custom_summary"} result_dict = handler._map_reasoning_effort(dict_input) assert result_dict["effort"] == "high" assert result_dict["summary"] == "custom_summary" print("✓ Dict input is passed through without modification") - + # Test 5: None/unknown values return None result_unknown = handler._map_reasoning_effort("unknown_value") assert result_unknown is None print("✓ Unknown reasoning_effort values return None") - + print("✓ All reasoning_effort behaviors work correctly with flag/env var control") - + finally: # Restore original values litellm.reasoning_auto_summary = original_flag @@ -1100,10 +1100,10 @@ def test_map_reasoning_effort_adds_summary_detailed(): def test_transform_response_preserves_annotations(): """ Test that annotations from Responses API are preserved when transforming to Chat Completions format. - + This is a regression test for the bug where annotations (like url_citation) were being dropped during the transformation from ResponsesAPIResponse to ModelResponse. - + The fix ensures annotations are extracted from ResponseOutputText content items and passed through to the Message object in the Chat Completions response. """ @@ -1278,3 +1278,137 @@ def test_transform_response_preserves_annotations(): assert result.usage.total_tokens == 30 print("✓ Annotations from Responses API are correctly preserved in Chat Completions format") + + +def test_apply_patch_tool_call_converted_to_chat_completion_tool_call(): + """ + Test that ResponseApplyPatchToolCall items from the Responses API are + correctly converted to ChatCompletions-style tool calls by the bridge. + + This is a regression test for a bug where litellm.completion() with a + responses/ model prefix crashed when the model returned an + apply_patch_call, because _convert_response_output_to_choices did not + handle ResponseApplyPatchToolCall items. The model DID use the tool, + but the bridge silently dropped it (or raised an error), while the + native litellm.responses() path worked correctly. + """ + import json + from unittest.mock import Mock + + from openai.types.responses.response_apply_patch_tool_call import ( + OperationCreateFile, + ) + from openai.types.responses.response_output_item import ( + ResponseApplyPatchToolCall, + ) + + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + from litellm.types.llms.openai import ( + InputTokensDetails, + OutputTokensDetails, + ResponseAPIUsage, + ResponsesAPIResponse, + ) + from litellm.types.utils import ModelResponse, Usage + + handler = LiteLLMResponsesTransformationHandler() + + # Build an apply_patch_call item like the model would return + operation = OperationCreateFile( + diff="--- /dev/null\n+++ b/hello.py\n@@ -0,0 +1 @@\n+print('hello world')\n", + path="hello.py", + type="create_file", + ) + apply_patch_item = ResponseApplyPatchToolCall( + id="apc_001", + call_id="call_patch_hello", + operation=operation, + status="completed", + type="apply_patch_call", + ) + + # Minimal usage + usage = ResponseAPIUsage( + input_tokens=30, + input_tokens_details=InputTokensDetails(cached_tokens=0), + output_tokens=40, + output_tokens_details=OutputTokensDetails(reasoning_tokens=0), + total_tokens=70, + ) + + raw_response = ResponsesAPIResponse( + id="resp_apply_patch_test", + created_at=1234567890, + error=None, + incomplete_details=None, + instructions=None, + metadata={}, + model="gpt-5.2-codex", + object="response", + output=[apply_patch_item], + parallel_tool_calls=True, + temperature=1.0, + tool_choice="auto", + tools=[], + top_p=1.0, + max_output_tokens=None, + previous_response_id=None, + reasoning=None, + status="completed", + text=None, + truncation="disabled", + usage=usage, + user=None, + store=True, + background=False, + ) + + model_response = ModelResponse( + id="chatcmpl-apply-patch", + created=1234567890, + model=None, + object="chat.completion", + choices=[], + usage=Usage(completion_tokens=0, prompt_tokens=0, total_tokens=0), + ) + + logging_obj = Mock() + + result = handler.transform_response( + model="gpt-5.2-codex", + raw_response=raw_response, + model_response=model_response, + logging_obj=logging_obj, + request_data={"model": "gpt-5.2-codex"}, + messages=[ + {"role": "system", "content": "You are a coding assistant."}, + {"role": "user", "content": "Create hello.py"}, + ], + optional_params={}, + litellm_params={}, + encoding=Mock(), + ) + + # Should have exactly one choice with finish_reason="tool_calls" + assert len(result.choices) == 1, f"Expected 1 choice, got {len(result.choices)}" + + choice = result.choices[0] + assert choice.finish_reason == "tool_calls" + + # The choice should contain one tool call for apply_patch + tool_calls = choice.message.tool_calls + assert tool_calls is not None, "tool_calls should not be None" + assert len(tool_calls) == 1, f"Expected 1 tool_call, got {len(tool_calls)}" + + tc = tool_calls[0] + assert tc["id"] == "call_patch_hello" + assert tc["type"] == "function" + assert tc["function"]["name"] == "apply_patch" + + # The operation should be serialised as JSON in arguments + args = json.loads(tc["function"]["arguments"]) + assert args["type"] == "create_file" + assert args["path"] == "hello.py" + assert "print('hello world')" in args["diff"] From ae613b2d36f92a700077884234b0076af67cfb85 Mon Sep 17 00:00:00 2001 From: Atharva Jaiswal <92455570+AtharvaJaiswal005@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:28:08 +0530 Subject: [PATCH 011/380] fix(router): break retry loop on non-retryable errors (#21370) The retry loop in async_function_with_retries catches all exceptions blindly and continues retrying even for non-retryable errors like 400 ContextWindowExceeded or 404 NotFoundError. This causes the original retryable error to be raised instead of the actual non-retryable one. Changes: - Update original_exception to latest error on each retry attempt - Add should_retry_this_error() check inside the retry loop to break out immediately on non-retryable errors - Respect _retry_policy_applies precedence Fixes #21343 --- litellm/router.py | 22 ++ .../test_router_retry_non_retryable_errors.py | 251 ++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 tests/test_litellm/test_router_retry_non_retryable_errors.py diff --git a/litellm/router.py b/litellm/router.py index 888c97ca0b1..3fac761ce60 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -5149,6 +5149,10 @@ class Router: return response except Exception as e: + # Always track the latest error so we raise the most + # recent exception instead of the first one. + original_exception = e + ## LOGGING kwargs = self.log_retry(kwargs=kwargs, e=e) remaining_retries = num_retries - current_attempt - 1 @@ -5163,6 +5167,24 @@ class Router: ) else: _healthy_deployments = [] + + # Check if this error is non-retryable (e.g., 400 context + # window exceeded). If so, raise immediately instead of + # continuing the retry loop. Respect retry policy + # precedence - only check when no retry policy applies. + if not _retry_policy_applies: + try: + self.should_retry_this_error( + error=e, + healthy_deployments=_healthy_deployments, + all_deployments=_all_deployments, + context_window_fallbacks=context_window_fallbacks, + regular_fallbacks=fallbacks, + content_policy_fallbacks=content_policy_fallbacks, + ) + except Exception: + raise e + _timeout = self._time_to_sleep_before_retry( e=e, remaining_retries=remaining_retries, diff --git a/tests/test_litellm/test_router_retry_non_retryable_errors.py b/tests/test_litellm/test_router_retry_non_retryable_errors.py new file mode 100644 index 00000000000..20a1c979a04 --- /dev/null +++ b/tests/test_litellm/test_router_retry_non_retryable_errors.py @@ -0,0 +1,251 @@ +""" +Test that the Router retry loop correctly handles non-retryable errors. + +Verifies that: +1. Non-retryable errors (e.g., 400 ContextWindowExceeded) inside the retry loop + break out immediately instead of being swallowed. +2. original_exception is updated to the latest error, not stuck on the first. +3. Retryable errors (e.g., 429 RateLimitError) still retry normally. + +Regression tests for https://github.com/BerriAI/litellm/issues/21343 +""" + +from unittest.mock import AsyncMock, patch + +import pytest + +import litellm +from litellm import Router + + +def _make_rate_limit_error(message="Rate limited"): + """Create a RateLimitError for testing.""" + return litellm.RateLimitError( + message=message, + llm_provider="bedrock", + model="anthropic.claude-v2", + ) + + +def _make_context_window_error(message="prompt is too long: 1205821 tokens > 200000"): + """Create a ContextWindowExceededError for testing.""" + return litellm.ContextWindowExceededError( + message=message, + llm_provider="vertex_ai", + model="claude-3-opus", + ) + + +def _make_bad_request_error(message="Invalid request"): + """Create a BadRequestError for testing.""" + return litellm.BadRequestError( + message=message, + llm_provider="openai", + model="gpt-4", + ) + + +def _make_not_found_error(message="Model not found"): + """Create a NotFoundError for testing.""" + return litellm.NotFoundError( + message=message, + llm_provider="openai", + model="gpt-99", + ) + + +def _create_router(num_retries=2): + """Create a Router with two deployments for testing.""" + return Router( + model_list=[ + { + "model_name": "test-model", + "litellm_params": { + "model": "openai/gpt-4", + "api_key": "fake-key-1", + }, + }, + { + "model_name": "test-model", + "litellm_params": { + "model": "openai/gpt-4", + "api_key": "fake-key-2", + }, + }, + ], + num_retries=num_retries, + ) + + +def _base_kwargs(): + """Return kwargs required by async_function_with_retries.""" + return { + "model": "test-model", + "messages": [{"role": "user", "content": "test"}], + "original_function": AsyncMock(), + "metadata": {}, + } + + +@pytest.mark.asyncio +async def test_non_retryable_error_in_retry_loop_raises_immediately(): + """ + When a non-retryable error (400 ContextWindowExceeded) occurs inside the + retry loop, the router should raise it immediately instead of swallowing it + and raising the original error. + + Scenario: First call -> 429, Retry -> 400 (non-retryable) + Expected: ContextWindowExceededError is raised, NOT RateLimitError + """ + router = _create_router(num_retries=2) + + rate_limit_error = _make_rate_limit_error() + context_window_error = _make_context_window_error() + + call_count = 0 + + async def mock_make_call(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise rate_limit_error + else: + raise context_window_error + + with patch.object(router, "make_call", side_effect=mock_make_call), \ + patch.object(router, "_async_get_healthy_deployments", + return_value=(["d1", "d2"], ["d1", "d2"])), \ + patch.object(router, "_time_to_sleep_before_retry", return_value=0), \ + patch.object(router, "log_retry", side_effect=lambda kwargs, e: kwargs): + with pytest.raises(litellm.ContextWindowExceededError): + await router.async_function_with_retries( + num_retries=2, + **_base_kwargs(), + ) + + +@pytest.mark.asyncio +async def test_bad_request_error_in_retry_loop_raises_immediately(): + """ + A generic 400 BadRequestError inside the retry loop should also break out + immediately since 400 is not retryable. + """ + router = _create_router(num_retries=2) + + rate_limit_error = _make_rate_limit_error() + bad_request_error = _make_bad_request_error() + + call_count = 0 + + async def mock_make_call(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise rate_limit_error + else: + raise bad_request_error + + with patch.object(router, "make_call", side_effect=mock_make_call), \ + patch.object(router, "_async_get_healthy_deployments", + return_value=(["d1", "d2"], ["d1", "d2"])), \ + patch.object(router, "_time_to_sleep_before_retry", return_value=0), \ + patch.object(router, "log_retry", side_effect=lambda kwargs, e: kwargs): + with pytest.raises(litellm.BadRequestError): + await router.async_function_with_retries( + num_retries=2, + **_base_kwargs(), + ) + + +@pytest.mark.asyncio +async def test_original_exception_updated_to_latest_error(): + """ + When all retries are exhausted with retryable errors, the LAST error + should be raised, not the first one. + """ + router = _create_router(num_retries=2) + + call_count = 0 + + async def mock_make_call(*args, **kwargs): + nonlocal call_count + call_count += 1 + raise _make_rate_limit_error(f"Rate limit attempt {call_count}") + + with patch.object(router, "make_call", side_effect=mock_make_call), \ + patch.object(router, "_async_get_healthy_deployments", + return_value=(["d1", "d2"], ["d1", "d2"])), \ + patch.object(router, "_time_to_sleep_before_retry", return_value=0), \ + patch.object(router, "log_retry", side_effect=lambda kwargs, e: kwargs): + with pytest.raises(litellm.RateLimitError) as exc_info: + await router.async_function_with_retries( + num_retries=2, + **_base_kwargs(), + ) + # Should be the LAST error, not the first + assert "Rate limit attempt 3" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_retryable_errors_still_retry_normally(): + """ + Retryable errors (429 RateLimitError) should still be retried the + configured number of times before raising. + """ + router = _create_router(num_retries=3) + + call_count = 0 + + async def mock_make_call(*args, **kwargs): + nonlocal call_count + call_count += 1 + raise _make_rate_limit_error(f"Rate limit attempt {call_count}") + + with patch.object(router, "make_call", side_effect=mock_make_call), \ + patch.object(router, "_async_get_healthy_deployments", + return_value=(["d1", "d2"], ["d1", "d2"])), \ + patch.object(router, "_time_to_sleep_before_retry", return_value=0), \ + patch.object(router, "log_retry", side_effect=lambda kwargs, e: kwargs): + with pytest.raises(litellm.RateLimitError): + await router.async_function_with_retries( + num_retries=3, + **_base_kwargs(), + ) + + # Initial call + 3 retries = 4 total calls + assert call_count == 4 + + +@pytest.mark.asyncio +async def test_not_found_error_in_retry_loop_raises_immediately(): + """ + A 404 NotFoundError inside the retry loop should break out immediately. + """ + router = _create_router(num_retries=2) + + rate_limit_error = _make_rate_limit_error() + not_found_error = _make_not_found_error() + + call_count = 0 + + async def mock_make_call(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise rate_limit_error + else: + raise not_found_error + + with patch.object(router, "make_call", side_effect=mock_make_call), \ + patch.object(router, "_async_get_healthy_deployments", + return_value=(["d1", "d2"], ["d1", "d2"])), \ + patch.object(router, "_time_to_sleep_before_retry", return_value=0), \ + patch.object(router, "log_retry", side_effect=lambda kwargs, e: kwargs): + with pytest.raises(litellm.NotFoundError): + await router.async_function_with_retries( + num_retries=2, + **_base_kwargs(), + ) + + # Only 2 calls: initial + first retry that hits non-retryable + assert call_count == 2 From 42afba9cdd3ec78270c84da0e6e915d158105434 Mon Sep 17 00:00:00 2001 From: Atharva Jaiswal <92455570+AtharvaJaiswal005@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:29:01 +0530 Subject: [PATCH 012/380] Fix invalid OpenAPI schema for /spend/calculate and /credentials endpoints (#21369) - /spend/calculate: wrap response in proper OpenAPI 3.x content structure - /credentials: split stacked route decorators into separate handlers to eliminate path parameter conflict between by_name and by_model routes --- .../proxy/credential_endpoints/endpoints.py | 97 ++++++------ .../spend_management_endpoints.py | 20 ++- .../proxy/test_openapi_schema_validation.py | 142 ++++++++++++++++++ 3 files changed, 209 insertions(+), 50 deletions(-) create mode 100644 tests/test_litellm/proxy/test_openapi_schema_validation.py diff --git a/litellm/proxy/credential_endpoints/endpoints.py b/litellm/proxy/credential_endpoints/endpoints.py index 9f228bb1184..5fa9546e006 100644 --- a/litellm/proxy/credential_endpoints/endpoints.py +++ b/litellm/proxy/credential_endpoints/endpoints.py @@ -142,17 +142,47 @@ async def get_credentials( tags=["credential management"], response_model=CredentialItem, ) +async def get_credential_by_name( + request: Request, + fastapi_response: Response, + credential_name: str = Path(..., description="The credential name, percent-decoded; may contain slashes"), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + [BETA] endpoint. This might change unexpectedly. + """ + try: + for credential in litellm.credential_list: + if credential.credential_name == credential_name: + masked_credential = CredentialItem( + credential_name=credential.credential_name, + credential_values=_get_masked_values( + credential.credential_values, + unmasked_length=4, + number_of_asterisks=4, + ), + credential_info=credential.credential_info, + ) + return masked_credential + raise HTTPException( + status_code=404, + detail="Credential not found. Got credential name: " + credential_name, + ) + except Exception as e: + verbose_proxy_logger.exception(e) + raise handle_exception_on_proxy(e) + + @router.get( "/credentials/by_model/{model_id}", dependencies=[Depends(user_api_key_auth)], tags=["credential management"], response_model=CredentialItem, ) -async def get_credential( +async def get_credential_by_model( request: Request, fastapi_response: Response, - credential_name: str = Path(..., description="The credential name, percent-decoded; may contain slashes"), - model_id: Optional[str] = None, + model_id: str = Path(..., description="The model ID to look up credentials for"), user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ @@ -161,48 +191,25 @@ async def get_credential( from litellm.proxy.proxy_server import llm_router try: - if model_id: - if llm_router is None: - raise HTTPException(status_code=500, detail="LLM router not found") - model = llm_router.get_deployment(model_id) - if model is None: - raise HTTPException(status_code=404, detail="Model not found") - credential_values = llm_router.get_deployment_credentials(model_id) - if credential_values is None: - raise HTTPException(status_code=404, detail="Model not found") - masked_credential_values = _get_masked_values( - credential_values, - unmasked_length=4, - number_of_asterisks=4, - ) - credential = CredentialItem( - credential_name="{}-credential-{}".format(model.model_name, model_id), - credential_values=masked_credential_values, - credential_info={}, - ) - # return credential object - return credential - elif credential_name: - for credential in litellm.credential_list: - if credential.credential_name == credential_name: - masked_credential = CredentialItem( - credential_name=credential.credential_name, - credential_values=_get_masked_values( - credential.credential_values, - unmasked_length=4, - number_of_asterisks=4, - ), - credential_info=credential.credential_info, - ) - return masked_credential - raise HTTPException( - status_code=404, - detail="Credential not found. Got credential name: " + credential_name, - ) - else: - raise HTTPException( - status_code=404, detail="Credential name or model ID required" - ) + if llm_router is None: + raise HTTPException(status_code=500, detail="LLM router not found") + model = llm_router.get_deployment(model_id) + if model is None: + raise HTTPException(status_code=404, detail="Model not found") + credential_values = llm_router.get_deployment_credentials(model_id) + if credential_values is None: + raise HTTPException(status_code=404, detail="Model not found") + masked_credential_values = _get_masked_values( + credential_values, + unmasked_length=4, + number_of_asterisks=4, + ) + credential = CredentialItem( + credential_name="{}-credential-{}".format(model.model_name, model_id), + credential_values=masked_credential_values, + credential_info={}, + ) + return credential except Exception as e: verbose_proxy_logger.exception(e) raise handle_exception_on_proxy(e) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 08aaa851691..92770a5c803 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -1461,11 +1461,21 @@ async def _get_spend_report_for_time_range( dependencies=[Depends(user_api_key_auth)], responses={ 200: { - "cost": { - "description": "The calculated cost", - "example": 0.0, - "type": "float", - } + "description": "The calculated cost", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cost": { + "type": "number", + "description": "The calculated cost", + "example": 0.0, + } + }, + } + } + }, } }, ) diff --git a/tests/test_litellm/proxy/test_openapi_schema_validation.py b/tests/test_litellm/proxy/test_openapi_schema_validation.py new file mode 100644 index 00000000000..aafe08f3033 --- /dev/null +++ b/tests/test_litellm/proxy/test_openapi_schema_validation.py @@ -0,0 +1,142 @@ +""" +Test that the OpenAPI schema generated by FastAPI is valid for specific endpoints. + +Validates fixes for: +- /spend/calculate response schema (must use proper OpenAPI 3.x content wrapper) +- /credentials/by_model/{model_id} path parameter (must not leak credential_name) + +Related issue: https://github.com/BerriAI/litellm/issues/21305 +""" + +import pytest + + +class TestSpendCalculateOpenAPISchema: + """Test /spend/calculate response schema is valid OpenAPI 3.x.""" + + def test_response_schema_has_description(self): + """The 200 response must have a 'description' field per OpenAPI 3.x spec.""" + from litellm.proxy.spend_tracking.spend_management_endpoints import router + + for route in router.routes: + if hasattr(route, "path") and route.path == "/spend/calculate": + responses = route.responses or {} + response_200 = responses.get(200, {}) + assert "description" in response_200, ( + "/spend/calculate 200 response must have a 'description' field" + ) + break + else: + pytest.fail("/spend/calculate route not found in router") + + def test_response_schema_has_content_wrapper(self): + """The 200 response must use 'content' wrapper, not bare properties.""" + from litellm.proxy.spend_tracking.spend_management_endpoints import router + + for route in router.routes: + if hasattr(route, "path") and route.path == "/spend/calculate": + responses = route.responses or {} + response_200 = responses.get(200, {}) + # Must NOT have 'cost' as a top-level key (invalid OpenAPI) + assert "cost" not in response_200, ( + "/spend/calculate 200 response must not have 'cost' as a " + "top-level property - use 'content' wrapper instead" + ) + # Must have 'content' wrapper + assert "content" in response_200, ( + "/spend/calculate 200 response must have a 'content' field" + ) + content = response_200["content"] + assert "application/json" in content + assert "schema" in content["application/json"] + break + else: + pytest.fail("/spend/calculate route not found in router") + + +class TestCredentialEndpointsOpenAPISchema: + """Test /credentials endpoints have correct path parameters.""" + + def test_by_name_and_by_model_are_separate_handlers(self): + """ + /credentials/by_name/{credential_name} and /credentials/by_model/{model_id} + must be separate handler functions so each only declares its own path params. + """ + from litellm.proxy.credential_endpoints.endpoints import router + + by_name_routes = [] + by_model_routes = [] + for route in router.routes: + if not hasattr(route, "path"): + continue + if "by_name" in route.path: + by_name_routes.append(route) + elif "by_model" in route.path: + by_model_routes.append(route) + + assert len(by_name_routes) == 1, "Expected exactly one by_name route" + assert len(by_model_routes) == 1, "Expected exactly one by_model route" + + # They must be different endpoint functions + by_name_endpoint = by_name_routes[0].endpoint + by_model_endpoint = by_model_routes[0].endpoint + assert by_name_endpoint is not by_model_endpoint, ( + "by_name and by_model must be separate handler functions " + "to avoid path parameter conflicts in OpenAPI spec" + ) + + def test_by_model_route_does_not_require_credential_name(self): + """ + The /credentials/by_model/{model_id} route must NOT have + credential_name as a parameter. + """ + import inspect + from litellm.proxy.credential_endpoints.endpoints import ( + get_credential_by_model, + ) + + sig = inspect.signature(get_credential_by_model) + param_names = list(sig.parameters.keys()) + assert "credential_name" not in param_names, ( + "get_credential_by_model must not have a credential_name parameter" + ) + + def test_by_name_route_does_not_require_model_id(self): + """ + The /credentials/by_name/{credential_name} route must NOT have + model_id as a parameter. + """ + import inspect + from litellm.proxy.credential_endpoints.endpoints import ( + get_credential_by_name, + ) + + sig = inspect.signature(get_credential_by_name) + param_names = list(sig.parameters.keys()) + assert "model_id" not in param_names, ( + "get_credential_by_name must not have a model_id parameter" + ) + + def test_by_model_has_model_id_path_param(self): + """The by_model handler must accept model_id as a path parameter.""" + import inspect + from litellm.proxy.credential_endpoints.endpoints import ( + get_credential_by_model, + ) + + sig = inspect.signature(get_credential_by_model) + assert "model_id" in sig.parameters, ( + "get_credential_by_model must have a model_id parameter" + ) + + def test_by_name_has_credential_name_path_param(self): + """The by_name handler must accept credential_name as a path parameter.""" + import inspect + from litellm.proxy.credential_endpoints.endpoints import ( + get_credential_by_name, + ) + + sig = inspect.signature(get_credential_by_name) + assert "credential_name" in sig.parameters, ( + "get_credential_by_name must have a credential_name parameter" + ) From 518cd3ef60e5809947dbf2d262c7edd47782a9ba Mon Sep 17 00:00:00 2001 From: Dibyo Mukherjee Date: Thu, 5 Feb 2026 19:40:41 -0500 Subject: [PATCH 013/380] feat(ui): add key creation deep-links with SSO return URL support Enables deep-linking directly to the key creation modal with prefilled form data via URL parameters, including support for preserving these deep-links through SSO authentication flows. Key Creation Deep-links: - Auto-open key creation modal via ?create=true parameter - Prefill form fields from URL parameters (team_id, key_alias, models, etc.) - Role-based access control for auto-open (requires write access) - Race condition protection for redirect handling Example: /ui?create=true&team_id=abc&key_alias=my-key&models=gpt-4,claude-3 SSO Return URL Preservation: - Cookie-based return URL storage (works across ports for SSO flows) - URL validation to prevent open redirect attacks - Support for both dev and production environments Co-Authored-By: Claude Opus 4.5 --- .../(dashboard)/hooks/useAuthorized.test.ts | 12 +- .../app/(dashboard)/hooks/useAuthorized.ts | 45 +- .../src/app/login/LoginPage.tsx | 25 +- ui/litellm-dashboard/src/app/page.tsx | 136 +++++-- .../organisms/create_key_button.test.tsx | 367 ++++++++++++++--- .../organisms/create_key_button.tsx | 93 ++++- .../src/components/user_dashboard.tsx | 8 +- .../src/utils/returnUrlUtils.test.ts | 383 ++++++++++++++++++ .../src/utils/returnUrlUtils.ts | 304 ++++++++++++++ ui/litellm-dashboard/src/utils/roles.ts | 29 ++ .../tests/CreateKeyPage.expiredToken.test.tsx | 55 ++- 11 files changed, 1315 insertions(+), 142 deletions(-) create mode 100644 ui/litellm-dashboard/src/utils/returnUrlUtils.test.ts create mode 100644 ui/litellm-dashboard/src/utils/returnUrlUtils.ts 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 76a3129d6d7..5178aca0790 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts @@ -8,13 +8,14 @@ import useAuthorized from "./useAuthorized"; // Unmock useAuthorized to test the actual implementation vi.unmock("@/app/(dashboard)/hooks/useAuthorized"); -const { replaceMock, clearTokenCookiesMock, getProxyBaseUrlMock, getUiConfigMock, decodeTokenMock, checkTokenValidityMock } = vi.hoisted(() => ({ +const { replaceMock, clearTokenCookiesMock, getProxyBaseUrlMock, getUiConfigMock, decodeTokenMock, checkTokenValidityMock, buildLoginUrlWithReturnMock } = vi.hoisted(() => ({ replaceMock: vi.fn(), clearTokenCookiesMock: vi.fn(), getProxyBaseUrlMock: vi.fn(() => "http://proxy.example"), getUiConfigMock: vi.fn(), decodeTokenMock: vi.fn(), checkTokenValidityMock: vi.fn(), + buildLoginUrlWithReturnMock: vi.fn((baseUrl: string) => baseUrl), })); vi.mock("next/navigation", () => ({ @@ -49,6 +50,14 @@ vi.mock("@/utils/jwtUtils", async (importOriginal) => { }; }); +vi.mock("@/utils/returnUrlUtils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildLoginUrlWithReturn: buildLoginUrlWithReturnMock, + storeReturnUrl: vi.fn(), + }; +}); const createQueryClient = () => new QueryClient({ defaultOptions: { @@ -81,6 +90,7 @@ describe("useAuthorized", () => { getUiConfigMock.mockReset(); decodeTokenMock.mockReset(); checkTokenValidityMock.mockReset(); + buildLoginUrlWithReturnMock.mockClear(); clearCookie(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts index 0b60971c1eb..8f8c403a4e9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts @@ -3,39 +3,12 @@ import { getProxyBaseUrl } from "@/components/networking"; import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; import { checkTokenValidity, decodeToken } from "@/utils/jwtUtils"; +import { buildLoginUrlWithReturn, storeReturnUrl } from "@/utils/returnUrlUtils"; import { useRouter } from "next/navigation"; -import { useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; +import { formatUserRole } from "@/utils/roles"; import { useUIConfig } from "./uiConfig/useUIConfig"; -function formatUserRole(userRole: string) { - if (!userRole) { - return "Undefined Role"; - } - switch (userRole.toLowerCase()) { - case "app_owner": - return "App Owner"; - case "demo_app_owner": - return "App Owner"; - case "app_admin": - return "Admin"; - case "proxy_admin": - return "Admin"; - case "proxy_admin_viewer": - return "Admin Viewer"; - case "org_admin": - return "Org Admin"; - case "internal_user": - return "Internal User"; - case "internal_user_viewer": - case "internal_viewer": // TODO:remove if deprecated - return "Internal Viewer"; - case "app_user": - return "App User"; - default: - return "Unknown Role"; - } -} - const useAuthorized = () => { const router = useRouter(); const { data: uiConfig, isLoading: isUIConfigLoading } = useUIConfig(); @@ -47,6 +20,14 @@ const useAuthorized = () => { const isLoading = isUIConfigLoading; const isAuthorized = isTokenValid && !uiConfig?.admin_ui_disabled; + // Helper function to redirect to login while preserving the current URL + const redirectToLogin = useCallback(() => { + storeReturnUrl(); + const baseLoginUrl = `${getProxyBaseUrl()}/ui/login`; + const loginUrlWithReturn = buildLoginUrlWithReturn(baseLoginUrl); + router.replace(loginUrlWithReturn); + }, [router]); + // Single useEffect for all redirect logic useEffect(() => { if (isLoading) return; @@ -55,9 +36,9 @@ const useAuthorized = () => { if (token) { clearTokenCookies(); } - router.replace(`${getProxyBaseUrl()}/ui/login`); + redirectToLogin(); } - }, [isLoading, isAuthorized, token, router]); + }, [isLoading, isAuthorized, token, redirectToLogin]); return { isLoading, diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.tsx index a05fa4e214e..80372fcddca 100644 --- a/ui/litellm-dashboard/src/app/login/LoginPage.tsx +++ b/ui/litellm-dashboard/src/app/login/LoginPage.tsx @@ -6,6 +6,7 @@ import LoadingScreen from "@/components/common_components/LoadingScreen"; import { getProxyBaseUrl } from "@/components/networking"; import { getCookie } from "@/utils/cookieUtils"; import { isJwtExpired } from "@/utils/jwtUtils"; +import { consumeReturnUrl, getReturnUrl, isValidReturnUrl } from "@/utils/returnUrlUtils"; import { InfoCircleOutlined } from "@ant-design/icons"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Alert, Button, Card, Form, Input, Popover, Space, Typography } from "antd"; @@ -33,12 +34,24 @@ function LoginPageContent() { const rawToken = getCookie("token"); if (rawToken && !isJwtExpired(rawToken)) { - router.replace(`${getProxyBaseUrl()}/ui`); + // User already logged in - redirect to return URL or default + const returnUrl = consumeReturnUrl(); + if (returnUrl) { + router.replace(returnUrl); + } else { + router.replace(`${getProxyBaseUrl()}/ui`); + } return; } if (uiConfig && uiConfig.auto_redirect_to_sso) { - router.push(`${getProxyBaseUrl()}/sso/key/generate`); + // For SSO, pass the return URL to the SSO endpoint + const returnUrl = getReturnUrl(); + let ssoUrl = `${getProxyBaseUrl()}/sso/key/generate`; + if (returnUrl && isValidReturnUrl(returnUrl)) { + ssoUrl += `?redirect_to=${encodeURIComponent(returnUrl)}`; + } + router.push(ssoUrl); return; } @@ -50,7 +63,13 @@ function LoginPageContent() { { username, password }, { onSuccess: (data) => { - router.push(data.redirect_url); + // Check if we have a return URL to use instead of the default redirect + const returnUrl = consumeReturnUrl(); + if (returnUrl) { + router.push(returnUrl); + } else { + router.push(data.redirect_url); + } }, }, ); diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 258c2ccb0e0..26aebf3e3d2 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -23,7 +23,7 @@ import Navbar from "@/components/navbar"; import { getUiConfig, Organization, proxyBaseUrl, setGlobalLitellmHeaderName, getInProductNudgesCall } from "@/components/networking"; import NewUsagePage from "@/components/UsagePage/components/UsagePageView"; import OldTeams from "@/components/OldTeams"; -import { fetchUserModels } from "@/components/organisms/create_key_button"; +import { fetchUserModels, CreateKeyPrefillData } from "@/components/organisms/create_key_button"; import Organizations, { fetchOrganizations } from "@/components/organizations"; import PassThroughSettings from "@/components/pass_through_settings"; import PromptsPanel from "@/components/prompts"; @@ -43,11 +43,12 @@ import SpendLogsTable from "@/components/view_logs"; import ViewUserDashboard from "@/components/view_users"; import { ThemeProvider } from "@/contexts/ThemeContext"; import { isJwtExpired } from "@/utils/jwtUtils"; -import { isAdminRole } from "@/utils/roles"; +import { buildLoginUrlWithReturn, consumeReturnUrl, normalizeUrlForCompare, storeReturnUrl } from "@/utils/returnUrlUtils"; +import { formatUserRole, isAdminRole } from "@/utils/roles"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { jwtDecode } from "jwt-decode"; import { useSearchParams } from "next/navigation"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { ConfigProvider, theme } from "antd"; function getCookie(name: string) { @@ -67,35 +68,6 @@ function deleteCookie(name: string, path = "/") { document.cookie = `${name}=; Max-Age=0; Path=${path}`; } -function formatUserRole(userRole: string) { - if (!userRole) { - return "Undefined Role"; - } - switch (userRole.toLowerCase()) { - case "app_owner": - return "App Owner"; - case "demo_app_owner": - return "App Owner"; - case "app_admin": - return "Admin"; - case "proxy_admin": - return "Admin"; - case "proxy_admin_viewer": - return "Admin Viewer"; - case "org_admin": - return "Org Admin"; - case "internal_user": - return "Internal User"; - case "internal_user_viewer": - case "internal_viewer": // TODO:remove if deprecated - return "Internal Viewer"; - case "app_user": - return "App User"; - default: - return "Unknown Role"; - } -} - interface ProxySettings { PROXY_BASE_URL: string; PROXY_LOGOUT_URL: string; @@ -143,6 +115,58 @@ function CreateKeyPageContent() { const invitation_id = searchParams.get("invitation_id"); + // Parse URL query parameters for pre-filling the create key form + // Includes validation to prevent injection and DoS attacks + const autoOpenCreate = searchParams.get("create") === "true"; + const prefillData: CreateKeyPrefillData | undefined = useMemo(() => { + if (!autoOpenCreate) return undefined; + + const ownedBy = searchParams.get("owned_by"); + const teamId = searchParams.get("team_id"); + const keyAlias = searchParams.get("key_alias"); + const modelsParam = searchParams.get("models"); + const keyType = searchParams.get("key_type"); + + // Only return prefill data if at least one field is provided + if (!ownedBy && !teamId && !keyAlias && !modelsParam && !keyType) { + return undefined; + } + + // Validate owned_by against allowed values + const validOwnedByValues = ["you", "service_account", "another_user"]; + const validatedOwnedBy = ownedBy && validOwnedByValues.includes(ownedBy) + ? (ownedBy as CreateKeyPrefillData["owned_by"]) + : undefined; + + // Validate key_type against allowed values + const validKeyTypes = ["default", "llm_api", "management"]; + const validatedKeyType = keyType && validKeyTypes.includes(keyType) + ? (keyType as CreateKeyPrefillData["key_type"]) + : undefined; + + // Sanitize key_alias (limit length, trim whitespace) + const sanitizedKeyAlias = keyAlias + ? keyAlias.trim().slice(0, 256) // Reasonable max length + : undefined; + + // Sanitize models (limit array size and individual model name length) + const sanitizedModels = modelsParam + ? modelsParam + .split(",") + .slice(0, 100) // Limit number of models to prevent DoS + .map(m => m.trim().slice(0, 256)) // Limit individual model name length + .filter(m => m.length > 0) // Remove empty strings + : undefined; + + return { + owned_by: validatedOwnedBy, + team_id: teamId?.trim() || undefined, + key_alias: sanitizedKeyAlias, + models: sanitizedModels && sanitizedModels.length > 0 ? sanitizedModels : undefined, + key_type: validatedKeyType, + }; + }, [searchParams, autoOpenCreate]); + // Get page from URL, default to 'api-keys' if not present const [page, setPage] = useState(() => { return searchParams.get("page") || "api-keys"; @@ -163,6 +187,9 @@ function CreateKeyPageContent() { const [accessToken, setAccessToken] = useState(null); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + // Track if we've already attempted a return URL redirect to prevent race conditions + const hasAttemptedReturnRedirectRef = useRef(false); + const toggleSidebar = () => { setSidebarCollapsed(!sidebarCollapsed); }; @@ -207,12 +234,48 @@ function CreateKeyPageContent() { useEffect(() => { if (redirectToLogin) { + // Store the current URL so we can redirect back after login + storeReturnUrl(); + // Build login URL with return URL parameter + const baseLoginUrl = (proxyBaseUrl || "") + "/ui/login"; + const dest = buildLoginUrlWithReturn(baseLoginUrl); // Replace instead of assigning to avoid back-button loops - const dest = (proxyBaseUrl || "") + "/ui/login"; window.location.replace(dest); } }, [redirectToLogin]); + // Check for a stored return URL after successful authentication + // This handles the case where user comes back from SSO and we need to redirect to the original URL + useEffect(() => { + // Skip if still loading, no token, or we've already attempted a redirect + if (authLoading || !token || hasAttemptedReturnRedirectRef.current) { + return; + } + + // Mark that we've attempted the redirect to prevent race conditions + // This prevents duplicate redirects if token changes (e.g., refresh) + hasAttemptedReturnRedirectRef.current = true; + + // Check for a stored return URL + const returnUrl = consumeReturnUrl(); + if (returnUrl) { + const currentUrl = window.location.href; + const normalizedReturnUrl = normalizeUrlForCompare(returnUrl); + const normalizedCurrentUrl = normalizeUrlForCompare(currentUrl); + // Only redirect if the return URL is different from the current URL + // This prevents infinite redirect loops + if (normalizedReturnUrl !== normalizedCurrentUrl) { + window.location.replace(returnUrl); + } + } + }, [authLoading, token]); + + useEffect(() => { + if (!token) { + hasAttemptedReturnRedirectRef.current = false; + } + }, [token]); + useEffect(() => { if (!token) { return; @@ -410,9 +473,8 @@ function CreateKeyPageContent() { />
- -
- + +
{page == "api-keys" ? ( ) : page == "models" ? ( { - const fn = vi.fn().mockResolvedValue({ +const { formMock, setFieldsValueMock, radioGroupValueRef, formStateRef, mockKeyCreateCall } = vi.hoisted(() => { + const formStateRef = { current: {} as Record }; + const mockKeyCreateCall = vi.fn().mockResolvedValue({ key: "test-api-key", soft_budget: null, }); - return { mockKeyCreateCall: fn }; + const formMock = { + setFieldsValue: vi.fn((values: Record) => { + Object.assign(formStateRef.current, values); + }), + setFieldValue: vi.fn((name: string, value: any) => { + formStateRef.current[name] = value; + }), + getFieldValue: vi.fn((name: string) => formStateRef.current[name]), + resetFields: vi.fn(() => { + formStateRef.current = {}; + }), + }; + const radioGroupValueRef = { current: null as string | null }; + return { + formMock, + setFieldsValueMock: formMock.setFieldsValue, + radioGroupValueRef, + formStateRef, + mockKeyCreateCall, + }; +}); + +const defaultAuthorizedState = { + accessToken: "test-token", + userId: "test-user-id", + userRole: "Admin", + premiumUser: false, +}; + +let authorizedState = { ...defaultAuthorizedState }; + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => authorizedState, +})); + +vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ + keyKeys: { + lists: () => ["keys"], + }, +})); + +vi.mock("@ant-design/icons", () => ({ + InfoCircleOutlined: () => null, +})); + +vi.mock("react-copy-to-clipboard", () => ({ + CopyToClipboard: ({ children }: { children: any }) => children, +})); + +vi.mock("@tremor/react", () => { + const React = require("react"); + const Stub = ({ children }: { children?: any }) => React.createElement("div", null, children); + const Button = ({ children, ...props }: { children?: any }) => + React.createElement("button", props, children); + const TextInput = (props: any) => React.createElement("input", props); + + return { + Accordion: Stub, + AccordionBody: Stub, + AccordionHeader: Stub, + Button, + Col: Stub, + Grid: Stub, + Text: Stub, + TextInput, + Title: Stub, + }; +}); + +vi.mock("antd", () => { + const React = require("react"); + + const getValueFromEvent = (event: any) => { + if (event?.target) { + if (event.target.type === "checkbox") { + return event.target.checked; + } + return event.target.value; + } + return event; + }; + + const Form = ({ children, onFinish, ...props }: { children?: any; onFinish?: (values: Record) => void }) => + React.createElement( + "form", + { + ...props, + onSubmit: (event: Event) => { + event.preventDefault(); + onFinish?.({ ...formStateRef.current }); + }, + }, + children, + ); + + Form.Item = ({ children, name }: { children?: any; name?: string }) => { + if (!name || !React.isValidElement(children)) { + return React.createElement(React.Fragment, null, children); + } + + return React.cloneElement(children, { + value: formStateRef.current[name], + onChange: (event: any) => { + formStateRef.current[name] = getValueFromEvent(event); + }, + }); + }; + + Form.useForm = () => [formMock]; + + const Select = ({ children, onChange, ...props }: { children?: any; onChange?: (value: string) => void }) => + React.createElement( + "select", + { + ...props, + onChange: (event: any) => onChange?.(event.target.value), + }, + children, + ); + + Select.Option = ({ children, ...props }: { children?: any }) => + React.createElement("option", props, children); + + const Input = (props: any) => React.createElement("input", props); + Input.Password = (props: any) => React.createElement("input", { ...props, type: "password" }); + Input.TextArea = (props: any) => React.createElement("textarea", props); + + const Modal = ({ children, open }: { children?: any; open?: boolean }) => + open ? React.createElement("div", null, children) : null; + + const Radio = ({ children, ...props }: { children?: any }) => + React.createElement("div", props, children); + + Radio.Group = ({ children, value }: { children?: any; value?: string }) => { + radioGroupValueRef.current = value ?? null; + return React.createElement("div", null, children); + }; + + const Switch = (props: any) => React.createElement("input", { ...props, type: "checkbox" }); + const Tag = ({ children }: { children?: any }) => React.createElement("span", null, children); + const Tooltip = ({ children }: { children?: any }) => React.createElement(React.Fragment, null, children); + + const Button = ({ children, htmlType, ...props }: { children?: any; htmlType?: string }) => + React.createElement("button", { ...props, type: htmlType ?? props.type }, children); + + return { + Button, + Form, + Input, + message: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, + Modal, + Radio, + Select, + Switch, + Tag, + Tooltip, + }; }); vi.mock("../networking", () => ({ keyCreateCall: mockKeyCreateCall, - modelAvailableCall: vi.fn().mockResolvedValue({ data: [{ id: "gpt-4" }, { id: "gpt-3.5-turbo" }] }), + modelAvailableCall: vi.fn().mockResolvedValue({ data: [{ id: "gpt-4" }] }), getGuardrailsList: vi.fn().mockResolvedValue({ guardrails: [] }), + getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }), getPromptsList: vi.fn().mockResolvedValue({ prompts: [] }), proxyBaseUrl: "http://localhost:4000", getPossibleUserRoles: vi.fn().mockResolvedValue({ @@ -41,12 +204,31 @@ vi.mock("../molecules/notifications_manager", () => ({ }, })); +vi.mock("../agent_management/AgentSelector", () => ({ default: () => null })); +vi.mock("../common_components/budget_duration_dropdown", () => ({ default: () => null })); +vi.mock("../common_components/check_openapi_schema", () => ({ default: () => null })); +vi.mock("../common_components/KeyLifecycleSettings", () => ({ default: () => null })); +vi.mock("../common_components/ModelAliasManager", () => ({ default: () => null })); +vi.mock("../common_components/PassThroughRoutesSelector", () => ({ default: () => null })); +vi.mock("../common_components/PremiumLoggingSettings", () => ({ default: () => null })); +vi.mock("../common_components/RateLimitTypeFormItem", () => ({ default: () => null })); +vi.mock("../common_components/RouterSettingsAccordion", () => ({ default: () => null })); +vi.mock("../common_components/team_dropdown", () => ({ default: () => null })); +vi.mock("../CreateUserButton", () => ({ CreateUserButton: () => null })); +vi.mock("../mcp_server_management/MCPServerSelector", () => ({ default: () => null })); +vi.mock("../mcp_server_management/MCPToolPermissions", () => ({ default: () => null })); +vi.mock("../shared/numerical_input", () => ({ default: () => null })); +vi.mock("../vector_store_management/VectorStoreSelector", () => ({ default: () => null })); +vi.mock("../key_team_helpers/fetch_available_models_team_key", () => ({ + getModelDisplayName: (model: string) => model, +})); + vi.mock("../common_components/AccessGroupSelector", () => ({ default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => ( onChange?.(e.target.value ? e.target.value.split(",").map((s) => s.trim()) : [])} + onChange={(event) => onChange?.(event.target.value ? event.target.value.split(",").map((v) => v.trim()) : [])} /> ), })); @@ -54,14 +236,19 @@ vi.mock("../common_components/AccessGroupSelector", () => ({ describe("CreateKey", () => { const defaultProps = { team: null, - data: [], teams: [], + data: [], addKey: vi.fn(), }; beforeEach(() => { vi.clearAllMocks(); - localStorage.clear(); + if (typeof window !== "undefined" && window.localStorage && typeof window.localStorage.clear === "function") { + window.localStorage.clear(); + } + authorizedState = { ...defaultAuthorizedState }; + radioGroupValueRef.current = null; + formStateRef.current = {}; mockKeyCreateCall.mockResolvedValue({ key: "test-api-key", soft_budget: null, @@ -81,26 +268,8 @@ describe("CreateKey", () => { }); await waitFor(() => { - expect(screen.getByText("Key Type")).toBeInTheDocument(); - }); - - // Open the Key Type dropdown - const keyTypeSection = screen.getByText("Key Type").closest(".ant-form-item")!; - const selectElement = keyTypeSection.querySelector(".ant-select-selector")!; - act(() => { - fireEvent.mouseDown(selectElement); - }); - - await waitFor(() => { - // Verify "AI APIs" appears as an option - const options = document.querySelectorAll(".ant-select-item-option"); - const optionTexts = Array.from(options).map((el) => el.textContent); - const hasAIAPIs = optionTexts.some((text) => text?.includes("AI APIs")); - expect(hasAIAPIs).toBe(true); - - // Verify old "LLM API" label does NOT appear - const hasLLMAPI = optionTexts.some((text) => text?.includes("LLM API")); - expect(hasLLMAPI).toBe(false); + expect(screen.getByText("AI APIs")).toBeInTheDocument(); + expect(screen.queryByText("LLM API")).not.toBeInTheDocument(); }); }); @@ -111,46 +280,118 @@ describe("CreateKey", () => { fireEvent.click(screen.getByRole("button", { name: /create new key/i })); }); - await waitFor(() => { - expect(screen.getByLabelText(/key name/i)).toBeInTheDocument(); - }); - - fireEvent.change(screen.getByLabelText(/key name/i), { target: { value: "Test Key" } }); - - const optionalSettingsAccordion = screen.getByText("Optional Settings"); - act(() => { - fireEvent.click(optionalSettingsAccordion); - }); - await waitFor(() => { expect(screen.getByTestId("access-group-selector")).toBeInTheDocument(); }); - fireEvent.change(screen.getByTestId("access-group-selector"), { target: { value: "ag-1,ag-2" } }); + act(() => { + fireEvent.change(screen.getByTestId("access-group-selector"), { target: { value: "ag-1,ag-2" } }); + formMock.setFieldValue("key_alias", "Test Key"); + }); - const modelsCombobox = screen.getAllByRole("combobox").find((el) => el.closest('[class*="ant-form-item"]')?.textContent?.includes("Models")) || - screen.getAllByRole("combobox")[1]; - if (modelsCombobox) { - act(() => fireEvent.mouseDown(modelsCombobox)); - await waitFor(() => { - const allTeamModels = [...document.body.querySelectorAll(".ant-select-item")].find( - (el) => el.textContent?.includes("All Team Models"), - ); - if (allTeamModels) fireEvent.click(allTeamModels); - }); - } + act(() => { + fireEvent.click(screen.getByRole("button", { name: /create key/i })); + }); - const createButton = screen.getByRole("button", { name: /create key/i }); - act(() => fireEvent.click(createButton)); + await waitFor(() => { + expect(mockKeyCreateCall).toHaveBeenCalled(); + const formValues = mockKeyCreateCall.mock.calls[0][2]; + expect(formValues).toHaveProperty("access_group_ids"); + expect(formValues.access_group_ids).toEqual(["ag-1", "ag-2"]); + }); + }); - await waitFor( - () => { - expect(mockKeyCreateCall).toHaveBeenCalled(); - const formValues = mockKeyCreateCall.mock.calls[0][2]; - expect(formValues).toHaveProperty("access_group_ids"); - expect(formValues.access_group_ids).toEqual(["ag-1", "ag-2"]); - }, - { timeout: 15000 }, + it("should prefill models when provided without team_id", async () => { + renderWithProviders( + , ); - }, { timeout: 30000 }); + + await waitFor(() => { + expect(setFieldsValueMock).toHaveBeenCalledWith({ models: ["gpt-4"] }); + }); + }); + + it("should prefill team_id when it exists in teams", async () => { + renderWithProviders( + , + ); + + await waitFor(() => { + expect(setFieldsValueMock).toHaveBeenCalledWith({ team_id: "team-1" }); + }); + }); + + it("should ignore team_id when it does not exist in teams", async () => { + renderWithProviders( + , + ); + + await waitFor(() => { + expect(setFieldsValueMock).toHaveBeenCalledWith({ key_alias: "example-key" }); + }); + + expect(setFieldsValueMock).not.toHaveBeenCalledWith({ team_id: "team-404" }); + }); + + it('should fall back to "you" when owned_by is another_user for non-admin', async () => { + authorizedState = { ...defaultAuthorizedState, userRole: "Internal User" }; + + renderWithProviders( + , + ); + + await waitFor(() => { + expect(setFieldsValueMock).toHaveBeenCalledWith({ key_alias: "example-key" }); + }); + + expect(radioGroupValueRef.current).toBe("you"); + }); + + it("should apply owned_by another_user for admin", async () => { + renderWithProviders( + , + ); + + await waitFor(() => { + expect(radioGroupValueRef.current).toBe("another_user"); + }); + }); + + it("should prefill key_type when provided", async () => { + renderWithProviders( + , + ); + + await waitFor(() => { + expect(setFieldsValueMock).toHaveBeenCalledWith({ key_type: "management" }); + }); + }); }); 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 961a5d4d460..d071c4a4a3b 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -46,11 +46,24 @@ import { simplifyKeyGenerateError } from "./utils"; const { Option } = Select; +/** + * Interface for pre-filling the create key form from URL parameters + */ +export interface CreateKeyPrefillData { + owned_by?: "you" | "service_account" | "another_user"; + team_id?: string; + key_alias?: string; + models?: string[]; + key_type?: "default" | "llm_api" | "management"; +} + interface CreateKeyProps { team: Team | null; data: any[] | null; teams: Team[] | null; addKey: (data: any) => void; + autoOpenCreate?: boolean; + prefillData?: CreateKeyPrefillData; } interface User { @@ -141,7 +154,7 @@ export const fetchUserModels = async ( * Please contribute to the new refactor. * ───────────────────────────────────────────────────────────────────────── */ -const CreateKey: React.FC = ({ team, teams, data, addKey }) => { +const CreateKey: React.FC = ({ team, teams, data, addKey, autoOpenCreate, prefillData }) => { const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized(); const queryClient = useQueryClient(); const [form] = Form.useForm(); @@ -152,6 +165,8 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { const [modelsToPick, setModelsToPick] = useState([]); const [keyOwner, setKeyOwner] = useState("you"); const [predefinedTags, setPredefinedTags] = useState(getPredefinedTags(data)); + const [hasPrefilled, setHasPrefilled] = useState(false); + const [pendingPrefillModels, setPendingPrefillModels] = useState(null); const [guardrailsList, setGuardrailsList] = useState([]); const [policiesList, setPoliciesList] = useState([]); const [promptsList, setPromptsList] = useState([]); @@ -274,6 +289,55 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { fetchPossibleRoles(); }, [accessToken]); + // Auto-open modal and prefill form from URL params (deep link). + // Guarded by write access so we don't open for read-only users. + useEffect(() => { + if (autoOpenCreate && !hasPrefilled && teams && userRole && rolesWithWriteAccess.includes(userRole)) { + // Open the modal + setIsModalVisible(true); + setHasPrefilled(true); + + // Apply prefill data if provided + if (prefillData) { + // Set key owner (owned_by) - validate that "another_user" is only allowed for Admin + if (prefillData.owned_by) { + if (prefillData.owned_by === "another_user" && userRole !== "Admin") { + // Ignore invalid owned_by for non-admin users, fall back to default + setKeyOwner("you"); + } else { + setKeyOwner(prefillData.owned_by); + } + } + + // Set team - find the team by ID and set it (only if team exists in user's teams) + if (prefillData.team_id) { + const selectedTeam = teams?.find((t) => t.team_id === prefillData.team_id) || null; + if (selectedTeam) { + setSelectedCreateKeyTeam(selectedTeam); + form.setFieldsValue({ team_id: prefillData.team_id }); + } + // Silently ignore invalid team_id - don't prefill with a team user doesn't have access to + } + + // Set key alias + if (prefillData.key_alias) { + form.setFieldsValue({ key_alias: prefillData.key_alias }); + } + + // Defer model selection until we load the allowed model list. + if (prefillData.models && prefillData.models.length > 0) { + setPendingPrefillModels(prefillData.models); + } + + // Set key type + if (prefillData.key_type) { + setKeyType(prefillData.key_type); + form.setFieldsValue({ key_type: prefillData.key_type }); + } + } + } + }, [autoOpenCreate, prefillData, teams, hasPrefilled, form, userRole]); + // Check if team selection is required const isTeamSelectionRequired = modelsToPick.includes("no-default-models"); const isFormDisabled = isTeamSelectionRequired && !selectedCreateKeyTeam; @@ -467,6 +531,9 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { 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. useEffect(() => { if (userID && userRole && accessToken) { fetchTeamModels(userID, userRole, accessToken, selectedCreateKeyTeam?.team_id ?? null).then((models) => { @@ -474,8 +541,28 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { setModelsToPick(allModels); }); } - form.setFieldValue("models", []); - }, [selectedCreateKeyTeam, accessToken, userID, userRole]); + // Only clear models if we don't have pending prefill models + if (!pendingPrefillModels) { + form.setFieldValue("models", []); + } + }, [selectedCreateKeyTeam, accessToken, userID, userRole, form]); + + // Apply deferred model prefill once the available model list arrives. + // This handles timing where prefill data arrives before or after models are fetched. + useEffect(() => { + if (!pendingPrefillModels || pendingPrefillModels.length === 0) { + return; + } + if (!modelsToPick || modelsToPick.length === 0) { + return; + } + + const validModels = pendingPrefillModels.filter((model) => modelsToPick.includes(model)); + if (validModels.length > 0) { + form.setFieldsValue({ models: validModels }); + } + setPendingPrefillModels(null); + }, [pendingPrefillModels, modelsToPick, form]); // Add a callback function to handle user creation const handleUserCreated = (userId: string) => { diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index ec6de82fdf9..ecb17027548 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -16,7 +16,7 @@ import { Organization, userInfoCall, } from "./networking"; -import CreateKey from "./organisms/create_key_button"; +import CreateKey, { CreateKeyPrefillData } from "./organisms/create_key_button"; import { VirtualKeysTable } from "./VirtualKeysPage/VirtualKeysTable"; export interface ProxySettings { @@ -55,6 +55,8 @@ interface UserDashboardProps { organizations: Organization[] | null; addKey: (data: any) => void; createClicked: boolean; + autoOpenCreate?: boolean; + prefillData?: CreateKeyPrefillData; } type TeamInterface = { @@ -77,6 +79,8 @@ const UserDashboard: React.FC = ({ organizations, addKey, createClicked, + autoOpenCreate, + prefillData, }) => { const [userSpendData, setUserSpendData] = useState(null); const [currentOrg, setCurrentOrg] = useState(null); @@ -350,6 +354,8 @@ const UserDashboard: React.FC = ({ teams={teams as Team[]} data={keys} addKey={addKey} + autoOpenCreate={autoOpenCreate} + prefillData={prefillData} /> diff --git a/ui/litellm-dashboard/src/utils/returnUrlUtils.test.ts b/ui/litellm-dashboard/src/utils/returnUrlUtils.test.ts new file mode 100644 index 00000000000..3c09e550145 --- /dev/null +++ b/ui/litellm-dashboard/src/utils/returnUrlUtils.test.ts @@ -0,0 +1,383 @@ +import { + buildLoginUrlWithReturn, + clearStoredReturnUrl, + consumeReturnUrl, + getCurrentUrl, + getReturnUrl, + getReturnUrlFromParams, + getStoredReturnUrl, + isValidReturnUrl, + storeReturnUrl, +} from "./returnUrlUtils"; + +describe("returnUrlUtils", () => { + const originalLocation = window.location; + + beforeEach(() => { + // Clear cookies before each test + document.cookie.split(";").forEach((c) => { + document.cookie = c + .replace(/^ +/, "") + .replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/"); + }); + + // Reset location mock + Object.defineProperty(window, "location", { + value: { + href: "http://localhost:3000/ui?page=api-keys", + origin: "http://localhost:3000", + hostname: "localhost", + pathname: "/ui", + search: "?page=api-keys", + }, + writable: true, + }); + }); + + afterEach(() => { + // Restore original location + Object.defineProperty(window, "location", { + value: originalLocation, + writable: true, + }); + }); + + describe("getCurrentUrl", () => { + it("should return the current URL", () => { + const url = getCurrentUrl(); + expect(url).toBe("http://localhost:3000/ui?page=api-keys"); + }); + }); + + describe("storeReturnUrl and getStoredReturnUrl", () => { + it("should store and retrieve the return URL from cookie", () => { + storeReturnUrl(); + const storedUrl = getStoredReturnUrl(); + expect(storedUrl).toBe("http://localhost:3000/ui?page=api-keys"); + }); + + it("should return null if no URL is stored", () => { + const storedUrl = getStoredReturnUrl(); + expect(storedUrl).toBeNull(); + }); + }); + + describe("clearStoredReturnUrl", () => { + it("should clear the stored return URL", () => { + storeReturnUrl(); + expect(getStoredReturnUrl()).not.toBeNull(); + + clearStoredReturnUrl(); + expect(getStoredReturnUrl()).toBeNull(); + }); + }); + + describe("getReturnUrlFromParams", () => { + it("should return the redirect_to parameter from URL", () => { + Object.defineProperty(window, "location", { + value: { + ...window.location, + search: "?redirect_to=http%3A%2F%2Flocalhost%3A3000%2Fui%3Fcreate%3Dtrue", + }, + writable: true, + }); + + const returnUrl = getReturnUrlFromParams(); + expect(returnUrl).toBe("http://localhost:3000/ui?create=true"); + }); + + it("should return null if redirect_to parameter is not present", () => { + Object.defineProperty(window, "location", { + value: { + ...window.location, + search: "?page=api-keys", + }, + writable: true, + }); + + const returnUrl = getReturnUrlFromParams(); + expect(returnUrl).toBeNull(); + }); + }); + + describe("buildLoginUrlWithReturn", () => { + it("should build login URL with return URL parameter", () => { + Object.defineProperty(window, "location", { + value: { + ...window.location, + href: "http://localhost:3000/ui?create=true&team_id=123", + }, + writable: true, + }); + + const loginUrl = buildLoginUrlWithReturn("/ui/login"); + expect(loginUrl).toBe( + "/ui/login?redirect_to=http%3A%2F%2Flocalhost%3A3000%2Fui%3Fcreate%3Dtrue%26team_id%3D123" + ); + }); + + it("should not add return URL if already on login page", () => { + Object.defineProperty(window, "location", { + value: { + ...window.location, + href: "http://localhost:3000/ui/login", + }, + writable: true, + }); + + const loginUrl = buildLoginUrlWithReturn("/ui/login"); + expect(loginUrl).toBe("/ui/login"); + }); + + it("should handle login URL with existing query parameters", () => { + Object.defineProperty(window, "location", { + value: { + ...window.location, + href: "http://localhost:3000/ui?page=api-keys", + }, + writable: true, + }); + + const loginUrl = buildLoginUrlWithReturn("/ui/login?foo=bar"); + expect(loginUrl).toContain("&redirect_to="); + }); + }); + + describe("getReturnUrl", () => { + it("should prefer URL params over cookie", () => { + // Store a URL in cookie + storeReturnUrl(); + + // Set a different URL in the params + Object.defineProperty(window, "location", { + value: { + ...window.location, + search: "?redirect_to=http%3A%2F%2Flocalhost%3A3000%2Fui%3Fpage%3Dteams", + }, + writable: true, + }); + + const returnUrl = getReturnUrl(); + expect(returnUrl).toBe("http://localhost:3000/ui?page=teams"); + }); + + it("should fall back to cookie if no URL param", () => { + // Store a URL in cookie first + Object.defineProperty(window, "location", { + value: { + href: "http://localhost:3000/ui?create=true", + origin: "http://localhost:3000", + hostname: "localhost", + pathname: "/ui", + search: "?create=true", + }, + writable: true, + }); + storeReturnUrl(); + + // Clear the URL params + Object.defineProperty(window, "location", { + value: { + ...window.location, + search: "", + }, + writable: true, + }); + + const returnUrl = getReturnUrl(); + expect(returnUrl).toBe("http://localhost:3000/ui?create=true"); + }); + + it("should return null if no return URL found", () => { + Object.defineProperty(window, "location", { + value: { + ...window.location, + search: "", + }, + writable: true, + }); + + const returnUrl = getReturnUrl(); + expect(returnUrl).toBeNull(); + }); + }); + + describe("isValidReturnUrl", () => { + it("should validate relative URLs starting with /", () => { + expect(isValidReturnUrl("/ui?page=api-keys")).toBe(true); + expect(isValidReturnUrl("/ui/teams")).toBe(true); + }); + + it("should reject protocol-relative URLs", () => { + expect(isValidReturnUrl("//evil.com")).toBe(false); + }); + + it("should validate same-hostname URLs (even with different ports) in dev", () => { + // Same hostname, same port + expect(isValidReturnUrl("http://localhost:3000/ui?page=teams")).toBe(true); + // Same hostname, different port (important for dev environments) + expect(isValidReturnUrl("http://localhost:4000/ui?page=teams")).toBe(true); + }); + + it("should reject different-hostname URLs", () => { + expect(isValidReturnUrl("http://evil.com/ui")).toBe(false); + expect(isValidReturnUrl("https://google.com")).toBe(false); + }); + + it("should reject empty URLs", () => { + expect(isValidReturnUrl("")).toBe(false); + }); + + it("should reject invalid URLs", () => { + expect(isValidReturnUrl("not-a-url")).toBe(false); + }); + + it("should reject XSS attempts with javascript: protocol", () => { + expect(isValidReturnUrl('javascript:alert("xss")')).toBe(false); + expect(isValidReturnUrl("javascript:void(0)")).toBe(false); + }); + + it("should reject data: URLs", () => { + expect(isValidReturnUrl("data:text/html,")).toBe(false); + }); + + it("should allow 127.x.x.x addresses in dev environment", () => { + Object.defineProperty(window, "location", { + value: { + href: "http://127.0.0.1:3000/ui", + origin: "http://127.0.0.1:3000", + hostname: "127.0.0.1", + protocol: "http:", + pathname: "/ui", + search: "", + }, + writable: true, + }); + + expect(isValidReturnUrl("http://127.0.0.1:4000/ui")).toBe(true); + }); + + it("should allow .local domains in dev environment", () => { + Object.defineProperty(window, "location", { + value: { + href: "http://myapp.local:3000/ui", + origin: "http://myapp.local:3000", + hostname: "myapp.local", + protocol: "http:", + pathname: "/ui", + search: "", + }, + writable: true, + }); + + // Same hostname with different port should be allowed in dev + expect(isValidReturnUrl("http://myapp.local:4000/ui")).toBe(true); + }); + + it("should reject cross-port redirects in production environment", () => { + // Simulate production environment + Object.defineProperty(window, "location", { + value: { + href: "https://app.example.com/ui", + origin: "https://app.example.com", + hostname: "app.example.com", + protocol: "https:", + pathname: "/ui", + search: "", + }, + writable: true, + }); + + // Same origin should work + expect(isValidReturnUrl("https://app.example.com/ui?page=teams")).toBe(true); + // Different port should be rejected in production + expect(isValidReturnUrl("https://app.example.com:8080/ui")).toBe(false); + // Different hostname should be rejected + expect(isValidReturnUrl("https://evil.com/ui")).toBe(false); + }); + }); + + describe("consumeReturnUrl", () => { + it("should return and clear the stored return URL", () => { + Object.defineProperty(window, "location", { + value: { + href: "http://localhost:3000/ui?create=true", + origin: "http://localhost:3000", + hostname: "localhost", + pathname: "/ui", + search: "?create=true", + }, + writable: true, + }); + storeReturnUrl(); + + // Clear the URL params for the consume call + Object.defineProperty(window, "location", { + value: { + href: "http://localhost:3000/ui/login", + origin: "http://localhost:3000", + hostname: "localhost", + pathname: "/ui/login", + search: "", + }, + writable: true, + }); + + const returnUrl = consumeReturnUrl(); + expect(returnUrl).toBe("http://localhost:3000/ui?create=true"); + expect(getStoredReturnUrl()).toBeNull(); + }); + + it("should return null for invalid return URLs (different hostname)", () => { + // Manually set an invalid URL in cookie + document.cookie = "litellm_return_url=" + encodeURIComponent("http://evil.com/phishing") + "; path=/"; + + const returnUrl = consumeReturnUrl(); + expect(returnUrl).toBeNull(); + }); + + it("should allow URLs with different ports on same hostname", () => { + // Store URL with port 3000 + Object.defineProperty(window, "location", { + value: { + href: "http://localhost:3000/ui?create=true", + origin: "http://localhost:3000", + hostname: "localhost", + pathname: "/ui", + search: "?create=true", + }, + writable: true, + }); + storeReturnUrl(); + + // Now we're on port 4000 + Object.defineProperty(window, "location", { + value: { + href: "http://localhost:4000/ui", + origin: "http://localhost:4000", + hostname: "localhost", + pathname: "/ui", + search: "", + }, + writable: true, + }); + + const returnUrl = consumeReturnUrl(); + // Should be valid because same hostname (localhost) + expect(returnUrl).toBe("http://localhost:3000/ui?create=true"); + }); + + it("should return null if no return URL found", () => { + Object.defineProperty(window, "location", { + value: { + ...window.location, + search: "", + }, + writable: true, + }); + + const returnUrl = consumeReturnUrl(); + expect(returnUrl).toBeNull(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/returnUrlUtils.ts b/ui/litellm-dashboard/src/utils/returnUrlUtils.ts new file mode 100644 index 00000000000..76562a0122a --- /dev/null +++ b/ui/litellm-dashboard/src/utils/returnUrlUtils.ts @@ -0,0 +1,304 @@ +/** + * Utility functions for managing return URLs during authentication flows. + * + * When a user is redirected to login, we store the original URL so they can be + * redirected back after successful authentication. + * + * NOTE: We use cookies instead of sessionStorage because the SSO flow may cross + * different ports (e.g., localhost:3000 -> localhost:4000), and sessionStorage + * is not shared across different origins. Cookies on the same hostname are shared + * across different ports. + */ + +const RETURN_URL_COOKIE_NAME = "litellm_return_url"; +const RETURN_URL_PARAM = "redirect_to"; + +/** + * Gets the current URL with all query parameters. + * Returns null if running on server-side. + */ +export function getCurrentUrl(): string | null { + if (typeof window === "undefined") { + return null; + } + return window.location.href; +} + +/** + * Sets a cookie with the given name and value. + * Automatically adds Secure flag when running over HTTPS. + */ +function setCookie(name: string, value: string, maxAgeSeconds: number = 300): void { + if (typeof document === "undefined") { + return; + } + // Set cookie with path=/ so it's available across all paths + // Use SameSite=Lax to allow the cookie to be sent on navigation from external sites (SSO redirect) + // Add Secure flag when running over HTTPS to prevent cookie from being sent over unencrypted connections + const isSecure = typeof window !== "undefined" && window.location.protocol === "https:"; + const secureFlag = isSecure ? "; Secure" : ""; + document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAgeSeconds}; SameSite=Lax${secureFlag}`; +} + +/** + * Gets a cookie value by name. + */ +function getCookie(name: string): string | null { + if (typeof document === "undefined") { + return null; + } + const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`)); + if (match) { + try { + return decodeURIComponent(match[2]); + } catch { + return match[2]; + } + } + return null; +} + +/** + * Deletes a cookie by name. + */ +function deleteCookie(name: string): void { + if (typeof document === "undefined") { + return; + } + document.cookie = `${name}=; path=/; max-age=0`; +} + +/** + * Stores the current URL in a cookie before redirecting to login. + * This allows us to redirect the user back to their original destination after login. + * Cookie expires in 5 minutes (300 seconds). + */ +export function storeReturnUrl(): void { + if (typeof window === "undefined") { + return; + } + + const currentUrl = getCurrentUrl(); + if (currentUrl) { + setCookie(RETURN_URL_COOKIE_NAME, currentUrl, 300); + } +} + +/** + * Retrieves the stored return URL from the cookie. + * Returns null if no return URL is stored or if running on server-side. + */ +export function getStoredReturnUrl(): string | null { + if (typeof window === "undefined") { + return null; + } + return getCookie(RETURN_URL_COOKIE_NAME); +} + +/** + * Clears the stored return URL from the cookie. + * Should be called after redirecting to the return URL. + */ +export function clearStoredReturnUrl(): void { + if (typeof window === "undefined") { + return; + } + + try { + deleteCookie(RETURN_URL_COOKIE_NAME); + } catch (error) { + console.error("Failed to clear return URL cookie:", error); + } +} + +/** + * Gets the return URL from URL query parameters. + * Used when the return URL is passed via query string to the login page. + */ +export function getReturnUrlFromParams(): string | null { + if (typeof window === "undefined") { + return null; + } + + const searchParams = new URLSearchParams(window.location.search); + return searchParams.get(RETURN_URL_PARAM); +} + +/** + * Builds a login URL with the return URL as a query parameter. + * + * @param baseLoginUrl - The base login URL (e.g., "/ui/login") + * @param returnUrl - The URL to redirect to after login (defaults to current URL) + */ +export function buildLoginUrlWithReturn(baseLoginUrl: string, returnUrl?: string): string { + const url = returnUrl || getCurrentUrl(); + + if (!url) { + return baseLoginUrl; + } + + // Don't add return URL if we're already on the login page + if (url.includes("/login")) { + return baseLoginUrl; + } + + const separator = baseLoginUrl.includes("?") ? "&" : "?"; + return `${baseLoginUrl}${separator}${RETURN_URL_PARAM}=${encodeURIComponent(url)}`; +} + +/** + * Gets the best return URL to use after login. + * Priority: + * 1. URL query parameter (redirect_to) + * 2. Cookie + * 3. null (caller should use default) + */ +export function getReturnUrl(): string | null { + // First check URL params + const paramUrl = getReturnUrlFromParams(); + if (paramUrl) { + return paramUrl; + } + + // Then check cookie + const storedUrl = getStoredReturnUrl(); + if (storedUrl) { + return storedUrl; + } + + return null; +} + +/** + * Checks if we're running in a development environment. + * Returns true for localhost, 127.0.0.1, IPv6 localhost, or .local domains. + * This determines whether cross-port redirects are allowed (dev only). + */ +function isDevEnvironment(): boolean { + if (typeof window === "undefined") { + return false; + } + const hostname = window.location.hostname; + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" || + hostname.startsWith("127.") || // Full IPv4 loopback range (127.0.0.0/8) + hostname.endsWith(".local") // Common dev domain suffix + ); +} + +/** + * Validates a return URL to prevent open redirect attacks. + * - Always allows relative URLs (starting with / but not //) + * - In dev (localhost): allows same hostname with any port + * - In production: requires exact origin match (protocol + hostname + port) + * + * @param url - The URL to validate + * @returns true if the URL is safe to redirect to + */ +export function isValidReturnUrl(url: string): boolean { + if (!url) { + return false; + } + + // Allow relative URLs + if (url.startsWith("/") && !url.startsWith("//")) { + return true; + } + + // For absolute URLs, validate against current origin + if (typeof window === "undefined") { + return false; + } + + try { + const returnUrlObj = new URL(url); + const currentHostname = window.location.hostname; + + // Hostname must always match + if (returnUrlObj.hostname !== currentHostname) { + return false; + } + + // In dev environments (localhost), allow any port on the same hostname + // This supports SSO flows that cross ports (e.g., localhost:3000 -> localhost:4000) + if (isDevEnvironment()) { + return true; + } + + // In production, require exact origin match (protocol + hostname + port) + return returnUrlObj.origin === window.location.origin; + } catch { + // Invalid URL + return false; + } +} + +export function normalizeUrlForCompare(url: string): string { + if (typeof window === "undefined") { + return url; + } + + try { + const parsed = new URL(url, window.location.origin); + let pathname = parsed.pathname; + if (pathname.length > 1 && pathname.endsWith("/")) { + pathname = pathname.slice(0, -1); + } + + const params = new URLSearchParams(parsed.search); + const sortedParams = new URLSearchParams(); + Array.from(params.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([key, value]) => { + sortedParams.append(key, value); + }); + + const search = sortedParams.toString(); + const hash = parsed.hash || ""; + return `${parsed.origin}${pathname}${search ? `?${search}` : ""}${hash}`; + } catch { + return url; + } +} + +/** + * Gets and clears the return URL in one operation. + * Returns the validated return URL or null if invalid/not found. + * + * Priority: + * 1. If redirect_to param is valid, use it and clear cookie + * 2. If redirect_to param is invalid/missing, check cookie + * 3. Only clear cookie when we have a valid URL to return + */ +export function consumeReturnUrl(): string | null { + // Check URL param first + const paramUrl = getReturnUrlFromParams(); + if (paramUrl) { + if (isValidReturnUrl(paramUrl)) { + clearStoredReturnUrl(); + return paramUrl; + } + // Log rejected URLs in development for debugging + if (isDevEnvironment()) { + console.warn("[returnUrlUtils] Invalid return URL in params rejected:", paramUrl); + } + } + + // Fall back to cookie + const storedUrl = getStoredReturnUrl(); + if (storedUrl) { + if (isValidReturnUrl(storedUrl)) { + clearStoredReturnUrl(); + return storedUrl; + } + // Log rejected URLs in development for debugging + if (isDevEnvironment()) { + console.warn("[returnUrlUtils] Invalid return URL in cookie rejected:", storedUrl); + } + } + + // No valid URL found - don't clear cookie (nothing to clear or already invalid) + return null; +} diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts index 580b4568c53..608a54ae143 100644 --- a/ui/litellm-dashboard/src/utils/roles.ts +++ b/ui/litellm-dashboard/src/utils/roles.ts @@ -31,3 +31,32 @@ export const isUserTeamAdminForSingleTeam = (teamMemberWithRoles: Member[] | nul } return teamMemberWithRoles.some((member) => member.user_id === userID && member.role === "admin"); }; + +export const formatUserRole = (userRole: string): string => { + if (!userRole) { + return "Undefined Role"; + } + switch (userRole.toLowerCase()) { + case "app_owner": + return "App Owner"; + case "demo_app_owner": + return "App Owner"; + case "app_admin": + return "Admin"; + case "proxy_admin": + return "Admin"; + case "proxy_admin_viewer": + return "Admin Viewer"; + case "org_admin": + return "Org Admin"; + case "internal_user": + return "Internal User"; + case "internal_user_viewer": + case "internal_viewer": // TODO:remove if deprecated + return "Internal Viewer"; + case "app_user": + return "App User"; + default: + return "Unknown Role"; + } +}; diff --git a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx index c3c5ae59237..8b05def9ba3 100644 --- a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx +++ b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx @@ -5,12 +5,13 @@ import { vi, describe, it, beforeEach, afterEach, expect } from "vitest"; /** ---------------------------- * Hoisted helpers for mocks (required by Vitest) * --------------------------- */ -const { stub, jwtDecodeMock } = vi.hoisted(() => { +const { stub, jwtDecodeMock, consumeReturnUrlMock } = vi.hoisted(() => { const React = require("react"); const stub = (name: string) => () => React.createElement("div", { "data-testid": name }); return { stub, jwtDecodeMock: vi.fn(), + consumeReturnUrlMock: vi.fn(), }; }); @@ -84,6 +85,14 @@ vi.mock("jwt-decode", () => ({ jwtDecode: (token: string) => jwtDecodeMock(token), })); +vi.mock("@/utils/returnUrlUtils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + consumeReturnUrl: consumeReturnUrlMock, + }; +}); + // Super-light stubs for all heavy components so rendering doesn't explode vi.mock("@/components/navbar", () => ({ default: stub("navbar") })); vi.mock("@/components/user_dashboard", () => ({ default: stub("user-dashboard") })); @@ -152,6 +161,7 @@ beforeEach(() => { // Fresh module state & DOM vi.clearAllMocks(); clearAllCookies(); + consumeReturnUrlMock.mockReturnValue(null); // Make location.replace spy-able to validate redirect delete (window as any).location; @@ -191,9 +201,11 @@ describe("CreateKeyPage auth behavior", () => { // Act render(); - // Assert: we eventually redirect to SSO login (single replace, not assign/href) + // Assert: we eventually redirect to SSO login with return URL (single replace, not assign/href) await waitFor(() => { - expect(window.location.replace).toHaveBeenCalledWith("https://example.com/ui/login"); + expect(window.location.replace).toHaveBeenCalledWith( + expect.stringContaining("https://example.com/ui/login?redirect_to=") + ); }); // And we attempted to clear the cookie (defensive deletion) @@ -235,4 +247,41 @@ describe("CreateKeyPage auth behavior", () => { expect(screen.getByTestId("navbar")).toBeInTheDocument(); }); }); + + it("should not redirect when return URL only differs by query order", async () => { + setCookie("token=validtoken"); + + jwtDecodeMock.mockImplementation((tok: string) => { + expect(tok).toBe("validtoken"); + return { + exp: Math.floor(Date.now() / 1000) + 60 * 60, + key: "accessKey-123", + user_role: "app_user", + user_email: "user@example.com", + login_method: "username_password", + premium_user: false, + auth_header_name: "x-litellm-auth", + user_id: "u_123", + }; + }); + + // Current URL has params in a different order + delete (window as any).location; + (window as any).location = { + ...originalLocation, + href: "http://localhost/ui?b=2&a=1", + origin: "http://localhost", + assign: vi.fn(), + replace: vi.fn(), + }; + + // Return URL has the same params in a different order + consumeReturnUrlMock.mockReturnValue("http://localhost/ui?a=1&b=2"); + + render(); + + await waitFor(() => { + expect(window.location.replace).not.toHaveBeenCalled(); + }); + }); }); From f1c563d2b2550d553f7adc368d5097dbbf2f92a7 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Fri, 27 Feb 2026 14:58:17 -0800 Subject: [PATCH 014/380] org-exclusive-add-member --- .../internal_user_endpoints.py | 62 ++++++++- .../test_internal_user_endpoints.py | 127 +++++++++++++++++- 2 files changed, 179 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index e535ccaaa46..f5488ce865d 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -30,6 +30,7 @@ from litellm.proxy.management_endpoints.common_daily_activity import ( get_daily_activity, get_daily_activity_aggregated, ) +from litellm.proxy.auth.auth_checks import get_user_object from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view from litellm.proxy.management_endpoints.key_management_endpoints import ( generate_key_helper_fn, @@ -1830,7 +1831,11 @@ async def ui_view_users( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - [PROXY-ADMIN ONLY]Filter users based on partial match of user_id or email with pagination. + Filter users based on partial match of user_id or email with pagination. + + - Proxy admins: receive all matching users. + - Organization admins: receive only users in their own organization(s). + - Other roles: access denied (403). Args: user_id (Optional[str]): Partial user ID to search for @@ -1840,19 +1845,60 @@ async def ui_view_users( user_api_key_dict (UserAPIKeyAuth): User authentication information Returns: - List[LiteLLM_SpendLogs]: Paginated list of matching user records + List of matching user records (LiteLLM_UserTableFiltered), scoped by org for org admins. """ - from litellm.proxy.proxy_server import prisma_client + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) if prisma_client is None: raise HTTPException(status_code=500, detail={"error": "No db connected"}) try: + # Restrict by caller role: proxy admin sees all; org admin sees only their org(s); others 403 + is_proxy_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + if not is_proxy_admin: + if user_api_key_dict.user_id is None: + raise HTTPException( + status_code=403, + detail={ + "error": "Only proxy admins and organization admins can search users." + }, + ) + caller_user = await get_user_object( + user_id=user_api_key_dict.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + proxy_logging_obj=proxy_logging_obj, + ) + if caller_user is None: + raise HTTPException( + status_code=403, + detail={ + "error": "Only proxy admins and organization admins can search users." + }, + ) + org_admin_org_ids = [ + m.organization_id + for m in (caller_user.organization_memberships or []) + if m.user_role == LitellmUserRoles.ORG_ADMIN.value + ] + if not org_admin_org_ids: + raise HTTPException( + status_code=403, + detail={ + "error": "Only proxy admins and organization admins can search users." + }, + ) + # Calculate offset for pagination skip = (page - 1) * page_size # Build where conditions based on provided parameters - where_conditions = {} + where_conditions: Dict[str, Any] = {} if user_id: where_conditions["user_id"] = { @@ -1866,6 +1912,12 @@ async def ui_view_users( "mode": "insensitive", # Case-insensitive search } + # Org admins: only users in their org(s) + if not is_proxy_admin and org_admin_org_ids: + where_conditions["organization_memberships"] = { + "some": {"organization_id": {"in": org_admin_org_ids}} + } + # Query users with pagination and filters users: Optional[List[BaseModel]] = ( await prisma_client.db.litellm_usertable.find_many( @@ -1881,6 +1933,8 @@ async def ui_view_users( return [LiteLLM_UserTableFiltered(**user.model_dump()) for user in users] + except HTTPException: + raise except Exception as e: verbose_proxy_logger.exception(f"Error searching users: {str(e)}") raise HTTPException(status_code=500, detail=f"Error searching users: {str(e)}") diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 839885bc752..16b5feb108a 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -34,7 +34,8 @@ client = TestClient(app) @pytest.mark.asyncio async def test_ui_view_users_with_null_email(mocker, caplog): """ - Test that /user/filter/ui endpoint returns users even when they have null email fields + Test that /user/filter/ui endpoint returns users even when they have null email fields. + Uses proxy admin so no org filtering is applied. """ # Mock the prisma client mock_prisma_client = mocker.MagicMock() @@ -48,19 +49,18 @@ async def test_ui_view_users_with_null_email(mocker, caplog): "created_at": "2024-01-01T00:00:00Z", } - # Setup the mock find_many response - # Setup the mock find_many response as an async function async def mock_find_many(*args, **kwargs): return [mock_user] mock_prisma_client.db.litellm_usertable.find_many = mock_find_many - # Patch the prisma client import in the endpoint mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) - # Call ui_view_users function directly + # Proxy admin: no org filter, no get_user_object call response = await ui_view_users( - user_api_key_dict=UserAPIKeyAuth(user_id="test_user"), + user_api_key_dict=UserAPIKeyAuth( + user_id="test_user", user_role=LitellmUserRoles.PROXY_ADMIN + ), user_id="test_user", user_email=None, page=1, @@ -72,6 +72,121 @@ async def test_ui_view_users_with_null_email(mocker, caplog): ] +@pytest.mark.asyncio +async def test_ui_view_users_proxy_admin_no_org_filter(mocker): + """ + Proxy admin: find_many is called without organization_memberships in where. + """ + mock_prisma_client = mocker.MagicMock() + async def mock_find_many(*args, **kwargs): + assert "organization_memberships" not in (kwargs.get("where") or {}) + return [] + + mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + await ui_view_users( + user_api_key_dict=UserAPIKeyAuth( + user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN + ), + user_id=None, + user_email="foo", + page=1, + page_size=50, + ) + + +@pytest.mark.asyncio +async def test_ui_view_users_org_admin_filtered_by_org(mocker): + """ + Org admin: find_many is called with organization_memberships filter so only users + in the caller's org(s) are returned. + """ + from litellm.proxy._types import LiteLLM_OrganizationMembershipTable + + mock_prisma_client = mocker.MagicMock() + org_id = "org-123" + + async def mock_find_many(*args, **kwargs): + where = kwargs.get("where") or {} + assert "organization_memberships" in where + assert where["organization_memberships"] == { + "some": {"organization_id": {"in": [org_id]}} + } + return [] + + mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) + mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) + + caller_user = mocker.MagicMock() + caller_user.organization_memberships = [ + LiteLLM_OrganizationMembershipTable( + user_id="org-admin", + organization_id=org_id, + user_role=LitellmUserRoles.ORG_ADMIN.value, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + ] + + async def mock_get_user_object(*args, **kwargs): + return caller_user + + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_user_object", + side_effect=mock_get_user_object, + ) + + response = await ui_view_users( + user_api_key_dict=UserAPIKeyAuth(user_id="org-admin", user_role=None), + user_id=None, + user_email="u", + page=1, + page_size=50, + ) + + assert response == [] + + +@pytest.mark.asyncio +async def test_ui_view_users_non_org_admin_returns_403(mocker): + """ + Caller is not proxy admin and not org admin: endpoint returns 403. + """ + from fastapi import HTTPException + + mock_prisma_client = mocker.MagicMock() + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) + mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) + + # Caller has no org admin membership + caller_user = mocker.MagicMock() + caller_user.organization_memberships = [] # not an org admin + + async def mock_get_user_object(*args, **kwargs): + return caller_user + + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_user_object", + side_effect=mock_get_user_object, + ) + + with pytest.raises(HTTPException) as exc_info: + await ui_view_users( + user_api_key_dict=UserAPIKeyAuth(user_id="internal_user", user_role=None), + user_id=None, + user_email="u", + page=1, + page_size=50, + ) + + assert exc_info.value.status_code == 403 + assert "Only proxy admins and organization admins" in str(exc_info.value.detail) + + def test_user_daily_activity_types(): """ Assert all fiels in SpendMetrics are reported in DailySpendMetadata as "total_" From 9dc085694c7dcfaedc13df3a873483df226f61dc Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Sat, 28 Feb 2026 13:29:46 +0530 Subject: [PATCH 015/380] feat: jwt mapping vkeyv --- litellm/proxy/_types.py | 45 +++ litellm/proxy/auth/handle_jwt.py | 2 + litellm/proxy/auth/user_api_key_auth.py | 348 +++++++++++------- .../jwt_key_mapping_endpoints.py | 152 ++++++++ litellm/proxy/proxy_server.py | 4 + litellm/proxy/schema.prisma | 19 + .../proxy_unit_tests/test_jwt_key_mapping.py | 112 ++++++ 7 files changed, 553 insertions(+), 129 deletions(-) create mode 100644 litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py create mode 100644 tests/proxy_unit_tests/test_jwt_key_mapping.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index dfc2ba59d96..6440bf0ed81 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -539,6 +539,11 @@ class LiteLLMRoutes(enum.Enum): "/model/update", "/model/delete", "/model/info", + "/jwt/key/mapping/new", + "/jwt/key/mapping/update", + "/jwt/key/mapping/delete", + "/jwt/key/mapping/list", + "/jwt/key/mapping/info", ] + key_management_routes spend_tracking_routes = [ @@ -3664,6 +3669,36 @@ class KeyHealthResponse(TypedDict, total=False): logging_callbacks: Optional[LoggingCallbackStatus] +class CreateJWTKeyMappingRequest(LiteLLMPydanticObjectBase): + jwt_claim_name: str + jwt_claim_value: str + key: str + description: Optional[str] = None + + +class UpdateJWTKeyMappingRequest(LiteLLMPydanticObjectBase): + id: str + key: Optional[str] = None + description: Optional[str] = None + is_active: Optional[bool] = None + + +class DeleteJWTKeyMappingRequest(LiteLLMPydanticObjectBase): + id: str + + +class JWTKeyMappingResponse(LiteLLMPydanticObjectBase): + id: str + jwt_claim_name: str + jwt_claim_value: str + token: str + key_alias: Optional[str] = None + description: Optional[str] = None + is_active: bool + created_at: datetime + updated_at: datetime + + class SpecialHeaders(enum.Enum): """Used by user_api_key_auth.py to get litellm key""" @@ -3834,6 +3869,7 @@ class JWTAuthBuilderResult(TypedDict): end_user_id: Optional[str] org_id: Optional[str] team_membership: Optional[LiteLLM_TeamMembership] + jwt_claims: dict # Decoded JWT token claims (avoids re-decoding) class ClientSideFallbackModel(TypedDict, total=False): @@ -3977,6 +4013,15 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase): default=300, description="TTL (in seconds) for caching UserInfo responses. Default: 300s (5 minutes).", ) + # JWT-to-Virtual-Key Mapping + virtual_key_claim_field: Optional[str] = Field( + default=None, + description="JWT claim field for virtual key mapping lookup (e.g. 'sub', 'email'). Supports dot notation.", + ) + virtual_key_mapping_cache_ttl: float = Field( + default=300, + description="TTL (seconds) for caching JWT-to-virtual-key mapping lookups.", + ) ######################################################### def __init__(self, **kwargs: Any) -> None: diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 9921b74b561..210996a0a86 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -857,6 +857,7 @@ class JWTAuthManager: end_user_id=None, org_id=org_id, team_membership=None, + jwt_claims={}, ) @staticmethod @@ -1479,4 +1480,5 @@ class JWTAuthManager: end_user_object=end_user_object, token=api_key, team_membership=team_membership_object, + jwt_claims=jwt_valid_token, ) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 8ad3b83c043..d453b721645 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -22,6 +22,7 @@ from litellm._logging import verbose_logger, verbose_proxy_logger from litellm._service_logger import ServiceLogging from litellm.caching import DualCache from litellm.litellm_core_utils.dd_tracing import tracer +from litellm.litellm_core_utils.dot_notation_indexing import get_nested_value from litellm.proxy._types import * from litellm.proxy.auth.auth_checks import ( ExperimentalUIJWTToken, @@ -438,6 +439,78 @@ async def check_api_key_for_custom_headers_or_pass_through_endpoints( return api_key +async def _resolve_jwt_to_virtual_key( + jwt_claims: dict, + jwt_handler: JWTHandler, + prisma_client: Optional[PrismaClient], + user_api_key_cache: DualCache, + parent_otel_span: Optional[Span], + proxy_logging_obj: ProxyLogging, +) -> Optional[UserAPIKeyAuth]: + virtual_key_claim_field = jwt_handler.litellm_jwtauth.virtual_key_claim_field + if virtual_key_claim_field is None: + return None + + claim_value = get_nested_value( + data=jwt_claims, + key_path=virtual_key_claim_field, + default=None, + ) + + if claim_value is None: + verbose_proxy_logger.debug( + f"JWT Key Mapping: Claim field '{virtual_key_claim_field}' not found in JWT claims." + ) + return None + + cache_key = f"jwt_key_mapping:{virtual_key_claim_field}:{claim_value}" + cached_mapping = await user_api_key_cache.async_get_cache(cache_key) + + if cached_mapping == "__NO_MAPPING__": + return None + elif cached_mapping is not None: + return await get_key_object( + hashed_token=cached_mapping, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) + + if prisma_client is None: + return None + + mapping = await prisma_client.db.litellm_jwtkeymapping.find_first( + where={ + "jwt_claim_name": virtual_key_claim_field, + "jwt_claim_value": str(claim_value), + "is_active": True, + } + ) + + if mapping: + token_hash = mapping.token + await user_api_key_cache.async_set_cache( + key=cache_key, + value=token_hash, + ttl=jwt_handler.litellm_jwtauth.virtual_key_mapping_cache_ttl, + ) + return await get_key_object( + hashed_token=token_hash, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) + else: + await user_api_key_cache.async_set_cache( + key=cache_key, + value="__NO_MAPPING__", + ttl=jwt_handler.litellm_jwtauth.virtual_key_mapping_cache_ttl, + ) + return None + + async def _user_api_key_auth_builder( # noqa: PLR0915 request: Request, api_key: str, @@ -602,132 +675,151 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 request_headers=_safe_get_request_headers(request), ) - is_proxy_admin = result["is_proxy_admin"] - team_id = result["team_id"] - team_object = result["team_object"] - user_id = result["user_id"] - user_object = result["user_object"] - end_user_id = result["end_user_id"] - end_user_object = result["end_user_object"] - org_id = result["org_id"] - token = result["token"] - team_membership: Optional[LiteLLM_TeamMembership] = result.get( - "team_membership", None - ) + # JWT-to-Virtual-Key Mapping lookup + do_standard_jwt_auth = True + if jwt_handler.litellm_jwtauth.virtual_key_claim_field is not None: + valid_token = await _resolve_jwt_to_virtual_key( + jwt_claims=result["jwt_claims"], + jwt_handler=jwt_handler, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) + if valid_token is not None: + api_key = valid_token.token or "" + do_standard_jwt_auth = False + # Fall through to virtual key checks - global_proxy_spend = await get_global_proxy_spend( - litellm_proxy_admin_name=litellm_proxy_admin_name, - user_api_key_cache=user_api_key_cache, - prisma_client=prisma_client, - token=token, - proxy_logging_obj=proxy_logging_obj, - ) + if do_standard_jwt_auth: + is_proxy_admin = result["is_proxy_admin"] + team_id = result["team_id"] + team_object = result["team_object"] + user_id = result["user_id"] + user_object = result["user_object"] + end_user_id = result["end_user_id"] + end_user_object = result["end_user_object"] + org_id = result["org_id"] + token = result["token"] + team_membership: Optional[LiteLLM_TeamMembership] = result.get( + "team_membership", None + ) - if is_proxy_admin: - return UserAPIKeyAuth( + global_proxy_spend = await get_global_proxy_spend( + litellm_proxy_admin_name=litellm_proxy_admin_name, + user_api_key_cache=user_api_key_cache, + prisma_client=prisma_client, + token=token, + proxy_logging_obj=proxy_logging_obj, + ) + + if is_proxy_admin: + return UserAPIKeyAuth( + api_key=None, + user_role=LitellmUserRoles.PROXY_ADMIN, + user_id=user_id, + team_id=team_id, + team_alias=( + team_object.team_alias + if team_object is not None + else None + ), + team_metadata=team_object.metadata + if team_object is not None + else None, + org_id=org_id, + end_user_id=end_user_id, + parent_otel_span=parent_otel_span, + ) + + valid_token = UserAPIKeyAuth( api_key=None, - user_role=LitellmUserRoles.PROXY_ADMIN, - user_id=user_id, team_id=team_id, team_alias=( team_object.team_alias if team_object is not None else None ), + team_tpm_limit=( + team_object.tpm_limit if team_object is not None else None + ), + team_rpm_limit=( + team_object.rpm_limit if team_object is not None else None + ), + team_models=team_object.models if team_object is not None else [], + user_role=( + LitellmUserRoles(user_object.user_role) + if user_object is not None and user_object.user_role is not None + else LitellmUserRoles.INTERNAL_USER + ), + user_id=user_id, + org_id=org_id, + parent_otel_span=parent_otel_span, + end_user_id=end_user_id, + user_tpm_limit=( + user_object.tpm_limit if user_object is not None else None + ), + user_rpm_limit=( + user_object.rpm_limit if user_object is not None else None + ), + team_member_rpm_limit=( + team_membership.safe_get_team_member_rpm_limit() + if team_membership is not None + else None + ), + team_member_tpm_limit=( + team_membership.safe_get_team_member_tpm_limit() + if team_membership is not None + else None + ), team_metadata=team_object.metadata if team_object is not None else None, - org_id=org_id, - end_user_id=end_user_id, - parent_otel_span=parent_otel_span, ) - valid_token = UserAPIKeyAuth( - api_key=None, - team_id=team_id, - team_alias=( - team_object.team_alias if team_object is not None else None - ), - team_tpm_limit=( - team_object.tpm_limit if team_object is not None else None - ), - team_rpm_limit=( - team_object.rpm_limit if team_object is not None else None - ), - team_models=team_object.models if team_object is not None else [], - user_role=( - LitellmUserRoles(user_object.user_role) - if user_object is not None and user_object.user_role is not None - else LitellmUserRoles.INTERNAL_USER - ), - user_id=user_id, - org_id=org_id, - parent_otel_span=parent_otel_span, - end_user_id=end_user_id, - user_tpm_limit=( - user_object.tpm_limit if user_object is not None else None - ), - user_rpm_limit=( - user_object.rpm_limit if user_object is not None else None - ), - team_member_rpm_limit=( - team_membership.safe_get_team_member_rpm_limit() - if team_membership is not None - else None - ), - team_member_tpm_limit=( - team_membership.safe_get_team_member_tpm_limit() - if team_membership is not None - else None - ), - team_metadata=team_object.metadata - if team_object is not None - else None, - ) + # Check if model has zero cost - if so, skip all budget checks + model = get_model_from_request(request_data, route) + skip_budget_checks = False + if model is not None and llm_router is not None: + from litellm.proxy.auth.auth_checks import _is_model_cost_zero - # Check if model has zero cost - if so, skip all budget checks - model = get_model_from_request(request_data, route) - skip_budget_checks = False - if model is not None and llm_router is not None: - from litellm.proxy.auth.auth_checks import _is_model_cost_zero - - skip_budget_checks = _is_model_cost_zero( - model=model, llm_router=llm_router - ) - if skip_budget_checks: - verbose_proxy_logger.info( - f"Skipping all budget checks for zero-cost model: {model}" + skip_budget_checks = _is_model_cost_zero( + model=model, llm_router=llm_router ) + if skip_budget_checks: + verbose_proxy_logger.info( + f"Skipping all budget checks for zero-cost model: {model}" + ) - # Fetch project object for JWT path if project_id is set - _jwt_project_obj = None - if valid_token.project_id is not None: - _jwt_project_obj = await get_project_object( - project_id=valid_token.project_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, + # Fetch project object for JWT path if project_id is set + _jwt_project_obj = None + if valid_token.project_id is not None: + _jwt_project_obj = await get_project_object( + project_id=valid_token.project_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + if _jwt_project_obj is not None: + valid_token.project_metadata = _jwt_project_obj.metadata + + # run through common checks + _ = await common_checks( + request=request, + request_body=request_data, + team_object=team_object, + user_object=user_object, + end_user_object=end_user_object, + general_settings=general_settings, + global_proxy_spend=global_proxy_spend, + route=route, + llm_router=llm_router, proxy_logging_obj=proxy_logging_obj, + valid_token=valid_token, + skip_budget_checks=skip_budget_checks, + project_object=_jwt_project_obj, ) - if _jwt_project_obj is not None: - valid_token.project_metadata = _jwt_project_obj.metadata - # run through common checks - _ = await common_checks( - request=request, - request_body=request_data, - team_object=team_object, - user_object=user_object, - end_user_object=end_user_object, - general_settings=general_settings, - global_proxy_spend=global_proxy_spend, - route=route, - llm_router=llm_router, - proxy_logging_obj=proxy_logging_obj, - valid_token=valid_token, - skip_budget_checks=skip_budget_checks, - project_object=_jwt_project_obj, - ) - - # return UserAPIKeyAuth object - return cast(UserAPIKeyAuth, valid_token) + # return UserAPIKeyAuth object + return cast(UserAPIKeyAuth, valid_token) #### ELSE #### ## CHECK PASS-THROUGH ENDPOINTS ## @@ -830,25 +922,26 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 # note: never string compare api keys, this is vulenerable to a time attack. Use secrets.compare_digest instead ### CHECK IF ADMIN ### # note: never string compare api keys, this is vulenerable to a time attack. Use secrets.compare_digest instead - ## Check CACHE - try: - valid_token = await get_key_object( - hashed_token=hash_token(api_key), - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - parent_otel_span=parent_otel_span, - proxy_logging_obj=proxy_logging_obj, - check_cache_only=True, - ) - except Exception: - verbose_logger.debug("api key not found in cache.") - valid_token = None + if valid_token is None: + ## Check CACHE + try: + valid_token = await get_key_object( + hashed_token=hash_token(api_key), + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + check_cache_only=True, + ) + except Exception: + verbose_logger.debug("api key not found in cache.") + valid_token = None - ## Check UI Hash Key - if valid_token is None and get_secret_bool("EXPERIMENTAL_UI_LOGIN"): - valid_token = ExperimentalUIJWTToken.get_key_object_from_ui_hash_key( - api_key - ) + ## Check UI Hash Key + if valid_token is None and get_secret_bool("EXPERIMENTAL_UI_LOGIN"): + valid_token = ExperimentalUIJWTToken.get_key_object_from_ui_hash_key( + api_key + ) if ( valid_token is not None @@ -986,9 +1079,6 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 param=None, ) - ## check for cache hit (In-Memory Cache) - _user_role = None - if valid_token is None: if isinstance( api_key, str diff --git a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py new file mode 100644 index 00000000000..056db954a5f --- /dev/null +++ b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py @@ -0,0 +1,152 @@ +import asyncio +from typing import List, Optional, Union +from fastapi import APIRouter, Depends, HTTPException, Request +import litellm +from litellm.proxy._types import * +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.utils import PrismaClient, ProxyLogging +from litellm.proxy.auth.auth_checks import _delete_cache_key_object + +router = APIRouter() + +@router.post("/jwt/key/mapping/new", tags=["JWT Key Mapping"]) +async def create_jwt_key_mapping( + data: CreateJWTKeyMappingRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + from litellm.proxy.proxy_server import prisma_client, user_api_key_cache + + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException(status_code=403, detail="Only proxy admins can create JWT key mappings") + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Database not connected") + + try: + new_mapping = await prisma_client.db.litellm_jwtkeymapping.create( + data={ + "jwt_claim_name": data.jwt_claim_name, + "jwt_claim_value": data.jwt_claim_value, + "token": data.token, + "is_active": data.is_active, + } + ) + + # Invalidate cache + cache_key = f"jwt_key_mapping:{data.jwt_claim_name}:{data.jwt_claim_value}" + await user_api_key_cache.async_delete_cache(cache_key) + + return new_mapping + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/jwt/key/mapping/update", tags=["JWT Key Mapping"]) +async def update_jwt_key_mapping( + data: UpdateJWTKeyMappingRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + from litellm.proxy.proxy_server import prisma_client, user_api_key_cache + + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException(status_code=403, detail="Only proxy admins can update JWT key mappings") + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Database not connected") + + update_data = data.model_dump(exclude_unset=True, exclude={"mapping_id"}) + + try: + # Get old mapping for cache invalidation + old_mapping = await prisma_client.db.litellm_jwtkeymapping.find_unique( + where={"mapping_id": data.mapping_id} + ) + + if old_mapping: + cache_key = f"jwt_key_mapping:{old_mapping.jwt_claim_name}:{old_mapping.jwt_claim_value}" + await user_api_key_cache.async_delete_cache(cache_key) + + updated_mapping = await prisma_client.db.litellm_jwtkeymapping.update( + where={"mapping_id": data.mapping_id}, + data=update_data + ) + + # Invalidate new cache key if claim fields changed + cache_key = f"jwt_key_mapping:{updated_mapping.jwt_claim_name}:{updated_mapping.jwt_claim_value}" + await user_api_key_cache.async_delete_cache(cache_key) + + return updated_mapping + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/jwt/key/mapping/delete", tags=["JWT Key Mapping"]) +async def delete_jwt_key_mapping( + data: DeleteJWTKeyMappingRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + from litellm.proxy.proxy_server import prisma_client, user_api_key_cache + + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException(status_code=403, detail="Only proxy admins can delete JWT key mappings") + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Database not connected") + + try: + # Get old mapping for cache invalidation + old_mapping = await prisma_client.db.litellm_jwtkeymapping.find_unique( + where={"mapping_id": data.mapping_id} + ) + + if old_mapping: + cache_key = f"jwt_key_mapping:{old_mapping.jwt_claim_name}:{old_mapping.jwt_claim_value}" + await user_api_key_cache.async_delete_cache(cache_key) + + await prisma_client.db.litellm_jwtkeymapping.delete( + where={"mapping_id": data.mapping_id} + ) + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/jwt/key/mapping/list", tags=["JWT Key Mapping"]) +async def list_jwt_key_mappings( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + from litellm.proxy.proxy_server import prisma_client + + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException(status_code=403, detail="Only proxy admins can list JWT key mappings") + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Database not connected") + + try: + mappings = await prisma_client.db.litellm_jwtkeymapping.find_many() + return mappings + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/jwt/key/mapping/info", tags=["JWT Key Mapping"]) +async def info_jwt_key_mapping( + mapping_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + from litellm.proxy.proxy_server import prisma_client + + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException(status_code=403, detail="Only proxy admins can get JWT key mapping info") + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Database not connected") + + try: + mapping = await prisma_client.db.litellm_jwtkeymapping.find_unique( + where={"mapping_id": mapping_id} + ) + if mapping is None: + raise HTTPException(status_code=404, detail="Mapping not found") + return mapping + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index be76c2ac5fb..4c613a4dcbf 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -376,6 +376,9 @@ from litellm.proxy.management_endpoints.key_management_endpoints import ( from litellm.proxy.management_endpoints.key_management_endpoints import ( router as key_management_router, ) +from litellm.proxy.management_endpoints.jwt_key_mapping_endpoints import ( + router as jwt_key_mapping_router, +) from litellm.proxy.management_endpoints.mcp_management_endpoints import ( router as mcp_management_router, ) @@ -12929,6 +12932,7 @@ app.include_router(debugging_endpoints_router) app.include_router(ui_crud_endpoints_router) app.include_router(openai_files_router) app.include_router(team_callback_router) +app.include_router(jwt_key_mapping_router) app.include_router(budget_management_router) app.include_router(model_management_router) app.include_router(model_access_group_management_router) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index a5b0d930f58..5b5ca8abf83 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -351,6 +351,7 @@ model LiteLLM_VerificationToken { litellm_organization_table LiteLLM_OrganizationTable? @relation(fields: [organization_id], references: [organization_id]) litellm_project_table LiteLLM_ProjectTable? @relation(fields: [project_id], references: [project_id]) object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id]) + jwt_key_mappings LiteLLM_JWTKeyMapping[] // SELECT COUNT(*) FROM (SELECT "public"."LiteLLM_VerificationToken"."token" FROM "public"."LiteLLM_VerificationToken" WHERE ("public"."LiteLLM_VerificationToken"."user_id" = $1 AND ("public"."LiteLLM_VerificationToken"."team_id" IS NULL OR "public"."LiteLLM_VerificationToken"."team_id" <> $2)) OFFSET $3 ) AS "sub" // SELECT ... FROM "public"."LiteLLM_VerificationToken" WHERE "public"."LiteLLM_VerificationToken"."user_id" = $1 OFFSET $2 @@ -363,6 +364,24 @@ model LiteLLM_VerificationToken { @@index([budget_reset_at, expires]) } +model LiteLLM_JWTKeyMapping { + id String @id @default(uuid()) + jwt_claim_name String // e.g. "sub", "email" + jwt_claim_value String // The claim value to match + token String // Hashed virtual key (FK) + description String? + is_active Boolean @default(true) + created_at DateTime @default(now()) + created_by String? + updated_at DateTime @default(now()) @updatedAt + updated_by String? + + litellm_verification_token LiteLLM_VerificationToken @relation(fields: [token], references: [token]) + + @@unique([jwt_claim_name, jwt_claim_value]) + @@index([jwt_claim_name, jwt_claim_value, is_active]) +} + // Deprecated keys during grace period - allows old key to work until revoke_at model LiteLLM_DeprecatedVerificationToken { id String @id @default(uuid()) diff --git a/tests/proxy_unit_tests/test_jwt_key_mapping.py b/tests/proxy_unit_tests/test_jwt_key_mapping.py new file mode 100644 index 00000000000..e44365897ad --- /dev/null +++ b/tests/proxy_unit_tests/test_jwt_key_mapping.py @@ -0,0 +1,112 @@ +import pytest +import sys +import os +from unittest.mock import AsyncMock, MagicMock, patch +from fastapi import Request +from starlette.datastructures import URL +import litellm + +# Add project root to sys.path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth, _resolve_jwt_to_virtual_key +from litellm.proxy.auth.handle_jwt import JWTHandler, JWTAuthManager +from litellm.proxy._types import LiteLLM_JWTAuth, UserAPIKeyAuth +from litellm.caching.caching import DualCache + +@pytest.mark.asyncio +async def test_jwt_to_virtual_key_mapping_resolution(): + """ + Test that a JWT claim is correctly resolved to a virtual key token. + """ + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth( + virtual_key_claim_field="email", + virtual_key_mapping_cache_ttl=3600 + ) + + jwt_claims = {"email": "user@example.com", "sub": "123"} + + prisma_client = MagicMock() + prisma_client.db.litellm_jwtkeymapping.find_first = AsyncMock() + + # Mock finding a mapping + mock_mapping = MagicMock() + mock_mapping.token = "sk-1234" + mock_mapping.is_active = True + prisma_client.db.litellm_jwtkeymapping.find_first.return_value = mock_mapping + + # Mock getting the key object + mock_key_obj = UserAPIKeyAuth(token="sk-1234", team_id="team1") + + user_api_key_cache = DualCache() + + # Use patch to mock get_key_object in the module where it's used + with patch("litellm.proxy.auth.user_api_key_auth.get_key_object", new_callable=AsyncMock) as mock_get_key: + mock_get_key.return_value = mock_key_obj + + result = await _resolve_jwt_to_virtual_key( + jwt_claims=jwt_claims, + jwt_handler=jwt_handler, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=None + ) + + assert result == mock_key_obj + prisma_client.db.litellm_jwtkeymapping.find_first.assert_called_once() + + # Test Cache hit + prisma_client.db.litellm_jwtkeymapping.find_first.reset_mock() + result_cached = await _resolve_jwt_to_virtual_key( + jwt_claims=jwt_claims, + jwt_handler=jwt_handler, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=None + ) + assert result_cached == mock_key_obj + prisma_client.db.litellm_jwtkeymapping.find_first.assert_not_called() + +@pytest.mark.asyncio +async def test_jwt_to_virtual_key_mapping_no_mapping(): + """ + Test that when no mapping exists, resolve returns None. + """ + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth(virtual_key_claim_field="email") + jwt_claims = {"email": "unknown@example.com"} + + prisma_client = MagicMock() + prisma_client.db.litellm_jwtkeymapping.find_first = AsyncMock() + prisma_client.db.litellm_jwtkeymapping.find_first.return_value = None + + # Mock get_key_object just in case + with patch("litellm.proxy.auth.user_api_key_auth.get_key_object", new_callable=AsyncMock) as mock_get_key: + user_api_key_cache = DualCache() + + result = await _resolve_jwt_to_virtual_key( + jwt_claims=jwt_claims, + jwt_handler=jwt_handler, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=None + ) + + assert result is None + + # Test Negative Cache hit + prisma_client.db.litellm_jwtkeymapping.find_first.reset_mock() + result_cached = await _resolve_jwt_to_virtual_key( + jwt_claims=jwt_claims, + jwt_handler=jwt_handler, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=None + ) + assert result_cached is None + prisma_client.db.litellm_jwtkeymapping.find_first.assert_not_called() From 465adce8721249af4ccab094daf307c71ffc955d Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Sat, 28 Feb 2026 13:40:53 +0530 Subject: [PATCH 016/380] feat reaq changes --- litellm/proxy/auth/handle_jwt.py | 78 ++++++++++--------- litellm/proxy/auth/user_api_key_auth.py | 7 +- .../jwt_key_mapping_endpoints.py | 45 ++++++----- litellm/proxy/proxy_server.py | 24 +++--- .../proxy_unit_tests/test_jwt_key_mapping.py | 58 +++++++------- 5 files changed, 119 insertions(+), 93 deletions(-) diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 210996a0a86..d3b028f63f9 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -165,7 +165,6 @@ class JWTHandler: return False def get_team_ids_from_jwt(self, token: dict) -> List[str]: - if self.litellm_jwtauth.team_ids_jwt_field is not None: team_ids: Optional[List[str]] = get_nested_value( data=token, @@ -245,7 +244,9 @@ class JWTHandler: team_id = default_value return team_id - def get_team_alias(self, token: dict, default_value: Optional[str]) -> Optional[str]: + def get_team_alias( + self, token: dict, default_value: Optional[str] + ) -> Optional[str]: """ Extract team name/alias from JWT token using the configured team_alias_jwt_field. @@ -538,17 +539,17 @@ class JWTHandler: async def get_oidc_userinfo(self, token: str) -> dict: """ Fetch user information from OIDC UserInfo endpoint. - + This follows the OpenID Connect protocol where an access token is sent to the identity provider's UserInfo endpoint to retrieve user identity information. - + Args: token: The access token to use for authentication - + Returns: dict: User information from the UserInfo endpoint - + Raises: Exception: If UserInfo endpoint is not configured or request fails """ @@ -556,19 +557,21 @@ class JWTHandler: raise Exception( "OIDC UserInfo endpoint not configured. Set 'oidc_userinfo_endpoint' in JWT auth config." ) - + # Check cache first - cache_key = f"oidc_userinfo_{token[:20]}" # Use first 20 chars of token as cache key + cache_key = ( + f"oidc_userinfo_{token[:20]}" # Use first 20 chars of token as cache key + ) cached_userinfo = await self.user_api_key_cache.async_get_cache(cache_key) - + if cached_userinfo is not None: verbose_proxy_logger.debug("Returning cached OIDC UserInfo") return cached_userinfo - + verbose_proxy_logger.debug( f"Calling OIDC UserInfo endpoint: {self.litellm_jwtauth.oidc_userinfo_endpoint}" ) - + try: # Call the UserInfo endpoint with the access token response = await self.http_handler.get( @@ -578,24 +581,24 @@ class JWTHandler: "Accept": "application/json", }, ) - + if response.status_code != 200: raise Exception( f"OIDC UserInfo endpoint returned status {response.status_code}: {response.text}" ) - + userinfo = response.json() verbose_proxy_logger.debug(f"Received OIDC UserInfo: {userinfo}") - + # Cache the userinfo response await self.user_api_key_cache.async_set_cache( key=cache_key, value=userinfo, ttl=self.litellm_jwtauth.oidc_userinfo_cache_ttl, ) - + return userinfo - + except Exception as e: verbose_proxy_logger.error(f"Error fetching OIDC UserInfo: {str(e)}") raise Exception(f"Failed to fetch OIDC UserInfo: {str(e)}") @@ -1032,11 +1035,11 @@ class JWTAuthManager: ) -> Tuple[ Optional[LiteLLM_UserTable], Optional[LiteLLM_OrganizationTable], - Optional[LiteLLM_EndUserTable], + Optional[LiteLLM_EndUserTable], Optional[LiteLLM_TeamMembership], ]: """Get user, org, and end user objects. Also resolves org aliases to IDs if configured.""" - + # Get org object - first try by ID, then by alias org_object: Optional[LiteLLM_OrganizationTable] = None if org_id: @@ -1373,7 +1376,9 @@ class JWTAuthManager: # Get team with model access ## Check if team_id is specified via x-litellm-team-id header all_team_ids = JWTAuthManager.get_all_team_ids(jwt_handler, jwt_valid_token) - specific_team_id = jwt_handler.get_team_id(token=jwt_valid_token, default_value=None) + specific_team_id = jwt_handler.get_team_id( + token=jwt_valid_token, default_value=None + ) if specific_team_id: all_team_ids.add(specific_team_id) @@ -1421,22 +1426,25 @@ class JWTAuthManager: org_alias = jwt_handler.get_org_alias(token=jwt_valid_token, default_value=None) # Get other objects - user_object, org_object, end_user_object, team_membership_object = ( - await JWTAuthManager.get_objects( - user_id=user_id, - user_email=user_email, - org_id=org_id, - end_user_id=end_user_id, - team_id=team_id, - valid_user_email=valid_user_email, - jwt_handler=jwt_handler, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - parent_otel_span=parent_otel_span, - proxy_logging_obj=proxy_logging_obj, - route=route, - org_alias=org_alias, - ) + ( + user_object, + org_object, + end_user_object, + team_membership_object, + ) = await JWTAuthManager.get_objects( + user_id=user_id, + user_email=user_email, + org_id=org_id, + end_user_id=end_user_id, + team_id=team_id, + valid_user_email=valid_user_email, + jwt_handler=jwt_handler, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + route=route, + org_alias=org_alias, ) # Derive org_id from org_object if resolved by alias diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index d453b721645..5c529bc69d6 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -744,10 +744,13 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 team_rpm_limit=( team_object.rpm_limit if team_object is not None else None ), - team_models=team_object.models if team_object is not None else [], + team_models=team_object.models + if team_object is not None + else [], user_role=( LitellmUserRoles(user_object.user_role) - if user_object is not None and user_object.user_role is not None + if user_object is not None + and user_object.user_role is not None else LitellmUserRoles.INTERNAL_USER ), user_id=user_id, diff --git a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py index 056db954a5f..06a526e13a9 100644 --- a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py +++ b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py @@ -1,14 +1,10 @@ -import asyncio -from typing import List, Optional, Union -from fastapi import APIRouter, Depends, HTTPException, Request -import litellm +from fastapi import APIRouter, Depends, HTTPException from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.proxy.utils import PrismaClient, ProxyLogging -from litellm.proxy.auth.auth_checks import _delete_cache_key_object router = APIRouter() + @router.post("/jwt/key/mapping/new", tags=["JWT Key Mapping"]) async def create_jwt_key_mapping( data: CreateJWTKeyMappingRequest, @@ -17,7 +13,9 @@ async def create_jwt_key_mapping( from litellm.proxy.proxy_server import prisma_client, user_api_key_cache if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: - raise HTTPException(status_code=403, detail="Only proxy admins can create JWT key mappings") + raise HTTPException( + status_code=403, detail="Only proxy admins can create JWT key mappings" + ) if prisma_client is None: raise HTTPException(status_code=500, detail="Database not connected") @@ -31,7 +29,7 @@ async def create_jwt_key_mapping( "is_active": data.is_active, } ) - + # Invalidate cache cache_key = f"jwt_key_mapping:{data.jwt_claim_name}:{data.jwt_claim_value}" await user_api_key_cache.async_delete_cache(cache_key) @@ -40,6 +38,7 @@ async def create_jwt_key_mapping( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + @router.post("/jwt/key/mapping/update", tags=["JWT Key Mapping"]) async def update_jwt_key_mapping( data: UpdateJWTKeyMappingRequest, @@ -48,28 +47,29 @@ async def update_jwt_key_mapping( from litellm.proxy.proxy_server import prisma_client, user_api_key_cache if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: - raise HTTPException(status_code=403, detail="Only proxy admins can update JWT key mappings") + raise HTTPException( + status_code=403, detail="Only proxy admins can update JWT key mappings" + ) if prisma_client is None: raise HTTPException(status_code=500, detail="Database not connected") update_data = data.model_dump(exclude_unset=True, exclude={"mapping_id"}) - + try: # Get old mapping for cache invalidation old_mapping = await prisma_client.db.litellm_jwtkeymapping.find_unique( where={"mapping_id": data.mapping_id} ) - + if old_mapping: cache_key = f"jwt_key_mapping:{old_mapping.jwt_claim_name}:{old_mapping.jwt_claim_value}" await user_api_key_cache.async_delete_cache(cache_key) updated_mapping = await prisma_client.db.litellm_jwtkeymapping.update( - where={"mapping_id": data.mapping_id}, - data=update_data + where={"mapping_id": data.mapping_id}, data=update_data ) - + # Invalidate new cache key if claim fields changed cache_key = f"jwt_key_mapping:{updated_mapping.jwt_claim_name}:{updated_mapping.jwt_claim_value}" await user_api_key_cache.async_delete_cache(cache_key) @@ -78,6 +78,7 @@ async def update_jwt_key_mapping( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + @router.post("/jwt/key/mapping/delete", tags=["JWT Key Mapping"]) async def delete_jwt_key_mapping( data: DeleteJWTKeyMappingRequest, @@ -86,7 +87,9 @@ async def delete_jwt_key_mapping( from litellm.proxy.proxy_server import prisma_client, user_api_key_cache if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: - raise HTTPException(status_code=403, detail="Only proxy admins can delete JWT key mappings") + raise HTTPException( + status_code=403, detail="Only proxy admins can delete JWT key mappings" + ) if prisma_client is None: raise HTTPException(status_code=500, detail="Database not connected") @@ -96,7 +99,7 @@ async def delete_jwt_key_mapping( old_mapping = await prisma_client.db.litellm_jwtkeymapping.find_unique( where={"mapping_id": data.mapping_id} ) - + if old_mapping: cache_key = f"jwt_key_mapping:{old_mapping.jwt_claim_name}:{old_mapping.jwt_claim_value}" await user_api_key_cache.async_delete_cache(cache_key) @@ -108,6 +111,7 @@ async def delete_jwt_key_mapping( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + @router.get("/jwt/key/mapping/list", tags=["JWT Key Mapping"]) async def list_jwt_key_mappings( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), @@ -115,7 +119,9 @@ async def list_jwt_key_mappings( from litellm.proxy.proxy_server import prisma_client if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: - raise HTTPException(status_code=403, detail="Only proxy admins can list JWT key mappings") + raise HTTPException( + status_code=403, detail="Only proxy admins can list JWT key mappings" + ) if prisma_client is None: raise HTTPException(status_code=500, detail="Database not connected") @@ -126,6 +132,7 @@ async def list_jwt_key_mappings( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + @router.get("/jwt/key/mapping/info", tags=["JWT Key Mapping"]) async def info_jwt_key_mapping( mapping_id: str, @@ -134,7 +141,9 @@ async def info_jwt_key_mapping( from litellm.proxy.proxy_server import prisma_client if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: - raise HTTPException(status_code=403, detail="Only proxy admins can get JWT key mapping info") + raise HTTPException( + status_code=403, detail="Only proxy admins can get JWT key mapping info" + ) if prisma_client is None: raise HTTPException(status_code=500, detail="Database not connected") diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 4c613a4dcbf..d80cb6be577 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2165,22 +2165,24 @@ async def _run_background_health_check(): "Error in shared health check, falling back to direct health check: %s", str(e), ) - healthy_endpoints, unhealthy_endpoints = ( - await _run_direct_health_check_with_instrumentation( - _llm_model_list, - health_check_details, - health_check_concurrency, - instrumentation_context, - ) - ) - else: - healthy_endpoints, unhealthy_endpoints = ( - await _run_direct_health_check_with_instrumentation( + ( + healthy_endpoints, + unhealthy_endpoints, + ) = await _run_direct_health_check_with_instrumentation( _llm_model_list, health_check_details, health_check_concurrency, instrumentation_context, ) + else: + ( + healthy_endpoints, + unhealthy_endpoints, + ) = await _run_direct_health_check_with_instrumentation( + _llm_model_list, + health_check_details, + health_check_concurrency, + instrumentation_context, ) # Update the global variable with the health check results diff --git a/tests/proxy_unit_tests/test_jwt_key_mapping.py b/tests/proxy_unit_tests/test_jwt_key_mapping.py index e44365897ad..7d3e7371b17 100644 --- a/tests/proxy_unit_tests/test_jwt_key_mapping.py +++ b/tests/proxy_unit_tests/test_jwt_key_mapping.py @@ -2,18 +2,18 @@ import pytest import sys import os from unittest.mock import AsyncMock, MagicMock, patch -from fastapi import Request -from starlette.datastructures import URL -import litellm # Add project root to sys.path sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) -from litellm.proxy.auth.user_api_key_auth import user_api_key_auth, _resolve_jwt_to_virtual_key -from litellm.proxy.auth.handle_jwt import JWTHandler, JWTAuthManager +from litellm.proxy.auth.user_api_key_auth import ( + _resolve_jwt_to_virtual_key, +) +from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy._types import LiteLLM_JWTAuth, UserAPIKeyAuth from litellm.caching.caching import DualCache + @pytest.mark.asyncio async def test_jwt_to_virtual_key_mapping_resolution(): """ @@ -21,42 +21,43 @@ async def test_jwt_to_virtual_key_mapping_resolution(): """ jwt_handler = JWTHandler() jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth( - virtual_key_claim_field="email", - virtual_key_mapping_cache_ttl=3600 + virtual_key_claim_field="email", virtual_key_mapping_cache_ttl=3600 ) - + jwt_claims = {"email": "user@example.com", "sub": "123"} - + prisma_client = MagicMock() prisma_client.db.litellm_jwtkeymapping.find_first = AsyncMock() - + # Mock finding a mapping mock_mapping = MagicMock() mock_mapping.token = "sk-1234" mock_mapping.is_active = True prisma_client.db.litellm_jwtkeymapping.find_first.return_value = mock_mapping - + # Mock getting the key object mock_key_obj = UserAPIKeyAuth(token="sk-1234", team_id="team1") - + user_api_key_cache = DualCache() - + # Use patch to mock get_key_object in the module where it's used - with patch("litellm.proxy.auth.user_api_key_auth.get_key_object", new_callable=AsyncMock) as mock_get_key: + with patch( + "litellm.proxy.auth.user_api_key_auth.get_key_object", new_callable=AsyncMock + ) as mock_get_key: mock_get_key.return_value = mock_key_obj - + result = await _resolve_jwt_to_virtual_key( jwt_claims=jwt_claims, jwt_handler=jwt_handler, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, parent_otel_span=None, - proxy_logging_obj=None + proxy_logging_obj=None, ) - + assert result == mock_key_obj prisma_client.db.litellm_jwtkeymapping.find_first.assert_called_once() - + # Test Cache hit prisma_client.db.litellm_jwtkeymapping.find_first.reset_mock() result_cached = await _resolve_jwt_to_virtual_key( @@ -65,11 +66,12 @@ async def test_jwt_to_virtual_key_mapping_resolution(): prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, parent_otel_span=None, - proxy_logging_obj=None + proxy_logging_obj=None, ) assert result_cached == mock_key_obj prisma_client.db.litellm_jwtkeymapping.find_first.assert_not_called() + @pytest.mark.asyncio async def test_jwt_to_virtual_key_mapping_no_mapping(): """ @@ -78,26 +80,28 @@ async def test_jwt_to_virtual_key_mapping_no_mapping(): jwt_handler = JWTHandler() jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth(virtual_key_claim_field="email") jwt_claims = {"email": "unknown@example.com"} - + prisma_client = MagicMock() prisma_client.db.litellm_jwtkeymapping.find_first = AsyncMock() prisma_client.db.litellm_jwtkeymapping.find_first.return_value = None - + # Mock get_key_object just in case - with patch("litellm.proxy.auth.user_api_key_auth.get_key_object", new_callable=AsyncMock) as mock_get_key: + with patch( + "litellm.proxy.auth.user_api_key_auth.get_key_object", new_callable=AsyncMock + ): user_api_key_cache = DualCache() - + result = await _resolve_jwt_to_virtual_key( jwt_claims=jwt_claims, jwt_handler=jwt_handler, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, parent_otel_span=None, - proxy_logging_obj=None + proxy_logging_obj=None, ) - + assert result is None - + # Test Negative Cache hit prisma_client.db.litellm_jwtkeymapping.find_first.reset_mock() result_cached = await _resolve_jwt_to_virtual_key( @@ -106,7 +110,7 @@ async def test_jwt_to_virtual_key_mapping_no_mapping(): prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, parent_otel_span=None, - proxy_logging_obj=None + proxy_logging_obj=None, ) assert result_cached is None prisma_client.db.litellm_jwtkeymapping.find_first.assert_not_called() From dcfd25e1f1e5a7707ac54fcc168b64ed0d732493 Mon Sep 17 00:00:00 2001 From: David Velarde Date: Sat, 28 Feb 2026 10:56:38 +0100 Subject: [PATCH 017/380] [Feature] Add Gemini 3.1 Flash Image Preview pricing details --- model_prices_and_context_window.json | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index f52288ea72a..5a43447e2c2 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -16421,6 +16421,39 @@ "supports_vision": true, "supports_web_search": true }, + "gemini/gemini-3.1-flash-image-preview": { + "input_cost_per_image": 0.0001375, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "gemini", + "max_input_tokens": 65536, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "image_generation", + "output_cost_per_image": 0.045, + "output_cost_per_image_token": 6e-05, + "output_cost_per_token": 1.5e-06, + "rpm": 1000, + "tpm": 4000000, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-image-preview", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_function_calling": false, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_vision": true, + "supports_web_search": true + }, "gemini/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, "input_cost_per_token": 2e-06, From 29d1d0479f3ef7d897fbd7cb707b0744f727101b Mon Sep 17 00:00:00 2001 From: David Velarde Date: Sat, 28 Feb 2026 11:09:38 +0100 Subject: [PATCH 018/380] [Feature] Add Gemini 3.1 Flash Image Preview input and output cost details --- model_prices_and_context_window.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 5a43447e2c2..f785fbbbb6e 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -16422,8 +16422,8 @@ "supports_web_search": true }, "gemini/gemini-3.1-flash-image-preview": { - "input_cost_per_image": 0.0001375, "input_cost_per_token": 2.5e-07, + "input_cost_per_token_batches": 1.25e-07, "litellm_provider": "gemini", "max_input_tokens": 65536, "max_output_tokens": 32768, @@ -16431,13 +16431,16 @@ "mode": "image_generation", "output_cost_per_image": 0.045, "output_cost_per_image_token": 6e-05, + "output_cost_per_image_token_batches": 3e-05, "output_cost_per_token": 1.5e-06, + "output_cost_per_token_batches": 7.5e-07, "rpm": 1000, "tpm": 4000000, "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-image-preview", "supported_endpoints": [ "/v1/chat/completions", - "/v1/completions" + "/v1/completions", + "/v1/batch" ], "supported_modalities": [ "text", From 941129c9e0bcf2206a439b9d1994d23496e15710 Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Sun, 1 Mar 2026 05:46:02 +0530 Subject: [PATCH 019/380] fix: resolve field mismatches and direct DB query in jwt key mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix data.token → hash_token(data.key) and remove non-existent data.is_active in create endpoint - Fix mapping_id → id in update, delete, and info endpoints to match Prisma schema - Extract direct DB query into get_jwt_key_mapping_object helper in auth_checks.py - Add hash_token import for proper key hashing before storage Co-Authored-By: Claude Opus 4.6 --- litellm/proxy/auth/auth_checks.py | 22 +++++++++++++++++++ litellm/proxy/auth/user_api_key_auth.py | 14 +++++------- .../jwt_key_mapping_endpoints.py | 19 ++++++++-------- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 500a39d9455..a7867fa08c7 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -2028,6 +2028,28 @@ async def _fetch_key_object_from_db_with_reconnect( raise +async def get_jwt_key_mapping_object( + jwt_claim_name: str, + jwt_claim_value: str, + prisma_client: PrismaClient, +) -> Optional[str]: + """ + Lookup a JWT-to-virtual-key mapping from the database. + + Returns the hashed token (str) if a matching active mapping is found, else None. + """ + mapping = await prisma_client.db.litellm_jwtkeymapping.find_first( + where={ + "jwt_claim_name": jwt_claim_name, + "jwt_claim_value": jwt_claim_value, + "is_active": True, + } + ) + if mapping is not None: + return mapping.token + return None + + @log_db_metrics async def get_key_object( hashed_token: str, diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 5c529bc69d6..7098b5e18d6 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -36,6 +36,7 @@ from litellm.proxy.auth.auth_checks import ( can_key_call_model, common_checks, get_end_user_object, + get_jwt_key_mapping_object, get_key_object, get_project_object, get_team_object, @@ -480,16 +481,13 @@ async def _resolve_jwt_to_virtual_key( if prisma_client is None: return None - mapping = await prisma_client.db.litellm_jwtkeymapping.find_first( - where={ - "jwt_claim_name": virtual_key_claim_field, - "jwt_claim_value": str(claim_value), - "is_active": True, - } + token_hash = await get_jwt_key_mapping_object( + jwt_claim_name=virtual_key_claim_field, + jwt_claim_value=str(claim_value), + prisma_client=prisma_client, ) - if mapping: - token_hash = mapping.token + if token_hash is not None: await user_api_key_cache.async_set_cache( key=cache_key, value=token_hash, diff --git a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py index 06a526e13a9..08ca00e66d4 100644 --- a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py +++ b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException from litellm.proxy._types import * +from litellm.proxy._types import hash_token from litellm.proxy.auth.user_api_key_auth import user_api_key_auth router = APIRouter() @@ -21,12 +22,12 @@ async def create_jwt_key_mapping( raise HTTPException(status_code=500, detail="Database not connected") try: + hashed_key = hash_token(data.key) new_mapping = await prisma_client.db.litellm_jwtkeymapping.create( data={ "jwt_claim_name": data.jwt_claim_name, "jwt_claim_value": data.jwt_claim_value, - "token": data.token, - "is_active": data.is_active, + "token": hashed_key, } ) @@ -54,12 +55,12 @@ async def update_jwt_key_mapping( if prisma_client is None: raise HTTPException(status_code=500, detail="Database not connected") - update_data = data.model_dump(exclude_unset=True, exclude={"mapping_id"}) + update_data = data.model_dump(exclude_unset=True, exclude={"id"}) try: # Get old mapping for cache invalidation old_mapping = await prisma_client.db.litellm_jwtkeymapping.find_unique( - where={"mapping_id": data.mapping_id} + where={"id": data.id} ) if old_mapping: @@ -67,7 +68,7 @@ async def update_jwt_key_mapping( await user_api_key_cache.async_delete_cache(cache_key) updated_mapping = await prisma_client.db.litellm_jwtkeymapping.update( - where={"mapping_id": data.mapping_id}, data=update_data + where={"id": data.id}, data=update_data ) # Invalidate new cache key if claim fields changed @@ -97,7 +98,7 @@ async def delete_jwt_key_mapping( try: # Get old mapping for cache invalidation old_mapping = await prisma_client.db.litellm_jwtkeymapping.find_unique( - where={"mapping_id": data.mapping_id} + where={"id": data.id} ) if old_mapping: @@ -105,7 +106,7 @@ async def delete_jwt_key_mapping( await user_api_key_cache.async_delete_cache(cache_key) await prisma_client.db.litellm_jwtkeymapping.delete( - where={"mapping_id": data.mapping_id} + where={"id": data.id} ) return {"status": "success"} except Exception as e: @@ -135,7 +136,7 @@ async def list_jwt_key_mappings( @router.get("/jwt/key/mapping/info", tags=["JWT Key Mapping"]) async def info_jwt_key_mapping( - mapping_id: str, + id: str, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): from litellm.proxy.proxy_server import prisma_client @@ -150,7 +151,7 @@ async def info_jwt_key_mapping( try: mapping = await prisma_client.db.litellm_jwtkeymapping.find_unique( - where={"mapping_id": mapping_id} + where={"id": id} ) if mapping is None: raise HTTPException(status_code=404, detail="Mapping not found") From 963390928d3be222170178848b20703d8f861391 Mon Sep 17 00:00:00 2001 From: Harshit Jain <48647625+Harshit28j@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:08:40 +0530 Subject: [PATCH 020/380] Update litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../proxy/management_endpoints/jwt_key_mapping_endpoints.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py index 08ca00e66d4..6c19f8c5dd4 100644 --- a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py +++ b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py @@ -55,7 +55,9 @@ async def update_jwt_key_mapping( if prisma_client is None: raise HTTPException(status_code=500, detail="Database not connected") - update_data = data.model_dump(exclude_unset=True, exclude={"id"}) + update_data = data.model_dump(exclude_unset=True, exclude={"id", "key"}) + if data.key is not None: + update_data["token"] = hash_token(data.key) try: # Get old mapping for cache invalidation From 0f9d3808748b74a35539c4ad1bf6dcfb1bc395cf Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Sun, 1 Mar 2026 13:28:06 +0530 Subject: [PATCH 021/380] fix: add pagination to jwt key mapping list endpoint Add page/size query params with take/skip to prevent unbounded queries. Returns paginated response with total_count, current_page, total_pages. Co-Authored-By: Claude Opus 4.6 --- .../jwt_key_mapping_endpoints.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py index 6c19f8c5dd4..770b9a48dd5 100644 --- a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py +++ b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from litellm.proxy._types import * from litellm.proxy._types import hash_token from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -118,6 +118,8 @@ async def delete_jwt_key_mapping( @router.get("/jwt/key/mapping/list", tags=["JWT Key Mapping"]) async def list_jwt_key_mappings( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + page: int = Query(1, description="Page number", ge=1), + size: int = Query(50, description="Page size", ge=1, le=100), ): from litellm.proxy.proxy_server import prisma_client @@ -130,8 +132,19 @@ async def list_jwt_key_mappings( raise HTTPException(status_code=500, detail="Database not connected") try: - mappings = await prisma_client.db.litellm_jwtkeymapping.find_many() - return mappings + skip = (page - 1) * size + mappings = await prisma_client.db.litellm_jwtkeymapping.find_many( + skip=skip, + take=size, + order={"created_at": "desc"}, + ) + total_count = await prisma_client.db.litellm_jwtkeymapping.count() + return { + "mappings": mappings, + "total_count": total_count, + "current_page": page, + "total_pages": -(-total_count // size), # ceiling division + } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) From 0e2dd4aac1a71623a72af4b2199731c27f5eaccc Mon Sep 17 00:00:00 2001 From: Harshit Jain <48647625+Harshit28j@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:43:06 +0530 Subject: [PATCH 022/380] Update litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../proxy/management_endpoints/jwt_key_mapping_endpoints.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py index 770b9a48dd5..9680a92ae67 100644 --- a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py +++ b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py @@ -22,12 +22,13 @@ async def create_jwt_key_mapping( raise HTTPException(status_code=500, detail="Database not connected") try: - hashed_key = hash_token(data.key) + try: new_mapping = await prisma_client.db.litellm_jwtkeymapping.create( data={ "jwt_claim_name": data.jwt_claim_name, "jwt_claim_value": data.jwt_claim_value, - "token": hashed_key, + "token": data.key, + "is_active": True, } ) From 911ba14e45509a250b19ca7c480dad188ac1ccca Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Sun, 1 Mar 2026 14:28:50 +0530 Subject: [PATCH 023/380] fix: address remaining greptile feedback for jwt key mapping - Persist description field on create (was silently dropped) - Remove phantom key_alias from JWTKeyMappingResponse (not in schema) - Populate created_by/updated_by audit fields from authenticated user - Pass actual jwt_valid_token in admin path instead of empty dict - Restore hash_token on create and fix duplicate try block Co-Authored-By: Claude Opus 4.6 --- litellm/proxy/_types.py | 3 ++- litellm/proxy/auth/handle_jwt.py | 5 +++-- .../jwt_key_mapping_endpoints.py | 20 ++++++++++++------- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 6440bf0ed81..6b51df709f7 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3692,11 +3692,12 @@ class JWTKeyMappingResponse(LiteLLMPydanticObjectBase): jwt_claim_name: str jwt_claim_value: str token: str - key_alias: Optional[str] = None description: Optional[str] = None is_active: bool created_at: datetime updated_at: datetime + created_by: Optional[str] = None + updated_by: Optional[str] = None class SpecialHeaders(enum.Enum): diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index d3b028f63f9..6ca7b290a07 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -831,6 +831,7 @@ class JWTAuthManager: user_id: Optional[str], org_id: Optional[str], api_key: str, + jwt_valid_token: Optional[dict] = None, ) -> Optional[JWTAuthBuilderResult]: """Check admin status and route access permissions""" if not jwt_handler.is_admin(scopes=scopes): @@ -860,7 +861,7 @@ class JWTAuthManager: end_user_id=None, org_id=org_id, team_membership=None, - jwt_claims={}, + jwt_claims=jwt_valid_token or {}, ) @staticmethod @@ -1368,7 +1369,7 @@ class JWTAuthManager: # Check admin access admin_result = await JWTAuthManager.check_admin_access( - jwt_handler, scopes, route, user_id, org_id, api_key + jwt_handler, scopes, route, user_id, org_id, api_key, jwt_valid_token ) if admin_result: return admin_result diff --git a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py index 9680a92ae67..c5d91d3699b 100644 --- a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py +++ b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py @@ -22,14 +22,19 @@ async def create_jwt_key_mapping( raise HTTPException(status_code=500, detail="Database not connected") try: - try: + hashed_key = hash_token(data.key) + create_data = { + "jwt_claim_name": data.jwt_claim_name, + "jwt_claim_value": data.jwt_claim_value, + "token": hashed_key, + "created_by": user_api_key_dict.user_id, + "updated_by": user_api_key_dict.user_id, + } + if data.description is not None: + create_data["description"] = data.description + new_mapping = await prisma_client.db.litellm_jwtkeymapping.create( - data={ - "jwt_claim_name": data.jwt_claim_name, - "jwt_claim_value": data.jwt_claim_value, - "token": data.key, - "is_active": True, - } + data=create_data ) # Invalidate cache @@ -59,6 +64,7 @@ async def update_jwt_key_mapping( update_data = data.model_dump(exclude_unset=True, exclude={"id", "key"}) if data.key is not None: update_data["token"] = hash_token(data.key) + update_data["updated_by"] = user_api_key_dict.user_id try: # Get old mapping for cache invalidation From 28a48acce645deaee4b53b485f1ad9eb022cfc70 Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Sun, 1 Mar 2026 16:46:49 +0530 Subject: [PATCH 024/380] fix: add @log_db_metrics and move jwt mapping before auth_builder - Add @log_db_metrics decorator to get_jwt_key_mapping_object for consistent DB latency/error tracking with other helpers - Move virtual key mapping lookup before auth_builder() to avoid unnecessary team/user/org DB queries when mapping resolves - JWT is decoded early; auth_builder only runs when no mapping found Co-Authored-By: Claude Opus 4.6 --- litellm/proxy/auth/auth_checks.py | 1 + litellm/proxy/auth/user_api_key_auth.py | 37 +++++++++++++++---------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index a7867fa08c7..e3776f2bfb7 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -2028,6 +2028,7 @@ async def _fetch_key_object_from_db_with_reconnect( raise +@log_db_metrics async def get_jwt_key_mapping_object( jwt_claim_name: str, jwt_claim_value: str, diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 7098b5e18d6..d6dad5167ca 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -660,24 +660,18 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 is_jwt = jwt_handler.is_jwt(token=api_key) verbose_proxy_logger.debug("is_jwt: %s", is_jwt) if is_jwt: - result = await JWTAuthManager.auth_builder( - request_data=request_data, - general_settings=general_settings, - api_key=api_key, - jwt_handler=jwt_handler, - route=route, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - proxy_logging_obj=proxy_logging_obj, - parent_otel_span=parent_otel_span, - request_headers=_safe_get_request_headers(request), - ) - - # JWT-to-Virtual-Key Mapping lookup + # Try JWT-to-Virtual-Key mapping first to avoid + # unnecessary DB queries in auth_builder do_standard_jwt_auth = True if jwt_handler.litellm_jwtauth.virtual_key_claim_field is not None: + # Decode JWT to get claims without running full auth_builder + if jwt_handler.litellm_jwtauth.oidc_userinfo_enabled: + jwt_claims = await jwt_handler.get_oidc_userinfo(token=api_key) + else: + jwt_claims = await jwt_handler.auth_jwt(token=api_key) + valid_token = await _resolve_jwt_to_virtual_key( - jwt_claims=result["jwt_claims"], + jwt_claims=jwt_claims, jwt_handler=jwt_handler, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, @@ -690,6 +684,19 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 # Fall through to virtual key checks if do_standard_jwt_auth: + result = await JWTAuthManager.auth_builder( + request_data=request_data, + general_settings=general_settings, + api_key=api_key, + jwt_handler=jwt_handler, + route=route, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + parent_otel_span=parent_otel_span, + request_headers=_safe_get_request_headers(request), + ) + is_proxy_admin = result["is_proxy_admin"] team_id = result["team_id"] team_object = result["team_object"] From a3cdf6c89540a8b171e119fa38f8b0aeca0ab66a Mon Sep 17 00:00:00 2001 From: David Steele Date: Mon, 2 Mar 2026 08:59:59 +0000 Subject: [PATCH 025/380] fix(streaming): don't emit finish_reason on output_item.done for function_call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The response.output_item.done handler for function_call type was emitting finish_reason='tool_calls' and a duplicate tool_call delta. This caused premature stream termination after the first tool call in multi-tool scenarios — downstream wrappers (e.g. AnthropicStreamWrapper) would close the stream before subsequent tool calls arrived. The response.completed event already inspects the response output list and emits finish_reason='tool_calls' when function_call items are present, so output_item.done does not need to (and must not) do so. This mirrors the existing fix for message-type output_item.done (#17246). Updated test_function_call_done_emits_is_finished (renamed) to assert finish_reason=None and no duplicate delta. Updated test_text_plus_tool_calls_sequence to match. Added test_multi_tool_call_stream_no_premature_finish which exercises a synthetic 2-tool-call stream and verifies no premature termination. --- .../transformation.py | 8 +- ...responses_transformation_transformation.py | 174 +++++++++++++++++- 2 files changed, 170 insertions(+), 12 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 1704861686e..e0e47a48b9d 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -1025,12 +1025,16 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): if provider_specific_fields: tool_call_chunk.provider_specific_fields = provider_specific_fields # type: ignore + # Do NOT emit finish_reason here — response.completed handles the terminal + # finish_reason. Emitting "tool_calls" here would prematurely terminate + # the stream before subsequent tool calls arrive (same fix as #17246 for + # the message-type branch). return ModelResponseStream( choices=[ StreamingChoices( index=0, - delta=Delta(tool_calls=[tool_call_chunk]), - finish_reason="tool_calls", + delta=Delta(), + finish_reason=None, ) ] ) diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 3021fff9a22..e7429fd7cb7 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -738,10 +738,12 @@ def test_response_completed_with_message_only_emits_stop_finish_reason(): ) -def test_function_call_done_emits_is_finished(): +def test_function_call_done_does_not_emit_finish_reason(): """ - Test that OUTPUT_ITEM_DONE for a function_call still emits is_finished=True. - This preserves existing behavior for tool_calls. + Test that OUTPUT_ITEM_DONE for a function_call does NOT emit finish_reason. + The response.completed event handles the terminal finish_reason correctly. + Emitting finish_reason here would prematurely terminate the stream in multi-tool + scenarios (same fix as #17246 for the message-type branch). """ from litellm.completion_extras.litellm_responses_transformation.transformation import ( OpenAiResponsesToChatCompletionStreamIterator, @@ -761,11 +763,14 @@ def test_function_call_done_emits_is_finished(): result = iterator.chunk_parser(chunk) - # function_call completion should emit finish_reason='tool_calls' + # function_call completion should NOT emit finish_reason — response.completed handles it assert len(result.choices) > 0, "result should have choices" - assert result.choices[0].finish_reason == "tool_calls", "function_call should emit finish_reason='tool_calls'" - assert result.choices[0].delta.tool_calls is not None and len(result.choices[0].delta.tool_calls) > 0, ( - "function_call should include tool_calls" + assert result.choices[0].finish_reason is None, ( + "output_item.done for function_call must not emit finish_reason; " + "response.completed is responsible for the terminal finish_reason" + ) + assert not result.choices[0].delta.tool_calls, ( + "output_item.done for function_call must not include a duplicate tool_calls delta" ) @@ -824,14 +829,16 @@ def test_text_plus_tool_calls_sequence(): "message done should not have finish_reason" ) - # Check function_call done (index 5) DOES have finish_reason='tool_calls' + # Check function_call done (index 5) does NOT have finish_reason set + # (response.completed is responsible for the terminal finish_reason) function_done_result = results[5] assert len(function_done_result.choices) > 0, "function_call done should have choices" - assert function_done_result.choices[0].finish_reason == "tool_calls", ( - "function_call done should have finish_reason='tool_calls'" + assert function_done_result.choices[0].finish_reason is None, ( + "output_item.done for function_call must not emit finish_reason" ) # Check response.completed (index 6) has finish_reason='stop' + # (the mock chunk has no nested 'response' data, so has_function_calls is False → 'stop') completed_result = results[6] assert len(completed_result.choices) > 0, "response.completed should have choices" assert completed_result.choices[0].finish_reason == "stop", "response.completed should have finish_reason='stop'" @@ -1317,4 +1324,151 @@ def test_transform_response_preserves_annotations(): assert result.usage.completion_tokens == 20 assert result.usage.total_tokens == 30 + +def test_multi_tool_call_stream_no_premature_finish(): + """ + Regression test for multi-tool-call streaming bug. + + When a response contains multiple tool calls, the stream used to be prematurely + terminated after the first output_item.done event because that handler emitted + finish_reason="tool_calls". This caused ~58% of streaming requests with multiple + tool calls to fail. + + The fix: output_item.done for function_call emits delta=Delta() and finish_reason=None. + Only response.completed emits the terminal finish_reason. + + Synthetic event sequence: + response.created + response.output_item.added (function_call: read_file, call_id: call_1) + response.function_call_arguments.delta (read_file args) + response.output_item.done (function_call: read_file) <- must NOT end stream + response.output_item.added (function_call: list_dir, call_id: call_2) + response.function_call_arguments.delta (list_dir args) + response.output_item.done (function_call: list_dir) <- must NOT end stream + response.completed (response with 2 function_call outputs) <- terminal + """ + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + OpenAiResponsesToChatCompletionStreamIterator, + ) + + iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True) + + chunks = [ + # 0: response created + {"type": "response.created", "response": {"id": "resp_001", "status": "in_progress"}}, + # 1: first tool call added + { + "type": "response.output_item.added", + "item": {"type": "function_call", "name": "read_file", "call_id": "call_1"}, + }, + # 2: first tool call arguments delta + {"type": "response.function_call_arguments.delta", "delta": '{"path":"/etc/hostname"}'}, + # 3: first tool call done ← must NOT emit finish_reason + { + "type": "response.output_item.done", + "item": { + "type": "function_call", + "name": "read_file", + "call_id": "call_1", + "arguments": '{"path":"/etc/hostname"}', + }, + }, + # 4: second tool call added + { + "type": "response.output_item.added", + "item": {"type": "function_call", "name": "list_dir", "call_id": "call_2"}, + }, + # 5: second tool call arguments delta + {"type": "response.function_call_arguments.delta", "delta": '{"path":"/tmp"}'}, + # 6: second tool call done ← must NOT emit finish_reason + { + "type": "response.output_item.done", + "item": { + "type": "function_call", + "name": "list_dir", + "call_id": "call_2", + "arguments": '{"path":"/tmp"}', + }, + }, + # 7: response completed with both tool calls in output ← ONLY terminal chunk + { + "type": "response.completed", + "response": { + "id": "resp_001", + "status": "completed", + "output": [ + { + "type": "function_call", + "name": "read_file", + "call_id": "call_1", + "arguments": '{"path":"/etc/hostname"}', + }, + { + "type": "function_call", + "name": "list_dir", + "call_id": "call_2", + "arguments": '{"path":"/tmp"}', + }, + ], + }, + }, + ] + + results = [iterator.chunk_parser(chunk) for chunk in chunks] + + # 1. output_item.done events (indices 3 and 6) must NOT emit finish_reason + for done_idx, label in [(3, "read_file done"), (6, "list_dir done")]: + r = results[done_idx] + assert r is not None, f"{label}: chunk_parser must return a result" + assert len(r.choices) > 0, f"{label}: result must have choices" + assert r.choices[0].finish_reason is None, ( + f"{label}: output_item.done must not emit finish_reason (stream would terminate prematurely)" + ) + assert not r.choices[0].delta.tool_calls, ( + f"{label}: output_item.done must not include a duplicate tool_calls delta" + ) + + # 2. output_item.added events (indices 1 and 4) should carry name + call_id + for added_idx, expected_name, expected_call_id in [ + (1, "read_file", "call_1"), + (4, "list_dir", "call_2"), + ]: + r = results[added_idx] + if r is not None and r.choices and r.choices[0].delta.tool_calls: + tc = r.choices[0].delta.tool_calls[0] + assert tc.function.name == expected_name, ( + f"output_item.added for {expected_name}: tool_call name mismatch" + ) + assert tc.id == expected_call_id, ( + f"output_item.added for {expected_name}: call_id mismatch" + ) + + # 3. argument delta events (indices 2 and 5) should carry arguments + for delta_idx, expected_args, label in [ + (2, '{"path":"/etc/hostname"}', "read_file args"), + (5, '{"path":"/tmp"}', "list_dir args"), + ]: + r = results[delta_idx] + if r is not None and r.choices and r.choices[0].delta.tool_calls: + tc = r.choices[0].delta.tool_calls[0] + assert tc.function.arguments == expected_args, ( + f"{label}: argument delta mismatch" + ) + + # 4. Only response.completed (index 7) emits the terminal finish_reason + completed_result = results[7] + assert completed_result is not None, "response.completed must return a result" + assert len(completed_result.choices) > 0, "response.completed must have choices" + assert completed_result.choices[0].finish_reason == "tool_calls", ( + "response.completed with function_call outputs must emit finish_reason='tool_calls'" + ) + + # 5. No chunk before the last one should have finish_reason set + for idx, r in enumerate(results[:-1]): + if r is not None and r.choices: + assert r.choices[0].finish_reason is None, ( + f"Chunk at index {idx} (type={chunks[idx]['type']!r}) must not emit finish_reason " + f"— only response.completed should terminate the stream" + ) + print("✓ Annotations from Responses API are correctly preserved in Chat Completions format") From a14ef270094aaefe28c5cb3374c1cfcdcc1a7f97 Mon Sep 17 00:00:00 2001 From: David Steele Date: Mon, 2 Mar 2026 09:08:03 +0000 Subject: [PATCH 026/380] test: fix copy-paste print message in multi-tool-call test --- ...on_extras_litellm_responses_transformation_transformation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index e7429fd7cb7..715d3f7b062 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -1471,4 +1471,4 @@ def test_multi_tool_call_stream_no_premature_finish(): f"— only response.completed should terminate the stream" ) - print("✓ Annotations from Responses API are correctly preserved in Chat Completions format") + print("✓ Multi-tool-call stream completes without premature finish_reason termination") From 8c8d1debee7f91078998f7eac0f15dae57db167c Mon Sep 17 00:00:00 2001 From: Kerem Turgutlu Date: Tue, 3 Mar 2026 08:51:06 +0300 Subject: [PATCH 027/380] fix: preserve usage/cached_tokens in Responses API streaming bridge (#22194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The response.completed handler in the completion→responses streaming bridge was discarding the usage object, causing prompt_tokens_details (and cached_tokens) to always be None when streaming with models that use the Responses API (e.g. gpt-5.2-codex, gpt-5.3-codex). Extract usage from the response.completed event and translate it via the existing _transform_response_api_usage_to_chat_usage helper. Fixes #22192 --- .../transformation.py | 9 +++- ...responses_transformation_transformation.py | 51 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 1704861686e..413c19bfc25 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -1088,6 +1088,12 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): finish_reason = "tool_calls" if has_function_calls else "stop" + usage = None + if response_data.get("usage"): + from litellm.responses.utils import ResponseAPILoggingUtils + usage = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage( + response_data.get("usage") + ) return ModelResponseStream( choices=[ StreamingChoices( @@ -1095,7 +1101,8 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): delta=Delta(content=""), finish_reason=finish_reason, ) - ] + ], + usage=usage ) else: pass diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 3021fff9a22..cdafe247990 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -738,6 +738,57 @@ def test_response_completed_with_message_only_emits_stop_finish_reason(): ) + +def test_response_completed_preserves_usage_with_cached_tokens(): + """ + Test that response.completed correctly translates Responses API usage + (input_tokens_details) to chat completion usage (prompt_tokens_details). + + This is a regression test for an issue where streaming with models that + use the Responses API bridge (e.g. gpt-5.2-codex) would drop + prompt_tokens_details, causing cached_tokens to always be None. + """ + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + OpenAiResponsesToChatCompletionStreamIterator, + ) + + iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True) + + chunk = { + "type": "response.completed", + "response": { + "id": "resp_789", + "status": "completed", + "output": [ + { + "type": "message", + "id": "msg_abc", + "role": "assistant", + "content": [{"type": "output_text", "text": "Six"}], + "status": "completed", + } + ], + "usage": { + "input_tokens": 1226, + "output_tokens": 5, + "total_tokens": 1231, + "input_tokens_details": {"cached_tokens": 1024}, + "output_tokens_details": {"reasoning_tokens": 0}, + }, + }, + } + + result = iterator.chunk_parser(chunk) + + assert result.usage is not None, "usage should be set on response.completed chunk" + assert result.usage.prompt_tokens == 1226, "prompt_tokens should map from input_tokens" + assert result.usage.completion_tokens == 5, "completion_tokens should map from output_tokens" + assert result.usage.prompt_tokens_details is not None, "prompt_tokens_details should be set" + assert result.usage.prompt_tokens_details.cached_tokens == 1024, ( + "cached_tokens should be preserved from input_tokens_details" + ) + + def test_function_call_done_emits_is_finished(): """ Test that OUTPUT_ITEM_DONE for a function_call still emits is_finished=True. From 239f044721a34901bfaa1216bf0079149adf0fb3 Mon Sep 17 00:00:00 2001 From: pnookala-godaddy <93624827+pnookala-godaddy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:52:32 -0800 Subject: [PATCH 028/380] fix(caching): inject default_in_memory_ttl in DualCache async_set_cache and async_set_cache_pipeline (#22241) DualCache.async_set_cache and async_set_cache_pipeline were missing the default_in_memory_ttl injection that the sync set_cache method has. This caused InMemoryCache to fall back to its own default_ttl (600s) instead of using DualCache's configured default_in_memory_ttl (typically 60s). This is particularly impactful for end-user budget enforcement in the proxy, where cached spend values could remain stale for 10 minutes instead of 1 minute, allowing users to exceed their budgets. --- litellm/caching/dual_cache.py | 4 + tests/test_litellm/caching/test_dual_cache.py | 103 ++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/litellm/caching/dual_cache.py b/litellm/caching/dual_cache.py index 6df570c72b9..48f4d8b8d3d 100644 --- a/litellm/caching/dual_cache.py +++ b/litellm/caching/dual_cache.py @@ -346,6 +346,8 @@ class DualCache(BaseCache): ) try: if self.in_memory_cache is not None: + if "ttl" not in kwargs and self.default_in_memory_ttl is not None: + kwargs["ttl"] = self.default_in_memory_ttl await self.in_memory_cache.async_set_cache(key, value, **kwargs) if self.redis_cache is not None and local_only is False: @@ -367,6 +369,8 @@ class DualCache(BaseCache): ) try: if self.in_memory_cache is not None: + if "ttl" not in kwargs and self.default_in_memory_ttl is not None: + kwargs["ttl"] = self.default_in_memory_ttl await self.in_memory_cache.async_set_cache_pipeline( cache_list=cache_list, **kwargs ) diff --git a/tests/test_litellm/caching/test_dual_cache.py b/tests/test_litellm/caching/test_dual_cache.py index 9974c23e4b4..606f25ddf44 100644 --- a/tests/test_litellm/caching/test_dual_cache.py +++ b/tests/test_litellm/caching/test_dual_cache.py @@ -1,9 +1,11 @@ import asyncio +import time from unittest.mock import AsyncMock, MagicMock, patch import pytest from litellm.caching.dual_cache import DualCache +from litellm.caching.in_memory_cache import InMemoryCache from litellm.caching.redis_cache import RedisCache @@ -56,3 +58,104 @@ async def test_dual_cache_async_batch_get_cache_rolls_back_redis_reservation_on_ assert mock_async_batch_get_cache.call_count == 2 assert "shared_a" not in dual_cache.last_redis_batch_access_time assert "shared_b" not in dual_cache.last_redis_batch_access_time + + +@pytest.mark.asyncio +async def test_dual_cache_async_set_cache_injects_default_in_memory_ttl(): + """ + Test that async_set_cache injects default_in_memory_ttl into kwargs + when no explicit ttl is provided, matching the sync set_cache behavior. + + Regression test for: async_set_cache was missing the TTL injection that + sync set_cache has, causing InMemoryCache to use its own default_ttl (600s) + instead of DualCache's default_in_memory_ttl. + """ + in_memory_cache = InMemoryCache(default_ttl=600) + dual_cache = DualCache( + in_memory_cache=in_memory_cache, + default_in_memory_ttl=60, + ) + + before = time.time() + await dual_cache.async_set_cache(key="test_key", value="test_value") + after = time.time() + + # The TTL stored should reflect default_in_memory_ttl (60s), not + # InMemoryCache's default_ttl (600s) + expiry = in_memory_cache.ttl_dict["test_key"] + assert expiry >= before + 60 + assert expiry <= after + 60 + + +@pytest.mark.asyncio +async def test_dual_cache_async_set_cache_respects_explicit_ttl(): + """ + Test that async_set_cache does NOT override an explicitly provided ttl. + """ + in_memory_cache = InMemoryCache(default_ttl=600) + dual_cache = DualCache( + in_memory_cache=in_memory_cache, + default_in_memory_ttl=60, + ) + + before = time.time() + await dual_cache.async_set_cache(key="test_key", value="test_value", ttl=30) + after = time.time() + + # The explicit ttl=30 should be used, not default_in_memory_ttl (60) + expiry = in_memory_cache.ttl_dict["test_key"] + assert expiry >= before + 30 + assert expiry <= after + 30 + + +@pytest.mark.asyncio +async def test_dual_cache_async_set_cache_pipeline_injects_default_in_memory_ttl(): + """ + Test that async_set_cache_pipeline injects default_in_memory_ttl into kwargs + when no explicit ttl is provided. + """ + in_memory_cache = InMemoryCache(default_ttl=600) + dual_cache = DualCache( + in_memory_cache=in_memory_cache, + default_in_memory_ttl=60, + ) + + cache_list = [("key_a", "value_a"), ("key_b", "value_b")] + + before = time.time() + await dual_cache.async_set_cache_pipeline(cache_list=cache_list) + after = time.time() + + for key in ["key_a", "key_b"]: + expiry = in_memory_cache.ttl_dict[key] + assert expiry >= before + 60 + assert expiry <= after + 60 + + +@pytest.mark.asyncio +async def test_dual_cache_sync_and_async_set_cache_use_same_ttl(): + """ + Test that sync set_cache and async async_set_cache produce the same TTL + when no explicit ttl is provided, ensuring parity between the two paths. + """ + in_memory_sync = InMemoryCache(default_ttl=600) + dual_cache_sync = DualCache( + in_memory_cache=in_memory_sync, + default_in_memory_ttl=60, + ) + + in_memory_async = InMemoryCache(default_ttl=600) + dual_cache_async = DualCache( + in_memory_cache=in_memory_async, + default_in_memory_ttl=60, + ) + + dual_cache_sync.set_cache(key="test_key", value="test_value") + await dual_cache_async.async_set_cache(key="test_key", value="test_value") + + sync_expiry = in_memory_sync.ttl_dict["test_key"] + async_expiry = in_memory_async.ttl_dict["test_key"] + + # Both should use default_in_memory_ttl=60, so their expiry times + # should be within a small tolerance of each other + assert abs(sync_expiry - async_expiry) < 1.0 From 52c5f2af6bb0649e9e3eee85935742fb47e9facc Mon Sep 17 00:00:00 2001 From: Umut Polat <52835619+umut-polat@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:56:37 +0300 Subject: [PATCH 029/380] fix: apply server root path to mapped passthrough route matching (#22310) mapped passthrough routes (vertex_ai, bedrock, etc) were compared against the raw request path without prepending SERVER_ROOT_PATH. db-registered routes already used _build_full_path_with_root for this but the mapped routes branch was missed. fixes #22272 --- .../pass_through_endpoints.py | 3 +- .../test_pass_through_endpoints.py | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 356807415de..4d95fda0a44 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -2062,7 +2062,8 @@ class InitPassThroughEndpointHelpers: """ ## CHECK IF MAPPED PASS THROUGH ENDPOINT for mapped_route in LiteLLMRoutes.mapped_pass_through_routes.value: - if route.startswith(mapped_route): + full_mapped_route = InitPassThroughEndpointHelpers._build_full_path_with_root(mapped_route) + if route.startswith(full_mapped_route): return True # Fast path: check if any registered route key contains this path diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index 7ec97ddc185..71420c23ad1 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -2369,3 +2369,42 @@ def test_get_registered_pass_through_route_with_custom_root(): # Clean up _registered_pass_through_routes.clear() + + +def test_mapped_pass_through_routes_with_server_root_path(): + """ + Mapped passthrough routes (vertex_ai, bedrock, etc) should match + even when SERVER_ROOT_PATH is set and the incoming route is prefixed. + + Regression test for https://github.com/BerriAI/litellm/issues/22272 + """ + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + InitPassThroughEndpointHelpers, + ) + + with patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path" + ) as mock_get_root: + mock_get_root.return_value = "/litellm" + + # prefixed route should match mapped routes like /vertex_ai + assert ( + InitPassThroughEndpointHelpers.is_registered_pass_through_route( + "/litellm/vertex_ai/v1/projects/foo" + ) + is True + ) + assert ( + InitPassThroughEndpointHelpers.is_registered_pass_through_route( + "/litellm/bedrock/model/invoke" + ) + is True + ) + + # bare route without prefix should not match when root is set + assert ( + InitPassThroughEndpointHelpers.is_registered_pass_through_route( + "/vertex_ai/v1/projects/foo" + ) + is False + ) From 2e362327b630ce2ce93751ecb020e787fbae7b0a Mon Sep 17 00:00:00 2001 From: Alejandro Tapia <67175024+atapia27@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:04:51 -0800 Subject: [PATCH 030/380] Update litellm/proxy/management_endpoints/internal_user_endpoints.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- litellm/proxy/management_endpoints/internal_user_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index f5488ce865d..d88d2810193 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1858,7 +1858,7 @@ async def ui_view_users( try: # Restrict by caller role: proxy admin sees all; org admin sees only their org(s); others 403 - is_proxy_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + is_proxy_admin = _user_has_admin_view(user_api_key_dict) if not is_proxy_admin: if user_api_key_dict.user_id is None: raise HTTPException( From 1c04016d7bced99f9747debe2d6e255c7860292d Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Tue, 3 Mar 2026 16:07:18 -0800 Subject: [PATCH 031/380] Fix: get_user_object raises on missing user, never returns None --- .../internal_user_endpoints.py | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index d88d2810193..3d799c5731c 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1867,13 +1867,22 @@ async def ui_view_users( "error": "Only proxy admins and organization admins can search users." }, ) - caller_user = await get_user_object( - user_id=user_api_key_dict.user_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - user_id_upsert=False, - proxy_logging_obj=proxy_logging_obj, - ) + try: + caller_user = await get_user_object( + user_id=user_api_key_dict.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + proxy_logging_obj=proxy_logging_obj, + ) + except ValueError: + # get_user_object raises ValueError when user not found (user_id_upsert=False) + raise HTTPException( + status_code=403, + detail={ + "error": "Only proxy admins and organization admins can search users." + }, + ) if caller_user is None: raise HTTPException( status_code=403, From cb07c75201d5f926361b19de28da302a43cfc15e Mon Sep 17 00:00:00 2001 From: Alejandro Tapia <67175024+atapia27@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:11:31 -0800 Subject: [PATCH 032/380] Update litellm/proxy/management_endpoints/internal_user_endpoints.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- litellm/proxy/management_endpoints/internal_user_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 3d799c5731c..70a1801fb1e 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1922,7 +1922,7 @@ async def ui_view_users( } # Org admins: only users in their org(s) - if not is_proxy_admin and org_admin_org_ids: + if not is_proxy_admin: where_conditions["organization_memberships"] = { "some": {"organization_id": {"in": org_admin_org_ids}} } From a2f3beb26f15c7354257b2d4a84bf621a819f4f6 Mon Sep 17 00:00:00 2001 From: Cesar Garcia <128240629+Chesars@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:47:55 -0300 Subject: [PATCH 033/380] Update tests/test_litellm/llms/base_llm/test_base_model_iterator.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- tests/test_litellm/llms/base_llm/test_base_model_iterator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_litellm/llms/base_llm/test_base_model_iterator.py b/tests/test_litellm/llms/base_llm/test_base_model_iterator.py index d5166c4690e..96cd299b2bc 100644 --- a/tests/test_litellm/llms/base_llm/test_base_model_iterator.py +++ b/tests/test_litellm/llms/base_llm/test_base_model_iterator.py @@ -4,7 +4,7 @@ Tests for BaseModelResponseIterator - specifically testing that empty SSE lines import pytest from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator -from litellm.types.utils import GenericStreamingChunk, ModelResponseStream +from litellm.types.utils import GenericStreamingChunk class TestBaseModelResponseIterator: From 36999b23ee976726631035054ca2f7df3196c62a Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 4 Mar 2026 13:07:25 +0530 Subject: [PATCH 034/380] [Chore] update mcp documentation for header forwarding --- docs/my-website/docs/mcp.md | 57 +++++++++++++++++++ docs/my-website/docs/mcp_control.md | 8 +-- .../src/components/mcp_tools/mcp_connect.tsx | 2 +- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/docs/my-website/docs/mcp.md b/docs/my-website/docs/mcp.md index fcbb31c07d3..c7789201579 100644 --- a/docs/my-website/docs/mcp.md +++ b/docs/my-website/docs/mcp.md @@ -870,6 +870,63 @@ asyncio.run(main()) [Learn more about customer management →](./proxy/customers) +## Calling the Proxy's /v1/responses Endpoint + +When calling your LiteLLM Proxy's `/v1/responses` endpoint to use MCP tools, **always use `server_url: "litellm_proxy"`** in the tools array. This tells the proxy to use its configured MCP servers. + +:::important Do not use the full proxy URL +Using `server_url: "https://your-proxy.com/mcp"` is incorrect when the request is already going to the proxy. The proxy needs the literal value `litellm_proxy` to route to its configured MCP servers. +::: + +```bash title="Correct: Using litellm_proxy" showLineNumbers +curl --location 'https://your-proxy.com/v1/responses' \ +--header 'Content-Type: application/json' \ +--header "Authorization: Bearer $LITELLM_API_KEY" \ +--data '{ + "model": "gpt-4", + "tools": [ + { + "type": "mcp", + "server_label": "litellm", + "server_url": "litellm_proxy", + "require_approval": "never" + } + ], + "input": "Run available tools", + "tool_choice": "required" +}' +``` + +### Sending Custom Headers to MCP Servers + +To pass custom headers (e.g., API keys, auth tokens) to specific MCP servers, use either: + +**Option 1: Request headers** – Add `x-mcp-{server_alias}-{header_name}` to your request headers. The proxy forwards these to the matching MCP server. + +```bash +# Send Authorization header to the "weather2" MCP server +--header 'x-mcp-weather2-authorization: Bearer your-token' + +# Send custom header to the "github" MCP server +--header 'x-mcp-github-x-api-key: your-api-key' +``` + +**Option 2: Headers in tool config** – Include a `headers` object in the tool definition. These are merged with request headers. + +```json +{ + "type": "mcp", + "server_label": "litellm", + "server_url": "litellm_proxy", + "require_approval": "never", + "headers": { + "x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY", + "x-mcp-servers": "Zapier_MCP,dev-group", + "x-mcp-weather2-authorization": "Bearer your-weather-api-token" + } +} +``` + ## Using your MCP with client side credentials Use this if you want to pass a client side authentication token to LiteLLM to then pass to your MCP to auth to your MCP. diff --git a/docs/my-website/docs/mcp_control.md b/docs/my-website/docs/mcp_control.md index 96c71ef9278..ccaa37f9497 100644 --- a/docs/my-website/docs/mcp_control.md +++ b/docs/my-website/docs/mcp_control.md @@ -323,7 +323,7 @@ curl --location '/v1/responses' \ { "type": "mcp", "server_label": "litellm", - "server_url": "/dev_group/mcp", + "server_url": "litellm_proxy", "require_approval": "never", "headers": { "x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY" @@ -335,7 +335,7 @@ curl --location '/v1/responses' \ }' ``` -This example uses URL namespacing to access all servers in the "dev_group" access group. +This example uses the `x-mcp-servers` header to access all servers in the "dev_group" access group. Use `server_url: "litellm_proxy"` when calling the proxy's `/v1/responses` endpoint—do not use the full proxy URL. @@ -423,7 +423,7 @@ curl --location '/v1/responses' \ { "type": "mcp", "server_label": "litellm", - "server_url": "/mcp/", + "server_url": "litellm_proxy", "require_approval": "never", "headers": { "x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY", @@ -436,7 +436,7 @@ curl --location '/v1/responses' \ }' ``` -This configuration restricts the request to only use tools from the specified MCP servers. +This configuration restricts the request to only use tools from the specified MCP servers. Use `server_url: "litellm_proxy"` when calling the proxy's `/v1/responses` endpoint. diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx index c48b9a755b7..1c82859e062 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx @@ -256,7 +256,7 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] { "type": "mcp", "server_label": "litellm", - "server_url": "${proxyBaseUrl}/mcp", + "server_url": "litellm_proxy", "require_approval": "never", "headers": { "x-litellm-api-key": "Bearer YOUR_LITELLM_VIRTUAL_KEY", From 39cdd3dc982331579224730adf114bd840637fa7 Mon Sep 17 00:00:00 2001 From: David Steele Date: Wed, 4 Mar 2026 10:17:20 +0000 Subject: [PATCH 035/380] test(streaming): add comprehensive parallel tool call integration test Add test_parallel_tool_calls_comprehensive_streaming_integration which synthesizes the full 10-event Responses API SSE sequence with split argument deltas and asserts all fix invariants together: 1. output_item.done emits no finish_reason (no premature stream end) 2. Each call_id appears exactly once (no duplicate tool_call chunks) 3. Split argument deltas assemble to correct final JSON 4. Exactly one finish event, at the terminal response.completed chunk 5. Parallel tool calls have distinct indices (output_index 0 and 1) All 24 unit tests pass. --- ...responses_transformation_transformation.py | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 490f7e7da62..ef3d7534d97 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -1565,3 +1565,216 @@ def test_streaming_parallel_tool_calls_have_distinct_indices(): f"Event {chunk['type']}: expected tool_call.index={expected_index}, " f"got {tc.index}" ) + + +# ============================================================================= +# Comprehensive integration test: parallel tool calls with split argument deltas +# ============================================================================= + + +def test_parallel_tool_calls_comprehensive_streaming_integration(): + """ + Comprehensive integration test for parallel tool calls via Responses API streaming. + + Regression test combining all fix invariants in a single end-to-end scenario + with split argument deltas — the exact event sequence that was broken before + the fix to output_item.done. + + Synthesized SSE event sequence: + response.created + response.output_item.added {output_index:0, type:function_call, call_id:call_1, name:read_file} + response.function_call_arguments.delta {output_index:0, delta:'{"path"'} + response.function_call_arguments.delta {output_index:0, delta:'":"/etc/foo"}'} + response.output_item.done {output_index:0, item:{type:function_call, call_id:call_1}} + response.output_item.added {output_index:1, type:function_call, call_id:call_2, name:list_dir} + response.function_call_arguments.delta {output_index:1, delta:'{"path"'} + response.function_call_arguments.delta {output_index:1, delta:'":"/tmp"}'} + response.output_item.done {output_index:1, item:{type:function_call, call_id:call_2}} + response.completed {response:{status:completed, output:[call_1, call_2]}} + + Asserts: + 1. No output_item.done chunk emits finish_reason (no premature stream termination) + 2. Each call_id appears exactly once in assembled tool_call IDs (no duplicates) + 3. Final assembled arguments are correct — split deltas concatenate to valid JSON + 4. Exactly one finish event, at the final response.completed chunk + 5. Two parallel tool calls have distinct indices (output_index 0 and 1) + """ + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + OpenAiResponsesToChatCompletionStreamIterator, + ) + + chunks = [ + # 0: response.created + {"type": "response.created", "response": {"id": "resp_001", "status": "in_progress"}}, + # 1: call_1 (read_file) added — output_index=0 + { + "type": "response.output_item.added", + "output_index": 0, + "item": {"type": "function_call", "name": "read_file", "call_id": "call_1"}, + }, + # 2: call_1 argument delta part 1 — split across two deltas + { + "type": "response.function_call_arguments.delta", + "output_index": 0, + "delta": '{"path":', + }, + # 3: call_1 argument delta part 2 + { + "type": "response.function_call_arguments.delta", + "output_index": 0, + "delta": '"/etc/foo"}', + }, + # 4: call_1 done — must NOT emit finish_reason or duplicate tool_call chunk + { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "function_call", + "name": "read_file", + "call_id": "call_1", + "arguments": '{"path":"/etc/foo"}', # full JSON, assembled from the two deltas + }, + }, + # 5: call_2 (list_dir) added — output_index=1 + { + "type": "response.output_item.added", + "output_index": 1, + "item": {"type": "function_call", "name": "list_dir", "call_id": "call_2"}, + }, + # 6: call_2 argument delta part 1 + { + "type": "response.function_call_arguments.delta", + "output_index": 1, + "delta": '{"path":', + }, + # 7: call_2 argument delta part 2 + { + "type": "response.function_call_arguments.delta", + "output_index": 1, + "delta": '"/tmp"}', + }, + # 8: call_2 done — must NOT emit finish_reason or duplicate tool_call chunk + { + "type": "response.output_item.done", + "output_index": 1, + "item": { + "type": "function_call", + "name": "list_dir", + "call_id": "call_2", + "arguments": '{"path":"/tmp"}', + }, + }, + # 9: response.completed — the ONLY terminal chunk + { + "type": "response.completed", + "response": { + "id": "resp_001", + "status": "completed", + "output": [ + { + "type": "function_call", + "name": "read_file", + "call_id": "call_1", + "arguments": '{"path":"/etc/foo"}', + }, + { + "type": "function_call", + "name": "list_dir", + "call_id": "call_2", + "arguments": '{"path":"/tmp"}', + }, + ], + }, + }, + ] + + iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True) + results = [iterator.chunk_parser(chunk) for chunk in chunks] + + # 1. output_item.done events (indices 4 and 8) must NOT emit finish_reason + for done_idx, label in [(4, "read_file done"), (8, "list_dir done")]: + r = results[done_idx] + assert r is not None, f"{label}: chunk_parser must return a result" + assert len(r.choices) > 0, f"{label}: result must have choices" + assert r.choices[0].finish_reason is None, ( + f"{label}: output_item.done must not emit finish_reason " + f"(would prematurely terminate stream before subsequent tool calls arrive)" + ) + assert not r.choices[0].delta.tool_calls, ( + f"{label}: output_item.done must not emit a duplicate tool_calls delta" + ) + + # 2. Each call_id appears exactly once in assembled tool_call IDs + # Only output_item.added emits id-bearing tool_call chunks; output_item.done emits Delta() + all_tool_call_ids = [ + tc.id + for r in results + if r is not None and r.choices and r.choices[0].delta.tool_calls + for tc in r.choices[0].delta.tool_calls + if tc.id + ] + assert all_tool_call_ids.count("call_1") == 1, ( + f"call_1 must appear exactly once in assembled tool_call IDs, " + f"got {all_tool_call_ids.count('call_1')} (duplicates indicate output_item.done still emits tool_call)" + ) + assert all_tool_call_ids.count("call_2") == 1, ( + f"call_2 must appear exactly once in assembled tool_call IDs, " + f"got {all_tool_call_ids.count('call_2')} (duplicates indicate output_item.done still emits tool_call)" + ) + + # 3. Final assembled arguments are correct when split deltas are concatenated + # output_item.added emits arguments="" (empty); the two deltas provide the content + assembled_args: dict = {} + for r in results: + if r is None or not r.choices: + continue + tool_calls = r.choices[0].delta.tool_calls + if not tool_calls: + continue + for tc in tool_calls: + if tc.function and tc.function.arguments: + idx = tc.index + assembled_args[idx] = assembled_args.get(idx, "") + tc.function.arguments + + # delta 1 = '{"path":' + delta 2 = '"/etc/foo"}' → '{"path":"/etc/foo"}' + assert assembled_args.get(0) == '{"path":"/etc/foo"}', ( + f"Assembled args for index 0 (read_file): " + f"expected '{{\"path\":\"/etc/foo\"}}', got '{assembled_args.get(0)}'" + ) + # delta 1 = '{"path":' + delta 2 = '"/tmp"}' → '{"path":"/tmp"}' + assert assembled_args.get(1) == '{"path":"/tmp"}', ( + f"Assembled args for index 1 (list_dir): " + f"expected '{{\"path\":\"/tmp\"}}', got '{assembled_args.get(1)}'" + ) + + # 4. Stream terminates with exactly one finish event, at the final response.completed chunk + finish_events = [ + (i, r.choices[0].finish_reason) + for i, r in enumerate(results) + if r is not None and r.choices and r.choices[0].finish_reason + ] + assert len(finish_events) == 1, ( + f"Expected exactly 1 finish event, got {len(finish_events)}: {finish_events}" + ) + assert finish_events[0][0] == len(chunks) - 1, ( + f"Finish event must be at the last chunk (index {len(chunks) - 1}), " + f"but was at index {finish_events[0][0]}" + ) + assert finish_events[0][1] == "tool_calls", ( + f"Terminal finish_reason must be 'tool_calls', got '{finish_events[0][1]}'" + ) + + # 5. Parallel tool calls have distinct indices matching output_index (0 and 1) + # Collect indices from output_item.added chunks only (they carry the call id) + added_tool_call_indices = [ + tc.index + for r in results + if r is not None and r.choices and r.choices[0].delta.tool_calls + for tc in r.choices[0].delta.tool_calls + if tc.id # output_item.added chunks carry the id; argument deltas do not + ] + assert set(added_tool_call_indices) == {0, 1}, ( + f"Parallel tool calls must have distinct indices {{0, 1}}, got: {set(added_tool_call_indices)}" + ) + + print("✓ Parallel tool calls with split argument deltas stream correctly end-to-end") From 32b387468470b65561798e8f385eea7106aab051 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Mar 2026 16:24:09 +0200 Subject: [PATCH 036/380] fix: update Okta SSO docs and custom SSO handler example 1. Okta SSO docs (admin_ui_sso.md): - Rewrite Step 3 to document both Org Auth Server (free) and Custom Auth Server (paid SKU) as tabbed options - Add Step 4 for GENERIC_CLIENT_STATE and PKCE configuration (moved from troubleshooting into the main guide) - Clarify no_matching_policy error only applies to Custom Auth Server - Deduplicate troubleshooting section to reference Step 4 2. Custom SSO handler (custom_sso.py + custom_sso.md): - Replace broken user_info() call with prisma_client.get_data() - user_info() is a FastAPI route handler requiring Request and UserAPIKeyAuth params, cannot be called directly - Keep new_user/add_new_member as commented-out import references in docs for customers who need them --- docs/my-website/docs/proxy/admin_ui_sso.md | 72 +++++++++++++--------- docs/my-website/docs/proxy/custom_sso.md | 16 ++--- litellm/proxy/custom_sso.py | 7 ++- 3 files changed, 54 insertions(+), 41 deletions(-) diff --git a/docs/my-website/docs/proxy/admin_ui_sso.md b/docs/my-website/docs/proxy/admin_ui_sso.md index f88d3480446..2bd4cf24b49 100644 --- a/docs/my-website/docs/proxy/admin_ui_sso.md +++ b/docs/my-website/docs/proxy/admin_ui_sso.md @@ -41,12 +41,38 @@ After creating the app, copy your **Client ID** and **Client Secret** from the a Ensure users are assigned to the app in the **Assignments** tab. If Federation Broker Mode is enabled, you may need to disable it to assign users manually. -#### Step 3: Configure Authorization Server Access Policy +#### Step 3: Set Environment Variables -:::warning Important -This step is required. Without an Access Policy for your app, users will get a `no_matching_policy` error when attempting to log in. +Set the following environment variables. The only difference between the two Okta authorization servers is the endpoint URLs: + +**Org Authorization Server** (available on all Okta plans, no additional SKU required): +```bash +GENERIC_CLIENT_ID="" +GENERIC_CLIENT_SECRET="" +GENERIC_AUTHORIZATION_ENDPOINT="https:///oauth2/v1/authorize" +GENERIC_TOKEN_ENDPOINT="https:///oauth2/v1/token" +GENERIC_USERINFO_ENDPOINT="https:///oauth2/v1/userinfo" +PROXY_BASE_URL="https://" +``` + +**Custom Authorization Server** (requires the Okta API Access Management SKU): +```bash +GENERIC_CLIENT_ID="" +GENERIC_CLIENT_SECRET="" +GENERIC_AUTHORIZATION_ENDPOINT="https:///oauth2/default/v1/authorize" +GENERIC_TOKEN_ENDPOINT="https:///oauth2/default/v1/token" +GENERIC_USERINFO_ENDPOINT="https:///oauth2/default/v1/userinfo" +PROXY_BASE_URL="https://" +``` + +:::tip +You can find all OAuth endpoints at `https:///.well-known/openid-configuration` ::: +#### Step 3a: Configure Access Policy (Custom Authorization Server only) + +If you are using the Custom Authorization Server, you must configure an Access Policy. Without it, users will get a `no_matching_policy` error. Skip this step if you are using the Org Authorization Server. + 1. Go to **Security** → **API** @@ -62,21 +88,21 @@ This step is required. Without an Access Policy for your app, users will get a ` See [Okta's Access Policy documentation](https://help.okta.com/en-us/content/topics/security/api-access-management/access-policies.htm) for more details. -#### Step 4: Configure LiteLLM Environment Variables +#### Step 4: Configure Okta Security Settings + +**GENERIC_CLIENT_STATE** is recommended for Okta to prevent CSRF attacks: ```bash -GENERIC_CLIENT_ID="" -GENERIC_CLIENT_SECRET="" -GENERIC_AUTHORIZATION_ENDPOINT="https:///oauth2/default/v1/authorize" -GENERIC_TOKEN_ENDPOINT="https:///oauth2/default/v1/token" -GENERIC_USERINFO_ENDPOINT="https:///oauth2/default/v1/userinfo" GENERIC_CLIENT_STATE="random-string" -PROXY_BASE_URL="https://" ``` -:::tip -You can find all OAuth endpoints at `https:///.well-known/openid-configuration` -::: +**PKCE (Proof Key for Code Exchange)** — If your Okta application is configured to require PKCE, enable it by setting: + +```bash +GENERIC_CLIENT_USE_PKCE="true" +``` + +LiteLLM will automatically handle PKCE parameter generation and verification during the OAuth flow. #### Step 5: Test the SSO Flow @@ -91,7 +117,7 @@ You can find all OAuth endpoints at `https:///.well-known/open |-------|-------|----------| | `redirect_uri` error | Redirect URI not configured | Add `/sso/callback` to Sign-in redirect URIs in Okta | | `access_denied` | User not assigned to app | Assign the user in the Assignments tab | -| `no_matching_policy` | Missing Access Policy | Create an Access Policy in the Authorization Server (see Step 3) | +| `no_matching_policy` | Missing Access Policy (Custom Authorization Server only) | Create an Access Policy in the Authorization Server (see Step 3a) | @@ -456,23 +482,9 @@ PROXY_BASE_URL=http://litellm.platform.com PROXY_BASE_URL=litellm.platform.com ``` -**2. For Okta specifically, ensure GENERIC_CLIENT_STATE is set** +**2. For Okta specifically, ensure `GENERIC_CLIENT_STATE` is set and PKCE is configured if required** -Okta requires the `GENERIC_CLIENT_STATE` parameter: - -```bash -GENERIC_CLIENT_STATE="random-string" # Required for Okta -``` - -### Okta PKCE - -If your Okta application is configured to require PKCE (Proof Key for Code Exchange), enable it by setting: - -```bash -GENERIC_CLIENT_USE_PKCE="true" -``` - -This is required when your Okta app settings enforce PKCE for enhanced security. LiteLLM will automatically handle PKCE parameter generation and verification during the OAuth flow. +See [Okta SSO — Step 4: Configure Okta Security Settings](#step-4-configure-okta-security-settings) for details on `GENERIC_CLIENT_STATE` and PKCE configuration. ### Common Configuration Issues diff --git a/docs/my-website/docs/proxy/custom_sso.md b/docs/my-website/docs/proxy/custom_sso.md index 8b7adeb0c5a..41ecde6e369 100644 --- a/docs/my-website/docs/proxy/custom_sso.md +++ b/docs/my-website/docs/proxy/custom_sso.md @@ -121,15 +121,14 @@ Use this if you want to run your own code **after** a user signs on to the LiteL Make sure the response type follows the `SSOUserDefinedValues` pydantic object. This is used for logging the user into the Admin UI: ```python -from fastapi import Request from fastapi_sso.sso.base import OpenID from litellm.proxy._types import LitellmUserRoles, SSOUserDefinedValues -from litellm.proxy.management_endpoints.internal_user_endpoints import ( - new_user, - user_info, -) -from litellm.proxy.management_endpoints.team_endpoints import add_new_member +from litellm.proxy import proxy_server + +# These imports are available if you need to create users or manage team membership: +# from litellm.proxy.management_endpoints.internal_user_endpoints import new_user +# from litellm.proxy.management_endpoints.team_endpoints import add_new_member async def custom_sso_handler(userIDPInfo: OpenID) -> SSOUserDefinedValues: @@ -158,8 +157,9 @@ async def custom_sso_handler(userIDPInfo: OpenID) -> SSOUserDefinedValues: ################################################# # Run your custom code / logic here # check if user exists in litellm proxy DB - _user_info = await user_info(user_id=userIDPInfo.id) - print("_user_info from litellm DB ", _user_info) # noqa + if proxy_server.prisma_client is not None: + _user_info = await proxy_server.prisma_client.get_data(user_id=userIDPInfo.id) + print("_user_info from litellm DB ", _user_info) # noqa ################################################# return SSOUserDefinedValues( diff --git a/litellm/proxy/custom_sso.py b/litellm/proxy/custom_sso.py index b2b028dfbe3..1419b5551c8 100644 --- a/litellm/proxy/custom_sso.py +++ b/litellm/proxy/custom_sso.py @@ -15,7 +15,7 @@ Flow: from fastapi_sso.sso.base import OpenID from litellm.proxy._types import LitellmUserRoles, SSOUserDefinedValues -from litellm.proxy.management_endpoints.internal_user_endpoints import user_info +from litellm.proxy import proxy_server async def custom_sso_handler(userIDPInfo: OpenID) -> SSOUserDefinedValues: @@ -32,8 +32,9 @@ async def custom_sso_handler(userIDPInfo: OpenID) -> SSOUserDefinedValues: # user_groups = extra_fields.get("group", []) # check if user exists in litellm proxy DB - _user_info = await user_info(user_id=userIDPInfo.id) - print("_user_info from litellm DB ", _user_info) # noqa + if proxy_server.prisma_client is not None: + _user_info = await proxy_server.prisma_client.get_data(user_id=userIDPInfo.id) + print("_user_info from litellm DB ", _user_info) # noqa return SSOUserDefinedValues( models=[], From fb8bd60c7d3cb7eaa7f497d69d8cc0e3fc4854b2 Mon Sep 17 00:00:00 2001 From: giulio-leone Date: Wed, 4 Mar 2026 06:00:25 +0100 Subject: [PATCH 037/380] fix(streaming): prevent Vertex AI Claude content truncation when finish_reason races content --- .../litellm_core_utils/streaming_handler.py | 9 +- .../test_streaming_handler.py | 91 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 1f17a3da4bb..317f1037686 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -1099,7 +1099,14 @@ class CustomStreamWrapper: and self.custom_llm_provider in litellm._custom_providers ): if self.received_finish_reason is not None: - if "provider_specific_fields" not in chunk: + _chunk_has_content = isinstance(chunk, dict) and ( + bool(chunk.get("text", "")) + or chunk.get("tool_use") is not None + ) + if not _chunk_has_content and ( + not isinstance(chunk, dict) + or "provider_specific_fields" not in chunk + ): raise StopIteration anthropic_response_obj: GChunk = cast(GChunk, chunk) completion_obj["content"] = anthropic_response_obj["text"] diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py index 76d24b7c190..6a64e7020b9 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py @@ -1400,3 +1400,94 @@ async def test_custom_stream_wrapper_aclose_none_stream(): # Should not raise await wrapper.aclose() + + +def test_content_not_dropped_when_finish_reason_already_set( + initialized_custom_stream_wrapper: CustomStreamWrapper, +): + """ + Regression test for #22098: Vertex AI Claude streaming truncation. + + When content_block_delta and message_delta arrive in rapid succession, + received_finish_reason can be set BEFORE the last content chunk is + processed. The old code raised StopIteration unconditionally, dropping + content. The fix checks for text/tool_use content before stopping. + """ + initialized_custom_stream_wrapper.received_finish_reason = "stop" + initialized_custom_stream_wrapper.custom_llm_provider = "anthropic" + + content_chunk = { + "text": "world!", + "tool_use": None, + "is_finished": False, + "finish_reason": "", + "usage": None, + "index": 0, + } + + result = initialized_custom_stream_wrapper.chunk_creator(chunk=content_chunk) + + assert result is not None, ( + "chunk_creator() returned None — content was dropped (issue #22098)" + ) + assert result.choices[0].delta.content == "world!" + + +def test_empty_chunk_still_stops_after_finish_reason_set( + initialized_custom_stream_wrapper: CustomStreamWrapper, +): + """ + Companion test for #22098: an empty GenericStreamingChunk must still + raise StopIteration when received_finish_reason is already set. + """ + initialized_custom_stream_wrapper.received_finish_reason = "stop" + initialized_custom_stream_wrapper.custom_llm_provider = "anthropic" + + empty_chunk = { + "text": "", + "tool_use": None, + "is_finished": False, + "finish_reason": "", + "usage": None, + "index": 0, + } + + with pytest.raises(StopIteration): + initialized_custom_stream_wrapper.chunk_creator(chunk=empty_chunk) + + +def test_tool_use_not_dropped_when_finish_reason_already_set( + initialized_custom_stream_wrapper: CustomStreamWrapper, +): + """ + Regression test for #22098: tool_use-only chunks must not be dropped + when received_finish_reason is already set. + """ + initialized_custom_stream_wrapper.received_finish_reason = "stop" + initialized_custom_stream_wrapper.custom_llm_provider = "anthropic" + + tool_chunk = { + "text": "", + "tool_use": { + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": "{}"}, + }, + "is_finished": False, + "finish_reason": "", + "usage": None, + "index": 0, + } + + result = initialized_custom_stream_wrapper.chunk_creator(chunk=tool_chunk) + + assert result is not None, ( + "chunk_creator() returned None — tool_use data was dropped" + ) + + tool_calls = result.choices[0].delta.tool_calls + assert tool_calls is not None and len(tool_calls) > 0, ( + "tool_calls should contain at least one tool call" + ) + assert tool_calls[0].id == "call_1" + assert tool_calls[0].function.name == "get_weather" From 12691dcce35f4896e5fa8d44e9e534cddfb094a6 Mon Sep 17 00:00:00 2001 From: giulio-leone Date: Wed, 4 Mar 2026 06:24:41 +0100 Subject: [PATCH 038/380] fix: WebSearch interception fails with thinking enabled + SpendLimit constraint --- .../websearch_interception/handler.py | 79 +++- litellm/llms/custom_httpx/llm_http_handler.py | 5 +- .../test_websearch_thinking_constraint.py | 439 ++++++++++++++++++ 3 files changed, 509 insertions(+), 14 deletions(-) create mode 100644 tests/test_litellm/integrations/websearch_interception/test_websearch_thinking_constraint.py diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index bef8925e8e9..35275b574dd 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -7,6 +7,7 @@ server-side using litellm router's search tools. """ import asyncio +import math from typing import Any, Dict, List, Optional, Tuple, Union, cast import litellm @@ -481,6 +482,56 @@ class WebSearchInterceptionLogger(CustomLogger): response_format=response_format, ) + @staticmethod + def _resolve_max_tokens( + optional_params: Dict, + kwargs: Dict, + ) -> int: + """Extract max_tokens and validate against thinking.budget_tokens. + + Anthropic API requires ``max_tokens > thinking.budget_tokens``. + If the constraint is violated, auto-adjust to ``budget_tokens + 1024``. + """ + max_tokens: int = optional_params.get( + "max_tokens", + kwargs.get("max_tokens", 1024), + ) + thinking_param = optional_params.get("thinking") + if thinking_param and isinstance(thinking_param, dict): + budget_tokens = thinking_param.get("budget_tokens") + if ( + budget_tokens is not None + and isinstance(budget_tokens, (int, float)) + and math.isfinite(budget_tokens) + and budget_tokens > 0 + ): + if max_tokens <= budget_tokens: + adjusted = math.ceil(budget_tokens) + 1024 + verbose_logger.warning( + "WebSearchInterception: max_tokens=%s <= thinking.budget_tokens=%s, " + "adjusting to %s to satisfy Anthropic API constraint", + max_tokens, budget_tokens, adjusted, + ) + max_tokens = adjusted + return max_tokens + + @staticmethod + def _prepare_followup_kwargs(kwargs: Dict) -> Dict: + """Build kwargs for the follow-up call, excluding internal keys. + + ``litellm_logging_obj`` MUST be excluded so the follow-up call creates + its own ``Logging`` instance via ``function_setup``. Reusing the + initial call's logging object triggers the dedup flag + (``has_logged_async_success``) which silently prevents the initial + call's spend from being recorded — the root cause of the + SpendLog / AWS billing mismatch. + """ + _internal_keys = {'litellm_logging_obj'} + return { + k: v for k, v in kwargs.items() + if not k.startswith('_websearch_interception') and k not in _internal_keys + } + async def _execute_agentic_loop( self, model: str, @@ -557,13 +608,18 @@ class WebSearchInterceptionLogger(CustomLogger): f"WebSearchInterception: Last message (tool_result): {user_message}" ) + # Correlation context for structured logging + _call_id = ( + getattr(logging_obj, "litellm_call_id", None) + or kwargs.get("litellm_call_id", "unknown") + ) + + full_model_name = model # safe default before try block + # Use anthropic_messages.acreate for follow-up request try: - # Extract max_tokens from optional params or kwargs - # max_tokens is a required parameter for anthropic_messages.acreate() - max_tokens = anthropic_messages_optional_request_params.get( - "max_tokens", - kwargs.get("max_tokens", 1024) # Default to 1024 if not found + max_tokens = self._resolve_max_tokens( + anthropic_messages_optional_request_params, kwargs ) verbose_logger.debug( @@ -576,16 +632,10 @@ class WebSearchInterceptionLogger(CustomLogger): if k != 'max_tokens' } - # Remove internal websearch interception flags from kwargs before follow-up request - # These flags are used internally and should not be passed to the LLM provider - kwargs_for_followup = { - k: v for k, v in kwargs.items() - if not k.startswith('_websearch_interception') - } + kwargs_for_followup = self._prepare_followup_kwargs(kwargs) # Get model from logging_obj.model_call_details["agentic_loop_params"] # This preserves the full model name with provider prefix (e.g., "bedrock/invoke/...") - full_model_name = model if logging_obj is not None: agentic_params = logging_obj.model_call_details.get("agentic_loop_params", {}) full_model_name = agentic_params.get("model", model) @@ -609,7 +659,10 @@ class WebSearchInterceptionLogger(CustomLogger): return final_response except Exception as e: verbose_logger.exception( - f"WebSearchInterception: Follow-up request failed: {str(e)}" + "WebSearchInterception: Follow-up request failed " + "[call_id=%s model=%s messages=%d searches=%d]: %s", + _call_id, full_model_name, len(follow_up_messages), + len(final_search_results), str(e), ) raise diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index b6fcf853ab5..1cef3e9ce15 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -4454,8 +4454,11 @@ class BaseLLMHTTPHandler: return agentic_response except Exception as e: + _call_id = getattr(logging_obj, "litellm_call_id", "unknown") verbose_logger.exception( - f"LiteLLM.AgenticHookError: Exception in agentic completion hooks: {str(e)}" + "LiteLLM.AgenticHookError: Exception in agentic completion hooks " + "[call_id=%s model=%s]: %s", + _call_id, model, str(e), ) # Check if we need to convert response to fake stream diff --git a/tests/test_litellm/integrations/websearch_interception/test_websearch_thinking_constraint.py b/tests/test_litellm/integrations/websearch_interception/test_websearch_thinking_constraint.py new file mode 100644 index 00000000000..476f38f5a2d --- /dev/null +++ b/tests/test_litellm/integrations/websearch_interception/test_websearch_thinking_constraint.py @@ -0,0 +1,439 @@ +""" +Tests for max_tokens vs thinking.budget_tokens constraint validation +in the websearch interception agentic loop. + +Covers: + - M1-I1: max_tokens auto-adjustment when <= thinking.budget_tokens + - M1-I3: Unit tests for thinking parameter validation + - M2-I5/I8: litellm_logging_obj excluded from follow-up kwargs to prevent SpendLog dedup + - M3-I12: Regression tests for error scenarios +""" + +from typing import Any, Dict, List +from unittest.mock import MagicMock, patch + +import pytest + +from litellm.integrations.websearch_interception.handler import ( + WebSearchInterceptionLogger, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_tool_calls() -> List[Dict]: + return [ + { + "id": "toolu_01", + "type": "tool_use", + "name": "web_search", + "input": {"query": "litellm spend tracking"}, + } + ] + + +def _make_logging_obj(model: str = "bedrock/us.anthropic.claude-opus-4-6-v1") -> MagicMock: + obj = MagicMock() + obj.model_call_details = { + "agentic_loop_params": {"model": model, "custom_llm_provider": "bedrock"}, + } + return obj + + +# --------------------------------------------------------------------------- +# M1-I1 / M1-I3: max_tokens validation against thinking.budget_tokens +# --------------------------------------------------------------------------- + +class TestThinkingBudgetTokensConstraint: + """Validate that _execute_agentic_loop adjusts max_tokens when <= thinking.budget_tokens.""" + + @pytest.mark.asyncio + async def test_max_tokens_adjusted_when_less_than_budget(self): + """max_tokens < thinking.budget_tokens → auto-adjusted to budget_tokens + 1024.""" + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"]) + captured_kwargs: Dict[str, Any] = {} + + async def _fake_acreate(**kw): + captured_kwargs.update(kw) + return MagicMock() # dummy response + + with patch( + "litellm.integrations.websearch_interception.handler.anthropic_messages.acreate", + side_effect=_fake_acreate, + ), patch.object(logger, "_execute_search", return_value="search result"): + + await logger._execute_agentic_loop( + model="us.anthropic.claude-opus-4-6-v1", + messages=[{"role": "user", "content": "hi"}], + tool_calls=_make_tool_calls(), + thinking_blocks=[], + anthropic_messages_optional_request_params={ + "max_tokens": 1024, + "thinking": {"type": "enabled", "budget_tokens": 5000}, + }, + logging_obj=_make_logging_obj(), + stream=False, + kwargs={}, + ) + + assert captured_kwargs["max_tokens"] == 5000 + 1024 + + @pytest.mark.asyncio + async def test_max_tokens_adjusted_when_equal_to_budget(self): + """max_tokens == thinking.budget_tokens → still adjusted (must be strictly greater).""" + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"]) + captured_kwargs: Dict[str, Any] = {} + + async def _fake_acreate(**kw): + captured_kwargs.update(kw) + return MagicMock() + + with patch( + "litellm.integrations.websearch_interception.handler.anthropic_messages.acreate", + side_effect=_fake_acreate, + ), patch.object(logger, "_execute_search", return_value="search result"): + + await logger._execute_agentic_loop( + model="us.anthropic.claude-opus-4-6-v1", + messages=[{"role": "user", "content": "hi"}], + tool_calls=_make_tool_calls(), + thinking_blocks=[], + anthropic_messages_optional_request_params={ + "max_tokens": 5000, + "thinking": {"type": "enabled", "budget_tokens": 5000}, + }, + logging_obj=_make_logging_obj(), + stream=False, + kwargs={}, + ) + + assert captured_kwargs["max_tokens"] == 5000 + 1024 + + @pytest.mark.asyncio + async def test_max_tokens_unchanged_when_greater_than_budget(self): + """max_tokens > thinking.budget_tokens → no adjustment needed.""" + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"]) + captured_kwargs: Dict[str, Any] = {} + + async def _fake_acreate(**kw): + captured_kwargs.update(kw) + return MagicMock() + + with patch( + "litellm.integrations.websearch_interception.handler.anthropic_messages.acreate", + side_effect=_fake_acreate, + ), patch.object(logger, "_execute_search", return_value="search result"): + + await logger._execute_agentic_loop( + model="us.anthropic.claude-opus-4-6-v1", + messages=[{"role": "user", "content": "hi"}], + tool_calls=_make_tool_calls(), + thinking_blocks=[], + anthropic_messages_optional_request_params={ + "max_tokens": 10000, + "thinking": {"type": "enabled", "budget_tokens": 5000}, + }, + logging_obj=_make_logging_obj(), + stream=False, + kwargs={}, + ) + + assert captured_kwargs["max_tokens"] == 10000 + + @pytest.mark.asyncio + async def test_no_thinking_param_no_adjustment(self): + """No thinking parameter → max_tokens used as-is (default 1024).""" + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"]) + captured_kwargs: Dict[str, Any] = {} + + async def _fake_acreate(**kw): + captured_kwargs.update(kw) + return MagicMock() + + with patch( + "litellm.integrations.websearch_interception.handler.anthropic_messages.acreate", + side_effect=_fake_acreate, + ), patch.object(logger, "_execute_search", return_value="search result"): + + await logger._execute_agentic_loop( + model="us.anthropic.claude-opus-4-6-v1", + messages=[{"role": "user", "content": "hi"}], + tool_calls=_make_tool_calls(), + thinking_blocks=[], + anthropic_messages_optional_request_params={}, + logging_obj=_make_logging_obj(), + stream=False, + kwargs={}, + ) + + assert captured_kwargs["max_tokens"] == 1024 + + @pytest.mark.asyncio + async def test_thinking_without_budget_tokens_no_adjustment(self): + """thinking param exists but has no budget_tokens → max_tokens used as-is.""" + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"]) + captured_kwargs: Dict[str, Any] = {} + + async def _fake_acreate(**kw): + captured_kwargs.update(kw) + return MagicMock() + + with patch( + "litellm.integrations.websearch_interception.handler.anthropic_messages.acreate", + side_effect=_fake_acreate, + ), patch.object(logger, "_execute_search", return_value="search result"): + + await logger._execute_agentic_loop( + model="us.anthropic.claude-opus-4-6-v1", + messages=[{"role": "user", "content": "hi"}], + tool_calls=_make_tool_calls(), + thinking_blocks=[], + anthropic_messages_optional_request_params={ + "max_tokens": 2048, + "thinking": {"type": "enabled"}, + }, + logging_obj=_make_logging_obj(), + stream=False, + kwargs={}, + ) + + assert captured_kwargs["max_tokens"] == 2048 + + +class TestResolveMaxTokensEdgeCases: + """Edge cases for _resolve_max_tokens: infinity, negative, extreme values.""" + + def test_infinity_budget_tokens_no_crash(self): + """float('inf') budget_tokens must not crash with OverflowError.""" + result = WebSearchInterceptionLogger._resolve_max_tokens( + {"max_tokens": 1024, "thinking": {"budget_tokens": float("inf")}}, {} + ) + assert result == 1024 # no adjustment for non-finite values + + def test_negative_infinity_no_crash(self): + result = WebSearchInterceptionLogger._resolve_max_tokens( + {"max_tokens": 1024, "thinking": {"budget_tokens": float("-inf")}}, {} + ) + assert result == 1024 + + def test_nan_budget_tokens_no_crash(self): + result = WebSearchInterceptionLogger._resolve_max_tokens( + {"max_tokens": 1024, "thinking": {"budget_tokens": float("nan")}}, {} + ) + assert result == 1024 + + def test_negative_budget_tokens_no_adjustment(self): + result = WebSearchInterceptionLogger._resolve_max_tokens( + {"max_tokens": 1024, "thinking": {"budget_tokens": -100}}, {} + ) + assert result == 1024 + + def test_zero_budget_tokens_no_adjustment(self): + result = WebSearchInterceptionLogger._resolve_max_tokens( + {"max_tokens": 1024, "thinking": {"budget_tokens": 0}}, {} + ) + assert result == 1024 + + +# --------------------------------------------------------------------------- +# M2-I5 / M2-I8: litellm_logging_obj excluded from follow-up kwargs +# --------------------------------------------------------------------------- + +class TestLoggingObjExcludedFromFollowUp: + """Verify litellm_logging_obj is NOT forwarded to the follow-up acreate() call. + + Passing the same logging object to both initial and follow-up calls causes + the has_logged_async_success dedup flag to fire, silently preventing the + initial call's spend from being recorded in SpendLogs. + """ + + @pytest.mark.asyncio + async def test_litellm_logging_obj_excluded_from_anthropic_followup(self): + """The Anthropic messages follow-up must NOT receive litellm_logging_obj.""" + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"]) + captured_kwargs: Dict[str, Any] = {} + + async def _fake_acreate(**kw): + captured_kwargs.update(kw) + return MagicMock() + + fake_logging_obj = _make_logging_obj() + + with patch( + "litellm.integrations.websearch_interception.handler.anthropic_messages.acreate", + side_effect=_fake_acreate, + ), patch.object(logger, "_execute_search", return_value="search result"): + + await logger._execute_agentic_loop( + model="us.anthropic.claude-opus-4-6-v1", + messages=[{"role": "user", "content": "hi"}], + tool_calls=_make_tool_calls(), + thinking_blocks=[], + anthropic_messages_optional_request_params={"max_tokens": 4096}, + logging_obj=fake_logging_obj, + stream=False, + kwargs={ + "litellm_logging_obj": fake_logging_obj, + "metadata": {"user_api_key": "test-key-hash"}, + "temperature": 0.5, + }, + ) + + # litellm_logging_obj must be absent from the follow-up call + assert "litellm_logging_obj" not in captured_kwargs + # But other kwargs (metadata, temperature) must be preserved + assert captured_kwargs.get("metadata") == {"user_api_key": "test-key-hash"} + assert captured_kwargs.get("temperature") == 0.5 + + @pytest.mark.asyncio + async def test_websearch_flags_also_excluded(self): + """Both _websearch_interception flags and litellm_logging_obj must be excluded.""" + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"]) + captured_kwargs: Dict[str, Any] = {} + + async def _fake_acreate(**kw): + captured_kwargs.update(kw) + return MagicMock() + + with patch( + "litellm.integrations.websearch_interception.handler.anthropic_messages.acreate", + side_effect=_fake_acreate, + ), patch.object(logger, "_execute_search", return_value="search result"): + + await logger._execute_agentic_loop( + model="us.anthropic.claude-opus-4-6-v1", + messages=[{"role": "user", "content": "hi"}], + tool_calls=_make_tool_calls(), + thinking_blocks=[], + anthropic_messages_optional_request_params={"max_tokens": 4096}, + logging_obj=_make_logging_obj(), + stream=False, + kwargs={ + "litellm_logging_obj": MagicMock(), + "_websearch_interception_converted_stream": True, + "_websearch_interception_other": "x", + "api_key": "fake", + }, + ) + + assert "litellm_logging_obj" not in captured_kwargs + assert "_websearch_interception_converted_stream" not in captured_kwargs + assert "_websearch_interception_other" not in captured_kwargs + assert captured_kwargs.get("api_key") == "fake" + + +# --------------------------------------------------------------------------- +# M3-I12: Regression tests for error scenarios +# --------------------------------------------------------------------------- + +class TestFollowUpErrorScenarios: + """Regression tests: the agentic loop must surface errors properly and + not silently swallow them (except at the _call_agentic_completion_hooks + level which intentionally catches to return the initial response).""" + + @pytest.mark.asyncio + async def test_followup_400_raises(self): + """A 400 error from the follow-up call must propagate out of _execute_agentic_loop.""" + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"]) + + async def _fail_acreate(**kw): + raise Exception("max_tokens must be greater than thinking.budget_tokens") + + with patch( + "litellm.integrations.websearch_interception.handler.anthropic_messages.acreate", + side_effect=_fail_acreate, + ), patch.object(logger, "_execute_search", return_value="search result"): + + with pytest.raises(Exception, match="max_tokens must be greater"): + await logger._execute_agentic_loop( + model="us.anthropic.claude-opus-4-6-v1", + messages=[{"role": "user", "content": "hi"}], + tool_calls=_make_tool_calls(), + thinking_blocks=[], + anthropic_messages_optional_request_params={"max_tokens": 4096}, + logging_obj=_make_logging_obj(), + stream=False, + kwargs={}, + ) + + @pytest.mark.asyncio + async def test_search_failure_does_not_crash_loop(self): + """If a search fails, the loop should still attempt the follow-up with error text.""" + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"]) + captured_kwargs: Dict[str, Any] = {} + + async def _fake_acreate(**kw): + captured_kwargs.update(kw) + return MagicMock() + + with patch( + "litellm.integrations.websearch_interception.handler.anthropic_messages.acreate", + side_effect=_fake_acreate, + ), patch.object( + logger, "_execute_search", side_effect=Exception("search API down") + ): + + result = await logger._execute_agentic_loop( + model="us.anthropic.claude-opus-4-6-v1", + messages=[{"role": "user", "content": "hi"}], + tool_calls=_make_tool_calls(), + thinking_blocks=[], + anthropic_messages_optional_request_params={"max_tokens": 4096}, + logging_obj=_make_logging_obj(), + stream=False, + kwargs={}, + ) + + # The follow-up call should have been made (with error text in search results) + assert result is not None + # Messages should contain the error text + follow_up_messages = captured_kwargs.get("messages", []) + assert len(follow_up_messages) > 1 # original + assistant + tool_result + + @pytest.mark.asyncio + async def test_metadata_preserved_after_logging_obj_exclusion(self): + """Proxy metadata (user_api_key, team_id, etc.) must survive in follow-up kwargs + even after litellm_logging_obj is excluded — so the new logging_obj from + function_setup has access to proxy tracking metadata.""" + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"]) + captured_kwargs: Dict[str, Any] = {} + + async def _fake_acreate(**kw): + captured_kwargs.update(kw) + return MagicMock() + + proxy_metadata = { + "user_api_key": "test-proxy-key-hash", + "user_api_key_user_id": "user-123", + "user_api_key_team_id": "team-456", + "user_api_key_org_id": "org-789", + "user_api_key_end_user_id": "end-user-001", + } + + with patch( + "litellm.integrations.websearch_interception.handler.anthropic_messages.acreate", + side_effect=_fake_acreate, + ), patch.object(logger, "_execute_search", return_value="search result"): + + await logger._execute_agentic_loop( + model="us.anthropic.claude-opus-4-6-v1", + messages=[{"role": "user", "content": "hi"}], + tool_calls=_make_tool_calls(), + thinking_blocks=[], + anthropic_messages_optional_request_params={"max_tokens": 4096}, + logging_obj=_make_logging_obj(), + stream=False, + kwargs={ + "litellm_logging_obj": MagicMock(), + "metadata": proxy_metadata, + "litellm_call_id": "call-abc-123", + }, + ) + + # litellm_logging_obj excluded + assert "litellm_logging_obj" not in captured_kwargs + # But ALL proxy metadata must be preserved + assert captured_kwargs.get("metadata") == proxy_metadata + assert captured_kwargs.get("litellm_call_id") == "call-abc-123" From 4d97818f98ec64c3c6075819c740661c996ba7c9 Mon Sep 17 00:00:00 2001 From: giulio-leone Date: Wed, 4 Mar 2026 06:01:42 +0100 Subject: [PATCH 039/380] fix(tools): gracefully repair truncated JSON in tool call arguments --- .../prompt_templates/common_utils.py | 95 ++++++++++-- .../prompt_templates/factory.py | 10 +- tests/llm_translation/test_prompt_factory.py | 144 ++++++++++++++++++ 3 files changed, 237 insertions(+), 12 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 125f2585a33..d59b8d88714 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -20,6 +20,7 @@ from typing import ( cast, ) +from litellm import verbose_logger from litellm.router_utils.batch_utils import InMemoryFile from litellm.types.llms.openai import ( AllMessageValues, @@ -1278,16 +1279,76 @@ def extract_images_from_message(message: AllMessageValues) -> List[str]: return images +def _attempt_json_repair(s: str) -> Optional[Any]: + """ + Attempt to repair truncated JSON produced by LLM tool calls. + + Handles the most common truncation patterns where the model generates + valid JSON that is cut short (missing closing brackets/braces). + + Returns the parsed value on success, or None if repair fails. + """ + import json + + stripped = s.rstrip() + if not stripped: + return None + + # Track the stack of unmatched openers to respect nesting order + opener_stack: list = [] + in_string = False + escape_next = False + + for ch in stripped: + if escape_next: + escape_next = False + continue + if ch == "\\": + if in_string: + escape_next = True + continue + if ch == '"': + in_string = not in_string + continue + if in_string: + continue + if ch == "{": + opener_stack.append("}") + elif ch == "[": + opener_stack.append("]") + elif ch in ("}", "]"): + if opener_stack and opener_stack[-1] == ch: + opener_stack.pop() + + if not opener_stack: + return None + + # Remove trailing comma before we close brackets + candidate = stripped.rstrip(",") + + # Close in reverse order of opening (respects nesting) + candidate += "".join(reversed(opener_stack)) + + try: + return json.loads(candidate) + except json.JSONDecodeError: + pass + + return None + + def parse_tool_call_arguments( arguments: Optional[str], tool_name: Optional[str] = None, context: Optional[str] = None, -) -> Dict[str, Any]: +) -> Any: """ Parse tool call arguments from a JSON string. - This function handles malformed JSON gracefully by raising a ValueError - with context about what failed and what the problematic input was. + When the JSON is malformed (e.g. truncated by the model), this function + attempts a lightweight repair (closing unmatched brackets/braces) before + raising an error. A warning is logged whenever repair succeeds so that + callers are aware the arguments were not perfectly formed. Args: arguments: The JSON string containing tool arguments, or None. @@ -1295,19 +1356,34 @@ def parse_tool_call_arguments( context: Optional context string (e.g., "Anthropic Messages API"). Returns: - Parsed arguments as a dictionary. Returns empty dict if arguments is None or empty. + Parsed arguments (usually a dict, but may be any JSON-deserializable + type such as list, str, int, float, or None). Returns empty dict if + arguments is None or empty. Raises: - ValueError: If the arguments string is not valid JSON. + ValueError: If the arguments string is not valid JSON and cannot be repaired. """ import json - if not arguments: + if not arguments or not arguments.strip(): return {} try: return json.loads(arguments) - except json.JSONDecodeError as e: + except json.JSONDecodeError as original_error: + repaired = _attempt_json_repair(arguments) + if repaired is not None: + verbose_logger.warning( + "Repaired truncated tool call arguments for tool '%s' (%s). " + "Original (%d chars): %.200s%s", + tool_name or "", + context or "unknown context", + len(arguments), + arguments, + "..." if len(arguments) > 200 else "", + ) + return repaired + error_parts = ["Failed to parse tool call arguments"] if tool_name: @@ -1316,10 +1392,11 @@ def parse_tool_call_arguments( error_parts.append(f"({context})") error_message = ( - " ".join(error_parts) + f". Error: {str(e)}. Arguments: {arguments}" + " ".join(error_parts) + + f". Error: {str(original_error)}. Arguments: {arguments}" ) - raise ValueError(error_message) from e + raise ValueError(error_message) from original_error def split_concatenated_json_objects(raw: str) -> List[Dict[str, Any]]: diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 796223ff8e1..a694cec7d66 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1035,9 +1035,13 @@ def convert_to_anthropic_tool_invoke_xml(tool_calls: list) -> str: parsed_args = parse_tool_call_arguments( tool_arguments, tool_name=tool_name, context="Anthropic XML tool invoke" ) - parameters = "".join( - f"<{param}>{val}\n" for param, val in parsed_args.items() - ) + if isinstance(parsed_args, dict): + parameters = "".join( + f"<{param}>{val}\n" + for param, val in parsed_args.items() + ) + else: + parameters = f"{parsed_args}\n" invokes += ( "\n" f"{tool_name}\n" diff --git a/tests/llm_translation/test_prompt_factory.py b/tests/llm_translation/test_prompt_factory.py index 9f902f2bd86..0e7ed28e1af 100644 --- a/tests/llm_translation/test_prompt_factory.py +++ b/tests/llm_translation/test_prompt_factory.py @@ -1457,3 +1457,147 @@ def test_convert_to_anthropic_tool_invoke_malformed_json(): error_msg = str(exc_info.value) assert "bad_tool" in error_msg assert '{"truncated' in error_msg + + +# ============ _attempt_json_repair Tests ============ +# Tests for the JSON repair utility that fixes truncated tool call arguments + + +def test_attempt_json_repair_missing_closing_brace(): + """Repair JSON truncated with a missing closing brace (issue #22312).""" + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + _attempt_json_repair, + ) + + truncated = '{"command": ["bash","-lc","find /x/repos -name \'messages.py\' -type f"]' + result = _attempt_json_repair(truncated) + assert result is not None + assert result["command"] == ["bash", "-lc", "find /x/repos -name 'messages.py' -type f"] + + +def test_attempt_json_repair_missing_bracket_and_brace(): + """Repair JSON truncated with both missing ] and }.""" + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + _attempt_json_repair, + ) + + truncated = '{"items": [1, 2, 3' + result = _attempt_json_repair(truncated) + assert result is not None + assert result["items"] == [1, 2, 3] + + +def test_attempt_json_repair_trailing_comma(): + """Repair JSON with a trailing comma before missing close.""" + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + _attempt_json_repair, + ) + + truncated = '{"a": 1, "b": 2,' + result = _attempt_json_repair(truncated) + assert result is not None + assert result == {"a": 1, "b": 2} + + +def test_attempt_json_repair_returns_none_for_unterminated_string(): + """Cannot repair an unterminated string — returns None.""" + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + _attempt_json_repair, + ) + + assert _attempt_json_repair('{"key": "incomplete value') is None + + +def test_attempt_json_repair_returns_none_for_valid_json(): + """Valid JSON has no unmatched brackets — returns None (no repair needed).""" + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + _attempt_json_repair, + ) + + assert _attempt_json_repair('{"key": "value"}') is None + + +def test_attempt_json_repair_returns_none_for_empty(): + """Empty / whitespace input returns None.""" + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + _attempt_json_repair, + ) + + assert _attempt_json_repair("") is None + assert _attempt_json_repair(" ") is None + + +def test_attempt_json_repair_interleaved_nesting(): + """Repair JSON with interleaved {} and [] nesting.""" + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + _attempt_json_repair, + ) + + # {"a": [{"b": 2 needs }]} not ]}} + truncated = '{"a": [{"b": 2' + result = _attempt_json_repair(truncated) + assert result is not None + assert result == {"a": [{"b": 2}]} + + +def test_attempt_json_repair_deeply_nested(): + """Repair deeply nested truncated JSON.""" + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + _attempt_json_repair, + ) + + truncated = '{"x": {"y": [1, {"z": [2, 3' + result = _attempt_json_repair(truncated) + assert result is not None + assert result == {"x": {"y": [1, {"z": [2, 3]}]}} + + +def test_parse_tool_call_arguments_whitespace_only(): + """Whitespace-only input returns empty dict.""" + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + parse_tool_call_arguments, + ) + + assert parse_tool_call_arguments(" ") == {} + assert parse_tool_call_arguments("\n") == {} + + +def test_parse_tool_call_arguments_non_object_json(): + """Non-object JSON (list, string, number) is returned as-is (no wrapping).""" + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + parse_tool_call_arguments, + ) + + result = parse_tool_call_arguments('[1, 2, 3]') + assert result == [1, 2, 3] + + +def test_parse_tool_call_arguments_repairs_truncated_json(): + """parse_tool_call_arguments should repair truncated JSON instead of raising.""" + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + parse_tool_call_arguments, + ) + + truncated = '{"command": ["bash","-lc","find /x -type f"]' + result = parse_tool_call_arguments( + truncated, tool_name="shell", context="Anthropic tool invoke" + ) + assert result == {"command": ["bash", "-lc", "find /x -type f"]} + + +def test_parse_tool_call_arguments_still_raises_for_unrepairable(): + """parse_tool_call_arguments raises ValueError when repair also fails.""" + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + parse_tool_call_arguments, + ) + + with pytest.raises(ValueError) as exc_info: + parse_tool_call_arguments( + '{"key": "unterminated', + tool_name="test_tool", + context="test context", + ) + + error_msg = str(exc_info.value) + assert "test_tool" in error_msg + assert "test context" in error_msg From 2f15686ea2cca8faeb2f52ad64b82a1c83314dde Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Thu, 5 Mar 2026 03:46:03 +0530 Subject: [PATCH 040/380] fix: address greptile feedback - redact hashed tokens, proper error codes, add tests - Remove token field from JWTKeyMappingResponse to prevent hashed key exposure - Use _to_response() helper on all CRUD endpoints to control returned fields - Return 409 for unique constraint violations, 400 for FK violations, 404 for not found - Add response_model to endpoint decorators - Add 8 new unit tests covering error handling and token redaction Co-Authored-By: Claude Opus 4.6 --- litellm/proxy/_types.py | 1 - .../jwt_key_mapping_endpoints.py | 123 +++++++-- .../proxy_unit_tests/test_jwt_key_mapping.py | 238 +++++++++++++++++- 3 files changed, 333 insertions(+), 29 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 6b51df709f7..e7aba56f008 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3691,7 +3691,6 @@ class JWTKeyMappingResponse(LiteLLMPydanticObjectBase): id: str jwt_claim_name: str jwt_claim_value: str - token: str description: Optional[str] = None is_active: bool created_at: datetime diff --git a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py index c5d91d3699b..779700caf55 100644 --- a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py +++ b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py @@ -1,12 +1,41 @@ +from typing import List + from fastapi import APIRouter, Depends, HTTPException, Query -from litellm.proxy._types import * -from litellm.proxy._types import hash_token + +from litellm.proxy._types import ( + CreateJWTKeyMappingRequest, + DeleteJWTKeyMappingRequest, + JWTKeyMappingResponse, + LitellmUserRoles, + UpdateJWTKeyMappingRequest, + UserAPIKeyAuth, + hash_token, +) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth router = APIRouter() -@router.post("/jwt/key/mapping/new", tags=["JWT Key Mapping"]) +def _to_response(mapping) -> JWTKeyMappingResponse: + """Convert a Prisma mapping object to a safe response (no hashed token).""" + return JWTKeyMappingResponse( + id=mapping.id, + jwt_claim_name=mapping.jwt_claim_name, + jwt_claim_value=mapping.jwt_claim_value, + description=mapping.description, + is_active=mapping.is_active, + created_at=mapping.created_at, + updated_at=mapping.updated_at, + created_by=mapping.created_by, + updated_by=mapping.updated_by, + ) + + +@router.post( + "/jwt/key/mapping/new", + tags=["JWT Key Mapping"], + response_model=JWTKeyMappingResponse, +) async def create_jwt_key_mapping( data: CreateJWTKeyMappingRequest, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), @@ -41,12 +70,29 @@ async def create_jwt_key_mapping( cache_key = f"jwt_key_mapping:{data.jwt_claim_name}:{data.jwt_claim_value}" await user_api_key_cache.async_delete_cache(cache_key) - return new_mapping + return _to_response(new_mapping) + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + error_str = str(e).lower() + if "unique" in error_str or "p2002" in error_str: + raise HTTPException( + status_code=409, + detail=f"A mapping for claim '{data.jwt_claim_name}' = '{data.jwt_claim_value}' already exists.", + ) + if "foreign" in error_str or "p2003" in error_str: + raise HTTPException( + status_code=400, + detail="The provided key does not match an existing virtual key.", + ) + raise HTTPException(status_code=500, detail="Failed to create JWT key mapping.") -@router.post("/jwt/key/mapping/update", tags=["JWT Key Mapping"]) +@router.post( + "/jwt/key/mapping/update", + tags=["JWT Key Mapping"], + response_model=JWTKeyMappingResponse, +) async def update_jwt_key_mapping( data: UpdateJWTKeyMappingRequest, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), @@ -72,9 +118,11 @@ async def update_jwt_key_mapping( where={"id": data.id} ) - if old_mapping: - cache_key = f"jwt_key_mapping:{old_mapping.jwt_claim_name}:{old_mapping.jwt_claim_value}" - await user_api_key_cache.async_delete_cache(cache_key) + if old_mapping is None: + raise HTTPException(status_code=404, detail="Mapping not found") + + cache_key = f"jwt_key_mapping:{old_mapping.jwt_claim_name}:{old_mapping.jwt_claim_value}" + await user_api_key_cache.async_delete_cache(cache_key) updated_mapping = await prisma_client.db.litellm_jwtkeymapping.update( where={"id": data.id}, data=update_data @@ -84,9 +132,17 @@ async def update_jwt_key_mapping( cache_key = f"jwt_key_mapping:{updated_mapping.jwt_claim_name}:{updated_mapping.jwt_claim_value}" await user_api_key_cache.async_delete_cache(cache_key) - return updated_mapping + return _to_response(updated_mapping) + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + error_str = str(e).lower() + if "unique" in error_str or "p2002" in error_str: + raise HTTPException( + status_code=409, + detail="A mapping with those claim values already exists.", + ) + raise HTTPException(status_code=500, detail="Failed to update JWT key mapping.") @router.post("/jwt/key/mapping/delete", tags=["JWT Key Mapping"]) @@ -110,19 +166,24 @@ async def delete_jwt_key_mapping( where={"id": data.id} ) - if old_mapping: - cache_key = f"jwt_key_mapping:{old_mapping.jwt_claim_name}:{old_mapping.jwt_claim_value}" - await user_api_key_cache.async_delete_cache(cache_key) + if old_mapping is None: + raise HTTPException(status_code=404, detail="Mapping not found") - await prisma_client.db.litellm_jwtkeymapping.delete( - where={"id": data.id} - ) + cache_key = f"jwt_key_mapping:{old_mapping.jwt_claim_name}:{old_mapping.jwt_claim_value}" + await user_api_key_cache.async_delete_cache(cache_key) + + await prisma_client.db.litellm_jwtkeymapping.delete(where={"id": data.id}) return {"status": "success"} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=500, detail="Failed to delete JWT key mapping.") -@router.get("/jwt/key/mapping/list", tags=["JWT Key Mapping"]) +@router.get( + "/jwt/key/mapping/list", + tags=["JWT Key Mapping"], +) async def list_jwt_key_mappings( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), page: int = Query(1, description="Page number", ge=1), @@ -147,16 +208,22 @@ async def list_jwt_key_mappings( ) total_count = await prisma_client.db.litellm_jwtkeymapping.count() return { - "mappings": mappings, + "mappings": [_to_response(m) for m in mappings], "total_count": total_count, "current_page": page, "total_pages": -(-total_count // size), # ceiling division } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=500, detail="Failed to list JWT key mappings.") -@router.get("/jwt/key/mapping/info", tags=["JWT Key Mapping"]) +@router.get( + "/jwt/key/mapping/info", + tags=["JWT Key Mapping"], + response_model=JWTKeyMappingResponse, +) async def info_jwt_key_mapping( id: str, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), @@ -177,8 +244,10 @@ async def info_jwt_key_mapping( ) if mapping is None: raise HTTPException(status_code=404, detail="Mapping not found") - return mapping + return _to_response(mapping) except HTTPException: raise - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + except Exception: + raise HTTPException( + status_code=500, detail="Failed to get JWT key mapping info." + ) diff --git a/tests/proxy_unit_tests/test_jwt_key_mapping.py b/tests/proxy_unit_tests/test_jwt_key_mapping.py index 7d3e7371b17..b67dd2792f8 100644 --- a/tests/proxy_unit_tests/test_jwt_key_mapping.py +++ b/tests/proxy_unit_tests/test_jwt_key_mapping.py @@ -1,6 +1,7 @@ import pytest import sys import os +from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch # Add project root to sys.path @@ -10,8 +11,26 @@ from litellm.proxy.auth.user_api_key_auth import ( _resolve_jwt_to_virtual_key, ) from litellm.proxy.auth.handle_jwt import JWTHandler -from litellm.proxy._types import LiteLLM_JWTAuth, UserAPIKeyAuth +from litellm.proxy._types import ( + JWTKeyMappingResponse, + LiteLLM_JWTAuth, + LitellmUserRoles, + UserAPIKeyAuth, +) +from litellm.proxy.management_endpoints.jwt_key_mapping_endpoints import ( + _to_response, + create_jwt_key_mapping, + delete_jwt_key_mapping, + info_jwt_key_mapping, + update_jwt_key_mapping, +) from litellm.caching.caching import DualCache +from fastapi import HTTPException + + +# ────────────────────────────────────────────── +# Tests: _resolve_jwt_to_virtual_key +# ────────────────────────────────────────────── @pytest.mark.asyncio @@ -114,3 +133,220 @@ async def test_jwt_to_virtual_key_mapping_no_mapping(): ) assert result_cached is None prisma_client.db.litellm_jwtkeymapping.find_first.assert_not_called() + + +# ────────────────────────────────────────────── +# Tests: _to_response redacts hashed token +# ────────────────────────────────────────────── + + +def test_to_response_excludes_token(): + """_to_response should not expose the hashed token field.""" + now = datetime.now(timezone.utc) + mock_mapping = MagicMock() + mock_mapping.id = "mapping-1" + mock_mapping.jwt_claim_name = "email" + mock_mapping.jwt_claim_value = "user@example.com" + mock_mapping.token = "hashed_secret_value" + mock_mapping.description = "test" + mock_mapping.is_active = True + mock_mapping.created_at = now + mock_mapping.updated_at = now + mock_mapping.created_by = "admin" + mock_mapping.updated_by = "admin" + + resp = _to_response(mock_mapping) + + assert isinstance(resp, JWTKeyMappingResponse) + assert resp.id == "mapping-1" + assert resp.jwt_claim_name == "email" + assert "token" not in resp.model_fields + + +# ────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────── + + +def _make_admin_auth() -> UserAPIKeyAuth: + return UserAPIKeyAuth( + token="sk-admin", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + +def _make_non_admin_auth() -> UserAPIKeyAuth: + return UserAPIKeyAuth( + token="sk-user", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + + +def _mock_prisma(): + prisma = MagicMock() + prisma.db.litellm_jwtkeymapping.create = AsyncMock() + prisma.db.litellm_jwtkeymapping.find_unique = AsyncMock() + prisma.db.litellm_jwtkeymapping.find_many = AsyncMock() + prisma.db.litellm_jwtkeymapping.update = AsyncMock() + prisma.db.litellm_jwtkeymapping.delete = AsyncMock() + prisma.db.litellm_jwtkeymapping.count = AsyncMock(return_value=0) + return prisma + + +def _mock_mapping( + id="mapping-1", + claim_name="email", + claim_value="user@example.com", +): + now = datetime.now(timezone.utc) + m = MagicMock() + m.id = id + m.jwt_claim_name = claim_name + m.jwt_claim_value = claim_value + m.token = "hashed_token" + m.description = None + m.is_active = True + m.created_at = now + m.updated_at = now + m.created_by = "admin" + m.updated_by = "admin" + return m + + +# ────────────────────────────────────────────── +# Tests: CRUD endpoint error handling +# ────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_create_returns_409_on_unique_violation(): + """Duplicate mapping should return 409, not 500.""" + from litellm.proxy._types import CreateJWTKeyMappingRequest + + mock_prisma = _mock_prisma() + mock_prisma.db.litellm_jwtkeymapping.create.side_effect = Exception( + "Unique constraint failed (P2002)" + ) + mock_cache = AsyncMock() + + data = CreateJWTKeyMappingRequest( + jwt_claim_name="email", jwt_claim_value="user@example.com", key="sk-test-key", + ) + + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_cache + ): + with pytest.raises(HTTPException) as exc_info: + await create_jwt_key_mapping(data=data, user_api_key_dict=_make_admin_auth()) + assert exc_info.value.status_code == 409 + assert "already exists" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_create_returns_400_on_foreign_key_violation(): + """Non-existent key should return 400, not 500.""" + from litellm.proxy._types import CreateJWTKeyMappingRequest + + mock_prisma = _mock_prisma() + mock_prisma.db.litellm_jwtkeymapping.create.side_effect = Exception( + "Foreign key constraint failed on field: `token` (P2003)" + ) + mock_cache = AsyncMock() + + data = CreateJWTKeyMappingRequest( + jwt_claim_name="sub", jwt_claim_value="user-999", key="sk-nonexistent", + ) + + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_cache + ): + with pytest.raises(HTTPException) as exc_info: + await create_jwt_key_mapping(data=data, user_api_key_dict=_make_admin_auth()) + assert exc_info.value.status_code == 400 + assert "does not match" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_create_non_admin_returns_403(): + """Non-admin users should get 403.""" + from litellm.proxy._types import CreateJWTKeyMappingRequest + + data = CreateJWTKeyMappingRequest( + jwt_claim_name="email", jwt_claim_value="user@example.com", key="sk-test", + ) + + with pytest.raises(HTTPException) as exc_info: + await create_jwt_key_mapping(data=data, user_api_key_dict=_make_non_admin_auth()) + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_delete_returns_404_when_not_found(): + """Deleting non-existent mapping should return 404.""" + from litellm.proxy._types import DeleteJWTKeyMappingRequest + + mock_prisma = _mock_prisma() + mock_prisma.db.litellm_jwtkeymapping.find_unique.return_value = None + mock_cache = AsyncMock() + + data = DeleteJWTKeyMappingRequest(id="nonexistent-id") + + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_cache + ): + with pytest.raises(HTTPException) as exc_info: + await delete_jwt_key_mapping(data=data, user_api_key_dict=_make_admin_auth()) + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_update_returns_404_when_not_found(): + """Updating non-existent mapping should return 404.""" + from litellm.proxy._types import UpdateJWTKeyMappingRequest + + mock_prisma = _mock_prisma() + mock_prisma.db.litellm_jwtkeymapping.find_unique.return_value = None + mock_cache = AsyncMock() + + data = UpdateJWTKeyMappingRequest(id="nonexistent-id", description="test") + + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_cache + ): + with pytest.raises(HTTPException) as exc_info: + await update_jwt_key_mapping(data=data, user_api_key_dict=_make_admin_auth()) + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_info_returns_404_when_not_found(): + """Getting info for non-existent mapping should return 404.""" + mock_prisma = _mock_prisma() + mock_prisma.db.litellm_jwtkeymapping.find_unique.return_value = None + + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with pytest.raises(HTTPException) as exc_info: + await info_jwt_key_mapping(id="nonexistent-id", user_api_key_dict=_make_admin_auth()) + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_create_success_returns_response_without_token(): + """Successful create should return JWTKeyMappingResponse without hashed token.""" + from litellm.proxy._types import CreateJWTKeyMappingRequest + + mock_prisma = _mock_prisma() + mock_prisma.db.litellm_jwtkeymapping.create.return_value = _mock_mapping() + mock_cache = AsyncMock() + + data = CreateJWTKeyMappingRequest( + jwt_claim_name="email", jwt_claim_value="user@example.com", key="sk-test-key", + ) + + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_cache + ): + result = await create_jwt_key_mapping(data=data, user_api_key_dict=_make_admin_auth()) + assert isinstance(result, JWTKeyMappingResponse) + assert "token" not in result.model_fields + assert result.jwt_claim_name == "email" From 63459d6777245266b835e801bcde5078b29e9bef Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Thu, 5 Mar 2026 03:59:59 +0530 Subject: [PATCH 041/380] docs: add JWT-to-Virtual-Key mapping documentation Co-Authored-By: Claude Opus 4.6 --- docs/my-website/docs/proxy/token_auth.md | 89 ++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/docs/my-website/docs/proxy/token_auth.md b/docs/my-website/docs/proxy/token_auth.md index e8634f0faf5..7364ae0fb56 100644 --- a/docs/my-website/docs/proxy/token_auth.md +++ b/docs/my-website/docs/proxy/token_auth.md @@ -1054,6 +1054,95 @@ curl -X GET 'http://0.0.0.0:4000/user/info?user_id=user-123' \ -H 'Authorization: Bearer ' ``` +## [BETA] JWT-to-Virtual-Key Mapping + +Map JWT identities to LiteLLM virtual keys so that JWT-authenticated users get per-user budgets, rate limits, model access controls, and spend tracking. + +When a JWT comes in, LiteLLM looks up a configured claim (e.g. `email`, `sub`) in a mapping table. If a mapping exists, the request is treated as if it arrived with the corresponding virtual key — all virtual key features apply. + +### Setup + +Add `virtual_key_claim_field` to your JWT auth config: + +```yaml +general_settings: + enable_jwt_auth: True + litellm_jwtauth: + virtual_key_claim_field: "email" # JWT claim to look up (supports dot notation) + virtual_key_mapping_cache_ttl: 300 # Cache TTL in seconds (default: 300) +``` + +### Managing Mappings + +All endpoints require admin auth (`Authorization: Bearer `). + +**Create a mapping** — link a JWT claim value to an existing virtual key: + +```bash +curl -X POST http://localhost:4000/jwt/key/mapping/new \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "jwt_claim_name": "email", + "jwt_claim_value": "user@example.com", + "key": "sk-virtual-key-from-key-generate" + }' +``` + +**List mappings** (paginated): + +```bash +curl http://localhost:4000/jwt/key/mapping/list?page=1&size=50 \ + -H "Authorization: Bearer sk-1234" +``` + +**Get a specific mapping:** + +```bash +curl "http://localhost:4000/jwt/key/mapping/info?id=" \ + -H "Authorization: Bearer sk-1234" +``` + +**Update a mapping:** + +```bash +curl -X POST http://localhost:4000/jwt/key/mapping/update \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "id": "", + "description": "Updated description", + "is_active": true + }' +``` + +**Delete a mapping:** + +```bash +curl -X POST http://localhost:4000/jwt/key/mapping/delete \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{"id": ""}' +``` + +### How It Works + +1. A request arrives with a JWT bearer token +2. LiteLLM validates the JWT signature +3. Extracts the configured claim (e.g. `email` → `user@example.com`) +4. Looks up the claim value in the `LiteLLM_JWTKeyMapping` table +5. If a mapping exists, the request proceeds as if the mapped virtual key was used — budgets, rate limits, model access, and spend tracking all apply +6. If no mapping exists, falls back to standard JWT auth (team-level controls) + +### Error Codes + +| Code | Meaning | +|------|---------| +| 409 | Duplicate mapping — a mapping for that claim name + value already exists | +| 400 | The provided key does not match an existing virtual key | +| 404 | Mapping not found (for update/delete/info) | +| 403 | Non-admin user attempted a mapping operation | + ## All JWT Params [**See Code**](https://github.com/BerriAI/litellm/blob/b204f0c01c703317d812a1553363ab0cb989d5b6/litellm/proxy/_types.py#L95) From 36e63bd1eebbacbf8a5034536167a8174b7af09a Mon Sep 17 00:00:00 2001 From: Harshit Jain <48647625+Harshit28j@users.noreply.github.com> Date: Thu, 5 Mar 2026 04:12:54 +0530 Subject: [PATCH 042/380] Update litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../proxy/management_endpoints/jwt_key_mapping_endpoints.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py index 779700caf55..ab3ad82e3ce 100644 --- a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py +++ b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py @@ -142,6 +142,11 @@ async def update_jwt_key_mapping( status_code=409, detail="A mapping with those claim values already exists.", ) + if "foreign" in error_str or "p2003" in error_str: + raise HTTPException( + status_code=400, + detail="The provided key does not match an existing virtual key.", + ) raise HTTPException(status_code=500, detail="Failed to update JWT key mapping.") From 20a41a67d605450ee2f0b0caa5656b520661ebe8 Mon Sep 17 00:00:00 2001 From: Chesars Date: Mon, 15 Dec 2025 19:55:55 -0300 Subject: [PATCH 043/380] fix: update gemini-live model supported_endpoints to /vertex_ai/live The gemini-live-2.5-flash-preview-native-audio-09-2025 model only works with WebSocket (Live API), not REST endpoints. Changed supported_endpoints from /v1/chat/completions to /vertex_ai/live to reflect the actual passthrough endpoint available in LiteLLM proxy. --- litellm/model_prices_and_context_window_backup.json | 6 ++---- model_prices_and_context_window.json | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index fc8c90ad773..bf86c3ffb20 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -14745,8 +14745,7 @@ "output_cost_per_token": 2e-06, "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions" + "/vertex_ai/live" ], "supported_modalities": [ "text", @@ -14791,8 +14790,7 @@ "rpm": 100000, "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions" + "/vertex_ai/live" ], "supported_modalities": [ "text", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index fc8c90ad773..bf86c3ffb20 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -14745,8 +14745,7 @@ "output_cost_per_token": 2e-06, "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions" + "/vertex_ai/live" ], "supported_modalities": [ "text", @@ -14791,8 +14790,7 @@ "rpm": 100000, "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ - "/v1/chat/completions", - "/v1/completions" + "/vertex_ai/live" ], "supported_modalities": [ "text", From ddf9598f30c19d56fefa8702c49c0fb83eec5107 Mon Sep 17 00:00:00 2001 From: Chesars Date: Mon, 15 Dec 2025 19:58:25 -0300 Subject: [PATCH 044/380] fix: use /v1/realtime for gemini/ provider live model The gemini/ prefix indicates Google AI Studio, which uses /v1/realtime endpoint (OpenAI-compatible), not /vertex_ai/live. --- litellm/model_prices_and_context_window_backup.json | 2 +- model_prices_and_context_window.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index bf86c3ffb20..d2196f4e125 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -14790,7 +14790,7 @@ "rpm": 100000, "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ - "/vertex_ai/live" + "/v1/realtime" ], "supported_modalities": [ "text", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index bf86c3ffb20..d2196f4e125 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -14790,7 +14790,7 @@ "rpm": 100000, "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ - "/vertex_ai/live" + "/v1/realtime" ], "supported_modalities": [ "text", From 0e1a633e30a07886bc5c33d55d98275ef113b087 Mon Sep 17 00:00:00 2001 From: Chesars Date: Tue, 16 Dec 2025 23:03:57 -0300 Subject: [PATCH 045/380] fix: update mode to realtime for gemini-live models The mode field is used by health checks to determine the correct check method (WebSocket for realtime vs REST for chat). --- litellm/model_prices_and_context_window_backup.json | 4 ++-- model_prices_and_context_window.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d2196f4e125..c09cb647b72 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -14740,7 +14740,7 @@ "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, - "mode": "chat", + "mode": "realtime", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_token": 2e-06, "source": "https://ai.google.dev/gemini-api/docs/pricing", @@ -14784,7 +14784,7 @@ "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, - "mode": "chat", + "mode": "realtime", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_token": 2e-06, "rpm": 100000, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index d2196f4e125..c09cb647b72 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -14740,7 +14740,7 @@ "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, - "mode": "chat", + "mode": "realtime", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_token": 2e-06, "source": "https://ai.google.dev/gemini-api/docs/pricing", @@ -14784,7 +14784,7 @@ "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, - "mode": "chat", + "mode": "realtime", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_token": 2e-06, "rpm": 100000, From 09e1a06f47af2f32229254b05c6afcc633b8cf2e Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Wed, 4 Mar 2026 14:54:05 -0800 Subject: [PATCH 046/380] fix(ui): allow internal users/team admins to select guardrails when creating keys (#22816) * fix(proxy): add guardrails list routes for internal users * fix(ui): add guardrails fetch with v1/v2 fallback in networking * fix(ui): allow internal users/team admins to select guardrails in create key modal * fix(ui): show guardrails selector for internal users in key edit view * fix(ui): pass canEditGuardrails flag to key info view * test(ui): add tests for role-based guardrails access in key info view * test(ui): update key edit view test for guardrails --- litellm/proxy/_types.py | 2 + .../src/components/networking.tsx | 34 +++++++--- .../organisms/create_key_button.tsx | 11 ++-- .../KeyInfoView.handleKeyUpdate.test.tsx | 62 +++++++++++++++++-- .../templates/key_edit_view.test.tsx | 2 +- .../components/templates/key_edit_view.tsx | 6 +- .../components/templates/key_info_view.tsx | 5 +- 7 files changed, 99 insertions(+), 23 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 42b48446e7a..4f72e30ac64 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -622,6 +622,8 @@ class LiteLLMRoutes(enum.Enum): "/global/activity/model", "/v1/models/{model_id}", "/models/{model_id}", + "/guardrails/list", + "/v2/guardrails/list", ] + spend_tracking_routes + key_management_routes diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index f2cb613ee29..9df2a3401e0 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -5460,8 +5460,8 @@ export const testMCPSemanticFilter = async (accessToken: string, model: string, export const getGuardrailsList = async (accessToken: string) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/v2/guardrails/list` : `/v2/guardrails/list`; - const response = await fetch(url, { + const v2Url = proxyBaseUrl ? `${proxyBaseUrl}/v2/guardrails/list` : `/v2/guardrails/list`; + const response = await fetch(v2Url, { method: "GET", headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}`, @@ -5470,17 +5470,35 @@ export const getGuardrailsList = async (accessToken: string) => { }); if (!response.ok) { - const errorData = await response.json(); - const errorMessage = deriveErrorMessage(errorData); - handleError(errorMessage); - throw new Error(errorMessage); + throw new Error(`v2 guardrails/list returned ${response.status}`); } const data = await response.json(); return data; } catch (error) { - console.error("Failed to get guardrails list:", error); - throw error; + console.log("v2/guardrails/list failed, falling back to v1:", error); + try { + const v1Url = proxyBaseUrl ? `${proxyBaseUrl}/guardrails/list` : `/guardrails/list`; + const fallbackResponse = await fetch(v1Url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!fallbackResponse.ok) { + const errorData = await fallbackResponse.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return await fallbackResponse.json(); + } catch (fallbackError) { + console.error("Failed to get guardrails list:", fallbackError); + throw fallbackError; + } } }; 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 802dfd14b63..19a07c49a3d 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -146,6 +146,7 @@ export const fetchUserModels = async ( */ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized(); + const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole)); const { data: projects, isLoading: isProjectsLoading } = useProjects(); const { data: uiSettingsData } = useUISettings(); const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); @@ -1000,7 +1001,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { name="guardrails" className="mt-4" help={ - premiumUser + canEditGuardrails ? "Select existing guardrails or enter new ones" : "Premium feature - Upgrade to set guardrails by key" } @@ -1008,9 +1009,9 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { handleDisplayNameChange(tool.name, e.target.value)} + /> + + Override how this tool's name appears to users. Leave blank to use original. + + +
+ + Description + + handleDescriptionChange(tool.name, e.target.value)} + rows={2} + /> + + Override the tool description shown to users. Leave blank to use original. + +
+ + )} - - )) + ); + }) )} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 1fa447c0e67..8a08f13e22a 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -169,6 +169,8 @@ export interface MCPServer { teams?: Team[]; mcp_access_groups?: string[]; allowed_tools?: string[]; + tool_name_to_display_name?: Record; + tool_name_to_description?: Record; allow_all_keys?: boolean; available_on_public_internet?: boolean; From b6c2028294945585fc0d2f26d71501a9e903c666 Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 4 Mar 2026 18:03:54 -0800 Subject: [PATCH 061/380] chore for release notes --- docs/my-website/release_notes/v1.81.14.md | 2 +- docs/my-website/release_notes/v1.82.0.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/my-website/release_notes/v1.81.14.md b/docs/my-website/release_notes/v1.81.14.md index 3a133f092ae..7a6e79f1b77 100644 --- a/docs/my-website/release_notes/v1.81.14.md +++ b/docs/my-website/release_notes/v1.81.14.md @@ -1,5 +1,5 @@ --- -title: "[Preview] v1.81.14 - New Gateway Level Guardrails & Compliance Playground" +title: "v1.81.14 - New Gateway Level Guardrails & Compliance Playground" slug: "v1-81-14" date: 2026-02-21T00:00:00 authors: diff --git a/docs/my-website/release_notes/v1.82.0.md b/docs/my-website/release_notes/v1.82.0.md index beb2451dd5c..c1eb00709f2 100644 --- a/docs/my-website/release_notes/v1.82.0.md +++ b/docs/my-website/release_notes/v1.82.0.md @@ -1,5 +1,5 @@ --- -title: "v1.82.0 - Realtime Guardrails, Projects Management, and 10+ Performance Optimizations" +title: "[Preview] v1.82.0 - Realtime Guardrails, Projects Management, and 10+ Performance Optimizations" slug: "v1-82-0" date: 2026-02-28T00:00:00 authors: From c60ea1878d2ab1fa2fd45584da4d3d5051481aa6 Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 4 Mar 2026 18:15:45 -0800 Subject: [PATCH 062/380] chore --- docs/my-website/release_notes/v1.81.14.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/my-website/release_notes/v1.81.14.md b/docs/my-website/release_notes/v1.81.14.md index 3a133f092ae..0129b4cab86 100644 --- a/docs/my-website/release_notes/v1.81.14.md +++ b/docs/my-website/release_notes/v1.81.14.md @@ -34,7 +34,7 @@ ghcr.io/berriai/litellm:main-v1.81.14.rc.1 ``` showLineNumbers title="pip install litellm" -pip install litellm==1.81.14 +pip install litellm==1.81.14-stable ``` From 1c46495c01cab449aab265ca663adff429524d17 Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 4 Mar 2026 18:24:04 -0800 Subject: [PATCH 063/380] new update --- docs/my-website/release_notes/v1.81.14.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/my-website/release_notes/v1.81.14.md b/docs/my-website/release_notes/v1.81.14.md index 0129b4cab86..20836d73b19 100644 --- a/docs/my-website/release_notes/v1.81.14.md +++ b/docs/my-website/release_notes/v1.81.14.md @@ -27,7 +27,7 @@ import Image from '@theme/IdealImage'; docker run \ -e STORE_MODEL_IN_DB=True \ -p 4000:4000 \ -ghcr.io/berriai/litellm:main-v1.81.14.rc.1 +ghcr.io/berriai/litellm:main-v1.81.14-stable ```
From 5bd692e649a82038c36c4772eb4b13effa06f4f9 Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 4 Mar 2026 18:27:40 -0800 Subject: [PATCH 064/380] doc change --- docs/my-website/release_notes/v1.81.14.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/my-website/release_notes/v1.81.14.md b/docs/my-website/release_notes/v1.81.14.md index 1dcfa071d04..c342bc47ee9 100644 --- a/docs/my-website/release_notes/v1.81.14.md +++ b/docs/my-website/release_notes/v1.81.14.md @@ -34,7 +34,7 @@ ghcr.io/berriai/litellm:main-v1.81.14-stable ``` showLineNumbers title="pip install litellm" -pip install litellm==1.81.14-stable +pip install litellm==1.81.14 ``` From 38ea5aba801e03af36e45d833177f4d74522352f Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Wed, 4 Mar 2026 18:51:02 -0800 Subject: [PATCH 065/380] Delete ttft-logs-screenshot.png --- ttft-logs-screenshot.png | Bin 222088 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 ttft-logs-screenshot.png diff --git a/ttft-logs-screenshot.png b/ttft-logs-screenshot.png deleted file mode 100644 index f07ad3b030862ad957dbf72f2a942385f3969295..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 222088 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

41F^<&4%Cs2_o;_1RGX%I)nwe zkmfOqdIrwesZWs{m@*c~S8!ak1k=-BRkZk{_5Kyd>ZvwiLQ-V06u1#7fV@;(6iO

4aZCI(QqRn)_ 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 z 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⪻9Zzf0!nE6Wq(;qFdNmWLHytqBC=1 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 Date: Wed, 4 Mar 2026 19:09:01 -0800 Subject: [PATCH 066/380] [Fix] UI - MCP Servers: align Current Team section with tabs Co-Authored-By: Claude Sonnet 4.6 --- ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx index 969438e1b37..238d422f6f7 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -348,7 +348,7 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) /> ) : (

-
+
From cb4aee5ce6a9a0ff3d87e4a779331341f42de137 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 4 Mar 2026 19:18:42 -0800 Subject: [PATCH 067/380] fix: remove px-6 from table wrapper to align with tabs Co-Authored-By: Claude Sonnet 4.6 --- ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx index 238d422f6f7..0f87f5e87b8 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -401,7 +401,7 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID })
-
+
Date: Wed, 4 Mar 2026 19:20:08 -0800 Subject: [PATCH 068/380] [Feature] Add option to hide bouncing icon in header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a localStorage-based toggle to hide the bouncing 🌑 icon next to the version tag in the navbar, following the same pattern used for hiding prompts, usage indicator, new feature badges, and blog posts. Co-Authored-By: Claude Sonnet 4.6 --- .../hooks/useDisableBouncingIcon.ts | 33 +++++++++++++++++++ .../Navbar/UserDropdown/UserDropdown.tsx | 19 +++++++++++ .../src/components/navbar.tsx | 18 ++++++---- 3 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBouncingIcon.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBouncingIcon.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBouncingIcon.ts new file mode 100644 index 00000000000..f5d8087ebe7 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBouncingIcon.ts @@ -0,0 +1,33 @@ +import { LOCAL_STORAGE_EVENT, getLocalStorageItem } from "@/utils/localStorageUtils"; +import { useSyncExternalStore } from "react"; + +function subscribe(callback: () => void) { + const onStorage = (e: StorageEvent) => { + if (e.key === "disableBouncingIcon") { + callback(); + } + }; + + const onCustom = (e: Event) => { + const { key } = (e as CustomEvent).detail; + if (key === "disableBouncingIcon") { + callback(); + } + }; + + window.addEventListener("storage", onStorage); + window.addEventListener(LOCAL_STORAGE_EVENT, onCustom); + + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener(LOCAL_STORAGE_EVENT, onCustom); + }; +} + +function getSnapshot() { + return getLocalStorageItem("disableBouncingIcon") === "true"; +} + +export function useDisableBouncingIcon() { + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index 2bef9a80778..6490cd32fa7 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -1,5 +1,6 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; +import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon"; import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator"; import { @@ -31,6 +32,7 @@ const UserDropdown: React.FC = ({ onLogout }) => { const disableShowPrompts = useDisableShowPrompts(); const disableUsageIndicator = useDisableUsageIndicator(); const disableBlogPosts = useDisableBlogPosts(); + const disableBouncingIcon = useDisableBouncingIcon(); const [disableShowNewBadge, setDisableShowNewBadge] = useState(false); useEffect(() => { @@ -167,6 +169,23 @@ const UserDropdown: React.FC = ({ onLogout }) => { aria-label="Toggle hide blog posts" /> + + Hide Bouncing Icon + { + if (checked) { + setLocalStorageItem("disableBouncingIcon", "true"); + emitLocalStorageChange("disableBouncingIcon"); + } else { + removeLocalStorageItem("disableBouncingIcon"); + emitLocalStorageChange("disableBouncingIcon"); + } + }} + aria-label="Toggle hide bouncing icon" + /> + ); diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 861fe054646..b1bb557b2a5 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -1,4 +1,5 @@ import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness"; +import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon"; import { getProxyBaseUrl } from "@/components/networking"; import { useTheme } from "@/contexts/ThemeContext"; import { clearTokenCookies } from "@/utils/cookieUtils"; @@ -45,6 +46,7 @@ const Navbar: React.FC = ({ const { logoUrl } = useTheme(); const { data: healthData } = useHealthReadiness(); const version = healthData?.litellm_version; + const disableBouncingIcon = useDisableBouncingIcon(); // Simple logo URL: use custom logo if available, otherwise default const imageUrl = logoUrl || `${baseUrl}/get_image`; @@ -101,13 +103,15 @@ const Navbar: React.FC = ({ {version && (
- - 🌑 - + {!disableBouncingIcon && ( + + 🌑 + + )} Date: Thu, 5 Mar 2026 09:26:51 +0530 Subject: [PATCH 069/380] docs(v1.82.0): add v1/messages routing note and caution to release notes Made-with: Cursor --- docs/my-website/release_notes/v1.82.0.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/my-website/release_notes/v1.82.0.md b/docs/my-website/release_notes/v1.82.0.md index beb2451dd5c..250d3497527 100644 --- a/docs/my-website/release_notes/v1.82.0.md +++ b/docs/my-website/release_notes/v1.82.0.md @@ -46,6 +46,11 @@ pip install litellm==1.82.0 - **Guardrail ecosystem expansion** — [Noma v2, Lakera v2 post-call, Singapore regulatory policies (PDPA + MAS), employment discrimination blockers, code execution blocker, guardrail policy versioning, and production monitoring](../../docs/proxy/guardrails) - [PR #21400](https://github.com/BerriAI/litellm/pull/21400), [PR #21783](https://github.com/BerriAI/litellm/pull/21783), [PR #21948](https://github.com/BerriAI/litellm/pull/21948) - **OpenAI Codex 5.3 — day 0** — [Full support for `gpt-5.3-codex` on OpenAI and Azure, plus `gpt-audio-1.5` and `gpt-realtime-1.5` model coverage](../../docs/providers/openai) - [PR #22035](https://github.com/BerriAI/litellm/pull/22035) - **10+ performance optimizations** — Streaming hot-path fixes, Redis pipeline batching, database task batching, ModelResponse init skip, and router cache improvements — lower latency and CPU on every request +- **`/v1/messages` → `/responses` routing** — `/v1/messages` requests are now routed to the [Responses API](../../docs/response_api) by default for OpenAI/Azure models + +:::danger v1/messages routing change +This version starts routing `/v1/messages` requests to the `/responses` API by default. To opt out and continue using chat/completions, set `LITELLM_USE_CHAT_COMPLETIONS_URL_FOR_ANTHROPIC_MESSAGES=true` or `litellm_settings.use_chat_completions_url_for_anthropic_messages: true` in your config. +::: --- From 51d876ce7906b1339fa78eeead0af4086ee3b359 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 4 Mar 2026 20:11:42 -0800 Subject: [PATCH 070/380] [Fix] UI - Keys: Organization shows Not Set due to org_id/organization_id mismatch The /key/list API returns `org_id` (the Pydantic field name), but the UI was reading `organization_id`, causing the Organization field to always show "Not Set" and the Organization ID filter to never match. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/VirtualKeysPage/VirtualKeysTable.tsx | 2 +- .../src/components/key_team_helpers/filter_logic.tsx | 2 +- .../src/components/key_team_helpers/key_list.tsx | 1 + ui/litellm-dashboard/src/components/templates/key_info_view.tsx | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index badaca93939..fe9d58b9791 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -210,7 +210,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo }, { id: "organization_id", - accessorKey: "organization_id", + accessorKey: "org_id", header: "Organization ID", size: 140, enableSorting: false, diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx index cf4cee64811..cd55477208c 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx @@ -96,7 +96,7 @@ export function useFilterLogic({ // Apply Organization ID filter if (filters["Organization ID"]) { - result = result.filter((key) => key.organization_id === filters["Organization ID"]); + result = result.filter((key) => (key.organization_id ?? key.org_id) === filters["Organization ID"]); } setFilteredKeys(result); diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index 08bccda7749..4cc3367f71d 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -47,6 +47,7 @@ export interface KeyResponse { blocked: boolean; litellm_budget_table: Record; organization_id: string | null; + org_id?: string | null; created_at: string; updated_at: string; last_active: string | null; 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 ed88d03c1e7..5d00ab3d0b9 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -644,7 +644,7 @@ export default function KeyInfoView({
Organization - {currentKeyData.organization_id || "Not Set"} + {(currentKeyData.organization_id ?? currentKeyData.org_id) || "Not Set"}
From 96b75be03d1db6e4957183061fb20e97163318ee Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 4 Mar 2026 20:13:14 -0800 Subject: [PATCH 071/380] [Feature] RBAC for Vector Stores and Agents Add proxy-admin-configurable toggles to restrict internal users (and optionally team admins) from accessing agent and vector store management features. Co-Authored-By: Claude Sonnet 4.6 --- litellm/proxy/agent_endpoints/endpoints.py | 20 ++- litellm/proxy/common_utils/rbac_utils.py | 126 ++++++++++++++ .../proxy_setting_endpoints.py | 60 +++++-- .../management_endpoints.py | 11 ++ .../proxy/agent_endpoints/test_agent_rbac.py | 84 ++++++++++ .../proxy/common_utils/test_rbac_utils.py | 156 ++++++++++++++++++ .../test_vector_store_rbac.py | 121 ++++++++++++++ .../components/SidebarProvider.tsx | 12 ++ .../AdminSettings/UISettings/UISettings.tsx | 136 +++++++++++++++ .../src/components/leftnav.tsx | 8 +- 10 files changed, 720 insertions(+), 14 deletions(-) create mode 100644 litellm/proxy/common_utils/rbac_utils.py create mode 100644 tests/litellm/proxy/agent_endpoints/test_agent_rbac.py create mode 100644 tests/litellm/proxy/common_utils/test_rbac_utils.py create mode 100644 tests/litellm/proxy/vector_store_endpoints/test_vector_store_rbac.py diff --git a/litellm/proxy/agent_endpoints/endpoints.py b/litellm/proxy/agent_endpoints/endpoints.py index 65674d01be7..80c55f634f7 100644 --- a/litellm/proxy/agent_endpoints/endpoints.py +++ b/litellm/proxy/agent_endpoints/endpoints.py @@ -16,6 +16,7 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.common_utils.rbac_utils import check_feature_access_for_user from litellm.proxy.management_endpoints.common_daily_activity import get_daily_activity from litellm.types.agents import ( AgentConfig, @@ -69,6 +70,8 @@ async def get_agents( Returns: List[AgentResponse] """ + await check_feature_access_for_user(user_api_key_dict, "agents") + from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry from litellm.proxy.agent_endpoints.auth.agent_permission_handler import ( AgentRequestHandler, @@ -179,6 +182,8 @@ async def create_agent( }' ``` """ + await check_feature_access_for_user(user_api_key_dict, "agents") + from litellm.proxy.proxy_server import prisma_client _check_agent_management_permission(user_api_key_dict) @@ -233,7 +238,10 @@ async def create_agent( dependencies=[Depends(user_api_key_auth)], response_model=AgentResponse, ) -async def get_agent_by_id(agent_id: str): +async def get_agent_by_id( + agent_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): """ Get a specific agent by ID @@ -243,6 +251,8 @@ async def get_agent_by_id(agent_id: str): -H "Authorization: Bearer " ``` """ + await check_feature_access_for_user(user_api_key_dict, "agents") + from litellm.proxy.proxy_server import prisma_client if prisma_client is None: @@ -319,6 +329,8 @@ async def update_agent( }' ``` """ + await check_feature_access_for_user(user_api_key_dict, "agents") + from litellm.proxy.proxy_server import prisma_client _check_agent_management_permission(user_api_key_dict) @@ -410,6 +422,8 @@ async def patch_agent( }' ``` """ + await check_feature_access_for_user(user_api_key_dict, "agents") + from litellm.proxy.proxy_server import prisma_client _check_agent_management_permission(user_api_key_dict) @@ -484,6 +498,8 @@ async def delete_agent( } ``` """ + await check_feature_access_for_user(user_api_key_dict, "agents") + from litellm.proxy.proxy_server import prisma_client _check_agent_management_permission(user_api_key_dict) @@ -763,6 +779,8 @@ async def get_agent_daily_activity( """ Get daily activity for specific agents or all accessible agents. """ + await check_feature_access_for_user(user_api_key_dict, "agents") + from litellm.proxy.proxy_server import prisma_client if prisma_client is None: diff --git a/litellm/proxy/common_utils/rbac_utils.py b/litellm/proxy/common_utils/rbac_utils.py new file mode 100644 index 00000000000..2b187d18065 --- /dev/null +++ b/litellm/proxy/common_utils/rbac_utils.py @@ -0,0 +1,126 @@ +""" +RBAC utility helpers for feature-level access control. + +These helpers are used by agent and vector store endpoints to enforce +proxy-admin-configurable toggles that restrict access for internal users. +""" + +from typing import TYPE_CHECKING + +from fastapi import HTTPException + +from litellm._logging import verbose_proxy_logger +from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, UserAPIKeyAuth + +if TYPE_CHECKING: + pass + + +def _is_user_team_admin_for_any_team( + user_api_key_dict: UserAPIKeyAuth, + teams: list, +) -> bool: + """ + Return True if the user is an admin member in at least one of the given teams. + + Args: + user_api_key_dict: The authenticated user. + teams: List of Prisma team records (from litellm_teamtable.find_many). + """ + for team in teams: + team_obj = LiteLLM_TeamTable(**team.model_dump()) + for member in team_obj.members_with_roles: + if ( + member.user_id is not None + and member.user_id == user_api_key_dict.user_id + and member.role == "admin" + ): + return True + return False + + +async def check_feature_access_for_user( + user_api_key_dict: UserAPIKeyAuth, + feature_name: str, +) -> None: + """ + Raise HTTP 403 if the user's role is blocked from accessing the given feature + by the UI settings stored in general_settings. + + Args: + user_api_key_dict: The authenticated user. + feature_name: Either "agents" or "vector_stores". + """ + # Proxy admins (and view-only admins) are never blocked. + if user_api_key_dict.user_role in ( + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + LitellmUserRoles.PROXY_ADMIN.value, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ): + return + + from litellm.proxy.proxy_server import general_settings + + disable_flag = f"disable_{feature_name}_for_internal_users" + allow_team_admins_flag = f"allow_{feature_name}_for_team_admins" + + if not general_settings.get(disable_flag, False): + # Feature is not disabled — allow all authenticated users. + return + + # Feature is disabled. Check if team admins are exempted. + if general_settings.get(allow_team_admins_flag, False): + is_team_admin = await _check_if_team_admin(user_api_key_dict) + if is_team_admin: + return + + raise HTTPException( + status_code=403, + detail={ + "error": f"Access to {feature_name} is disabled for your role. Contact your proxy admin." + }, + ) + + +async def _check_if_team_admin(user_api_key_dict: UserAPIKeyAuth) -> bool: + """ + Return True if the user is a team admin in any team. + Mirrors the logic in management_endpoints/common_utils._user_has_admin_privileges + but scoped to team-admin check only. + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None or user_api_key_dict.user_id is None: + return False + + from litellm.caching import DualCache + from litellm.proxy.auth.auth_checks import get_user_object + + try: + user_obj = await get_user_object( + user_id=user_api_key_dict.user_id, + prisma_client=prisma_client, + user_api_key_cache=DualCache(), + user_id_upsert=False, + proxy_logging_obj=None, + ) + + if user_obj is None: + return False + + if user_obj.teams is None or len(user_obj.teams) == 0: + return False + + teams = await prisma_client.db.litellm_teamtable.find_many( + where={"team_id": {"in": user_obj.teams}} + ) + + return _is_user_team_admin_for_any_team(user_api_key_dict, teams) + + except Exception as e: + verbose_proxy_logger.debug( + f"rbac_utils: error checking team admin status for user " + f"{user_api_key_dict.user_id}: {e}" + ) + return False diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index ceda08d520a..8991dc5fd5c 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -104,6 +104,26 @@ class UISettings(BaseModel): description="If enabled, shows the Projects feature in the UI sidebar and the project field in key management.", ) + disable_agents_for_internal_users: bool = Field( + default=False, + description="If true, internal users cannot access agent management endpoints or the Agents page in the UI.", + ) + + allow_agents_for_team_admins: bool = Field( + default=False, + description="If true, team admins are exempt from the agents disable restriction (only takes effect when disable_agents_for_internal_users is true).", + ) + + disable_vector_stores_for_internal_users: bool = Field( + default=False, + description="If true, internal users cannot access vector store management endpoints or the Vector Stores page in the UI.", + ) + + allow_vector_stores_for_team_admins: bool = Field( + default=False, + description="If true, team admins are exempt from the vector stores disable restriction (only takes effect when disable_vector_stores_for_internal_users is true).", + ) + class UISettingsResponse(SettingsResponse): """Response model for UI settings""" @@ -119,6 +139,10 @@ ALLOWED_UI_SETTINGS_FIELDS = { "require_auth_for_public_ai_hub", "forward_client_headers_to_llm_api", "enable_projects_ui", + "disable_agents_for_internal_users", + "allow_agents_for_team_admins", + "disable_vector_stores_for_internal_users", + "allow_vector_stores_for_team_admins", } @@ -976,14 +1000,20 @@ async def get_ui_settings(): k: v for k, v in ui_settings.items() if k in ALLOWED_UI_SETTINGS_FIELDS } - # Sync forward_client_headers_to_llm_api into general_settings so the proxy - # picks it up at runtime (covers server restart scenarios). - if "forward_client_headers_to_llm_api" in ui_settings: + # Sync runtime flags into general_settings so the proxy picks them up + # at runtime (covers server restart scenarios). + _runtime_flags = [ + "forward_client_headers_to_llm_api", + "disable_agents_for_internal_users", + "allow_agents_for_team_admins", + "disable_vector_stores_for_internal_users", + "allow_vector_stores_for_team_admins", + ] + _flags_to_sync = {k: ui_settings[k] for k in _runtime_flags if k in ui_settings} + if _flags_to_sync: from litellm.proxy.proxy_server import general_settings - general_settings["forward_client_headers_to_llm_api"] = ui_settings[ - "forward_client_headers_to_llm_api" - ] + general_settings.update(_flags_to_sync) # Build config-like object for schema helper config: Dict[str, Any] = {"litellm_settings": {"ui_settings": ui_settings}} @@ -1048,14 +1078,20 @@ async def update_ui_settings( }, ) - # Sync forward_client_headers_to_llm_api to general_settings so the proxy - # picks it up at runtime (general_settings is checked in pre-call utils). - if "forward_client_headers_to_llm_api" in ui_settings: + # Sync runtime flags to general_settings so the proxy picks them up + # at runtime (general_settings is checked in pre-call utils). + _runtime_flags = [ + "forward_client_headers_to_llm_api", + "disable_agents_for_internal_users", + "allow_agents_for_team_admins", + "disable_vector_stores_for_internal_users", + "allow_vector_stores_for_team_admins", + ] + _flags_to_sync = {k: ui_settings[k] for k in _runtime_flags if k in ui_settings} + if _flags_to_sync: from litellm.proxy.proxy_server import general_settings - general_settings["forward_client_headers_to_llm_api"] = ui_settings[ - "forward_client_headers_to_llm_api" - ] + general_settings.update(_flags_to_sync) return { "message": "UI settings updated successfully", diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index cccbb51f47b..068f4217e0f 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -24,6 +24,7 @@ from litellm.proxy._types import ( ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper +from litellm.proxy.common_utils.rbac_utils import check_feature_access_for_user from litellm.secret_managers.main import get_secret from litellm.types.vector_stores import ( LiteLLM_ManagedVectorStore, @@ -439,6 +440,8 @@ async def new_vector_store( - vector_store_description: Optional[str] - Description of the vector store - vector_store_metadata: Optional[Dict] - Additional metadata for the vector store """ + await check_feature_access_for_user(user_api_key_dict, "vector_stores") + from litellm.proxy.proxy_server import prisma_client try: @@ -506,6 +509,8 @@ async def list_vector_stores( - page: int - Page number for pagination (default: 1) - page_size: int - Number of items per page (default: 100) """ + await check_feature_access_for_user(user_api_key_dict, "vector_stores") + from litellm.proxy.proxy_server import prisma_client vector_store_map: Dict[str, LiteLLM_ManagedVectorStore] = {} @@ -605,6 +610,8 @@ async def delete_vector_store( Parameters: - vector_store_id: str - ID of the vector store to delete """ + await check_feature_access_for_user(user_api_key_dict, "vector_stores") + from litellm.proxy.proxy_server import prisma_client if prisma_client is None: @@ -687,6 +694,8 @@ async def get_vector_store_info( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """Return a single vector store's details""" + await check_feature_access_for_user(user_api_key_dict, "vector_stores") + from litellm.proxy.proxy_server import prisma_client if prisma_client is None: @@ -770,6 +779,8 @@ async def update_vector_store( Update vector store details in both database and in-memory registry. The updated data is immediately synchronized to the in-memory registry. """ + await check_feature_access_for_user(user_api_key_dict, "vector_stores") + from litellm.proxy.proxy_server import prisma_client from litellm.types.router import GenericLiteLLMParams diff --git a/tests/litellm/proxy/agent_endpoints/test_agent_rbac.py b/tests/litellm/proxy/agent_endpoints/test_agent_rbac.py new file mode 100644 index 00000000000..a863201ddb5 --- /dev/null +++ b/tests/litellm/proxy/agent_endpoints/test_agent_rbac.py @@ -0,0 +1,84 @@ +""" +Tests for RBAC enforcement on agent endpoints. + +Verifies that check_feature_access_for_user is called and that a 403 is +raised when agents are disabled for internal users. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException + +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + + +def _make_internal_user(user_id: str = "user-1") -> UserAPIKeyAuth: + return UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER.value, + user_id=user_id, + ) + + +def _make_admin_user(user_id: str = "admin-1") -> UserAPIKeyAuth: + return UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN.value, + user_id=user_id, + ) + + +# --------------------------------------------------------------------------- +# get_agents +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_get_agents_blocked_for_internal_user_when_disabled(): + """get_agents should raise 403 when agents are disabled for internal users.""" + from litellm.proxy.agent_endpoints.endpoints import get_agents + + user = _make_internal_user() + gs = {"disable_agents_for_internal_users": True, "allow_agents_for_team_admins": False} + + request_mock = MagicMock() + with patch.dict("litellm.proxy.proxy_server.general_settings", gs, clear=True): + with pytest.raises(HTTPException) as exc_info: + await get_agents(request=request_mock, user_api_key_dict=user) + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_get_agents_allowed_when_not_disabled(): + """get_agents should not raise RBAC 403 when agents are not disabled.""" + from litellm.proxy.agent_endpoints.endpoints import get_agents + + user = _make_internal_user() + request_mock = MagicMock() + + with patch.dict("litellm.proxy.proxy_server.general_settings", {}, clear=True): + with patch( + "litellm.proxy.agent_endpoints.agent_registry.global_agent_registry", + MagicMock(get_agent_list=MagicMock(return_value=[])), + ): + with patch( + "litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.get_allowed_agents", + new=AsyncMock(return_value=[]), + ): + result = await get_agents(request=request_mock, user_api_key_dict=user) + assert result == [] + + +# --------------------------------------------------------------------------- +# get_agent_daily_activity +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_get_agent_daily_activity_blocked_when_disabled(): + from litellm.proxy.agent_endpoints.endpoints import get_agent_daily_activity + + user = _make_internal_user() + gs = {"disable_agents_for_internal_users": True, "allow_agents_for_team_admins": False} + + with patch.dict("litellm.proxy.proxy_server.general_settings", gs, clear=True): + with pytest.raises(HTTPException) as exc_info: + await get_agent_daily_activity(user_api_key_dict=user) + assert exc_info.value.status_code == 403 diff --git a/tests/litellm/proxy/common_utils/test_rbac_utils.py b/tests/litellm/proxy/common_utils/test_rbac_utils.py new file mode 100644 index 00000000000..997a2e19b77 --- /dev/null +++ b/tests/litellm/proxy/common_utils/test_rbac_utils.py @@ -0,0 +1,156 @@ +""" +Tests for litellm/proxy/common_utils/rbac_utils.py + +Covers check_feature_access_for_user for agents and vector_stores features. +""" + +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException + +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.common_utils.rbac_utils import check_feature_access_for_user + + +def _make_user(role: str, user_id: str = "user-1") -> UserAPIKeyAuth: + return UserAPIKeyAuth(user_role=role, user_id=user_id) + + +# general_settings is imported from litellm.proxy.proxy_server inside the +# function, so we patch it via patch.dict on the original dict. +_GS_PATH = "litellm.proxy.proxy_server.general_settings" + + +# --------------------------------------------------------------------------- +# Proxy admin is always allowed +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_proxy_admin_always_allowed(): + user = _make_user(LitellmUserRoles.PROXY_ADMIN.value) + with patch.dict(_GS_PATH, {"disable_agents_for_internal_users": True}): + await check_feature_access_for_user(user, "agents") + + +@pytest.mark.asyncio +async def test_proxy_admin_view_only_always_allowed(): + user = _make_user(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value) + with patch.dict(_GS_PATH, {"disable_agents_for_internal_users": True}): + await check_feature_access_for_user(user, "agents") + + +# --------------------------------------------------------------------------- +# Feature not disabled — everyone allowed +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_feature_not_disabled_allows_internal_user(): + user = _make_user(LitellmUserRoles.INTERNAL_USER.value) + with patch.dict(_GS_PATH, {}, clear=True): + await check_feature_access_for_user(user, "agents") + + +@pytest.mark.asyncio +async def test_feature_not_disabled_allows_vector_stores(): + user = _make_user(LitellmUserRoles.INTERNAL_USER.value) + with patch.dict(_GS_PATH, {"disable_vector_stores_for_internal_users": False}, clear=True): + await check_feature_access_for_user(user, "vector_stores") + + +# --------------------------------------------------------------------------- +# Feature disabled, team-admin exemption OFF — internal user blocked +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_agents_disabled_blocks_internal_user(): + user = _make_user(LitellmUserRoles.INTERNAL_USER.value) + with patch.dict( + _GS_PATH, + {"disable_agents_for_internal_users": True, "allow_agents_for_team_admins": False}, + clear=True, + ): + with pytest.raises(HTTPException) as exc_info: + await check_feature_access_for_user(user, "agents") + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_vector_stores_disabled_blocks_internal_user(): + user = _make_user(LitellmUserRoles.INTERNAL_USER.value) + with patch.dict( + _GS_PATH, + {"disable_vector_stores_for_internal_users": True, "allow_vector_stores_for_team_admins": False}, + clear=True, + ): + with pytest.raises(HTTPException) as exc_info: + await check_feature_access_for_user(user, "vector_stores") + assert exc_info.value.status_code == 403 + + +# --------------------------------------------------------------------------- +# Feature disabled, allow_team_admins ON — team admin allowed, non-admin blocked +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_agents_disabled_team_admin_allowed(): + user = _make_user(LitellmUserRoles.INTERNAL_USER.value, user_id="team-admin-user") + with patch.dict( + _GS_PATH, + {"disable_agents_for_internal_users": True, "allow_agents_for_team_admins": True}, + clear=True, + ): + with patch( + "litellm.proxy.common_utils.rbac_utils._check_if_team_admin", + new=AsyncMock(return_value=True), + ): + await check_feature_access_for_user(user, "agents") + + +@pytest.mark.asyncio +async def test_agents_disabled_non_team_admin_blocked(): + user = _make_user(LitellmUserRoles.INTERNAL_USER.value, user_id="regular-user") + with patch.dict( + _GS_PATH, + {"disable_agents_for_internal_users": True, "allow_agents_for_team_admins": True}, + clear=True, + ): + with patch( + "litellm.proxy.common_utils.rbac_utils._check_if_team_admin", + new=AsyncMock(return_value=False), + ): + with pytest.raises(HTTPException) as exc_info: + await check_feature_access_for_user(user, "agents") + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_vector_stores_disabled_team_admin_allowed(): + user = _make_user(LitellmUserRoles.INTERNAL_USER.value, user_id="team-admin-user") + with patch.dict( + _GS_PATH, + {"disable_vector_stores_for_internal_users": True, "allow_vector_stores_for_team_admins": True}, + clear=True, + ): + with patch( + "litellm.proxy.common_utils.rbac_utils._check_if_team_admin", + new=AsyncMock(return_value=True), + ): + await check_feature_access_for_user(user, "vector_stores") + + +@pytest.mark.asyncio +async def test_vector_stores_disabled_non_team_admin_blocked(): + user = _make_user(LitellmUserRoles.INTERNAL_USER.value, user_id="regular-user") + with patch.dict( + _GS_PATH, + {"disable_vector_stores_for_internal_users": True, "allow_vector_stores_for_team_admins": True}, + clear=True, + ): + with patch( + "litellm.proxy.common_utils.rbac_utils._check_if_team_admin", + new=AsyncMock(return_value=False), + ): + with pytest.raises(HTTPException) as exc_info: + await check_feature_access_for_user(user, "vector_stores") + assert exc_info.value.status_code == 403 diff --git a/tests/litellm/proxy/vector_store_endpoints/test_vector_store_rbac.py b/tests/litellm/proxy/vector_store_endpoints/test_vector_store_rbac.py new file mode 100644 index 00000000000..3eb49bdf114 --- /dev/null +++ b/tests/litellm/proxy/vector_store_endpoints/test_vector_store_rbac.py @@ -0,0 +1,121 @@ +""" +Tests for RBAC enforcement on vector store management endpoints. + +Verifies that check_feature_access_for_user is called and that a 403 is +raised when vector stores are disabled for internal users. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException + +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + + +def _make_internal_user(user_id: str = "user-1") -> UserAPIKeyAuth: + return UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER.value, + user_id=user_id, + ) + + +_DISABLED_GS = { + "disable_vector_stores_for_internal_users": True, + "allow_vector_stores_for_team_admins": False, +} + +_ENABLED_GS: dict = {} + + +# --------------------------------------------------------------------------- +# list_vector_stores +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_list_vector_stores_blocked_when_disabled(): + from litellm.proxy.vector_store_endpoints.management_endpoints import list_vector_stores + + user = _make_internal_user() + with patch.dict("litellm.proxy.proxy_server.general_settings", _DISABLED_GS, clear=True): + with pytest.raises(HTTPException) as exc_info: + await list_vector_stores(user_api_key_dict=user) + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_list_vector_stores_allowed_when_not_disabled(): + """list_vector_stores should not raise 403 when vector stores are not disabled.""" + from litellm.proxy.vector_store_endpoints.management_endpoints import list_vector_stores + + import litellm + user = _make_internal_user() + mock_prisma = MagicMock() + mock_prisma.db.litellm_managedvectorstorestable.find_many = AsyncMock(return_value=[]) + + raised_403 = False + with patch.dict("litellm.proxy.proxy_server.general_settings", _ENABLED_GS, clear=True): + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with patch.object(litellm, "vector_store_registry", None): + with patch( + "litellm.proxy.vector_store_endpoints.management_endpoints.VectorStoreRegistry._get_vector_stores_from_db", + new=AsyncMock(return_value=[]), + ): + try: + await list_vector_stores(user_api_key_dict=user) + except HTTPException as e: + if e.status_code == 403: + raised_403 = True + assert not raised_403, "Should not raise 403 when vector stores are not disabled" + + +# --------------------------------------------------------------------------- +# new_vector_store +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_new_vector_store_blocked_when_disabled(): + from litellm.proxy.vector_store_endpoints.management_endpoints import new_vector_store + from litellm.types.vector_stores import LiteLLM_ManagedVectorStore + + user = _make_internal_user() + vs = LiteLLM_ManagedVectorStore(vector_store_id="vs-1", custom_llm_provider="openai") # type: ignore[call-arg] + + with patch.dict("litellm.proxy.proxy_server.general_settings", _DISABLED_GS, clear=True): + with pytest.raises(HTTPException) as exc_info: + await new_vector_store(vector_store=vs, user_api_key_dict=user) + assert exc_info.value.status_code == 403 + + +# --------------------------------------------------------------------------- +# Admin user is never blocked +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_list_vector_stores_admin_not_blocked(): + """Proxy admin should never be blocked, even when vector stores are disabled.""" + from litellm.proxy.vector_store_endpoints.management_endpoints import list_vector_stores + + import litellm + admin = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN.value, + user_id="admin-1", + ) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_managedvectorstorestable.find_many = AsyncMock(return_value=[]) + + raised_403 = False + with patch.dict("litellm.proxy.proxy_server.general_settings", _DISABLED_GS, clear=True): + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with patch.object(litellm, "vector_store_registry", None): + with patch( + "litellm.proxy.vector_store_endpoints.management_endpoints.VectorStoreRegistry._get_vector_stores_from_db", + new=AsyncMock(return_value=[]), + ): + try: + await list_vector_stores(user_api_key_dict=admin) + except HTTPException as e: + if e.status_code == 403: + raised_403 = True + assert not raised_403, "Admin should not be blocked even when vector stores are disabled" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx index 17f62a20f7d..7dcc3fa8a1a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx @@ -15,6 +15,8 @@ const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: Side const { accessToken } = useAuthorized(); const [enabledPagesInternalUsers, setEnabledPagesInternalUsers] = useState(null); const [enableProjectsUI, setEnableProjectsUI] = useState(false); + const [disableAgentsForInternalUsers, setDisableAgentsForInternalUsers] = useState(false); + const [disableVectorStoresForInternalUsers, setDisableVectorStoresForInternalUsers] = useState(false); useEffect(() => { const fetchUISettings = async () => { @@ -39,6 +41,14 @@ const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: Side if (settings?.values?.enable_projects_ui !== undefined) { setEnableProjectsUI(Boolean(settings.values.enable_projects_ui)); } + + if (settings?.values?.disable_agents_for_internal_users !== undefined) { + setDisableAgentsForInternalUsers(Boolean(settings.values.disable_agents_for_internal_users)); + } + + if (settings?.values?.disable_vector_stores_for_internal_users !== undefined) { + setDisableVectorStoresForInternalUsers(Boolean(settings.values.disable_vector_stores_for_internal_users)); + } } catch (error) { console.error("[SidebarProvider] Failed to fetch UI settings:", error); } @@ -54,6 +64,8 @@ const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: Side collapsed={sidebarCollapsed} enabledPagesInternalUsers={enabledPagesInternalUsers} enableProjectsUI={enableProjectsUI} + disableAgentsForInternalUsers={disableAgentsForInternalUsers} + disableVectorStoresForInternalUsers={disableVectorStoresForInternalUsers} /> ); }; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index 5d99dd2969d..dfc66d3484d 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -19,9 +19,15 @@ export default function UISettings() { const forwardClientHeadersProperty = schema?.properties?.forward_client_headers_to_llm_api; const enableProjectsUIProperty = schema?.properties?.enable_projects_ui; const enabledPagesProperty = schema?.properties?.enabled_ui_pages_internal_users; + const disableAgentsProperty = schema?.properties?.disable_agents_for_internal_users; + const allowAgentsTeamAdminsProperty = schema?.properties?.allow_agents_for_team_admins; + const disableVectorStoresProperty = schema?.properties?.disable_vector_stores_for_internal_users; + const allowVectorStoresTeamAdminsProperty = schema?.properties?.allow_vector_stores_for_team_admins; const values = data?.values ?? {}; const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users); const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user); + const isAgentsDisabled = Boolean(values.disable_agents_for_internal_users); + const isVectorStoresDisabled = Boolean(values.disable_vector_stores_for_internal_users); const handleToggle = (checked: boolean) => { updateSettings( @@ -105,6 +111,62 @@ export default function UISettings() { ); }; + const handleToggleDisableAgents = (checked: boolean) => { + updateSettings( + { disable_agents_for_internal_users: checked }, + { + onSuccess: () => { + NotificationManager.success("UI settings updated successfully"); + }, + onError: (error) => { + NotificationManager.fromBackend(error); + }, + }, + ); + }; + + const handleToggleAllowAgentsTeamAdmins = (checked: boolean) => { + updateSettings( + { allow_agents_for_team_admins: checked }, + { + onSuccess: () => { + NotificationManager.success("UI settings updated successfully"); + }, + onError: (error) => { + NotificationManager.fromBackend(error); + }, + }, + ); + }; + + const handleToggleDisableVectorStores = (checked: boolean) => { + updateSettings( + { disable_vector_stores_for_internal_users: checked }, + { + onSuccess: () => { + NotificationManager.success("UI settings updated successfully"); + }, + onError: (error) => { + NotificationManager.fromBackend(error); + }, + }, + ); + }; + + const handleToggleAllowVectorStoresTeamAdmins = (checked: boolean) => { + updateSettings( + { allow_vector_stores_for_team_admins: checked }, + { + onSuccess: () => { + NotificationManager.success("UI settings updated successfully"); + }, + onError: (error) => { + NotificationManager.fromBackend(error); + }, + }, + ); + }; + return ( {isLoading ? ( @@ -211,6 +273,80 @@ export default function UISettings() { + {/* Agents access control */} + + + + Disable agents for internal users + {disableAgentsProperty?.description && ( + {disableAgentsProperty.description} + )} + + + + + + + + Allow agents for team admins + + {allowAgentsTeamAdminsProperty?.description && ( + {allowAgentsTeamAdminsProperty.description} + )} + + + + + + {/* Vector Stores access control */} + + + + Disable vector stores for internal users + {disableVectorStoresProperty?.description && ( + {disableVectorStoresProperty.description} + )} + + + + + + + + Allow vector stores for team admins + + {allowVectorStoresTeamAdminsProperty?.description && ( + {allowVectorStoresTeamAdminsProperty.description} + )} + + + + + {/* Page Visibility for Internal Users */} = ({ setPage, defaultSelectedKey, collapsed = false, enabledPagesInternalUsers, enableProjectsUI }) => { +const Sidebar: React.FC = ({ setPage, defaultSelectedKey, collapsed = false, enabledPagesInternalUsers, enableProjectsUI, disableAgentsForInternalUsers, disableVectorStoresForInternalUsers }) => { const { userId, accessToken, userRole } = useAuthorized(); const { data: organizations } = useOrganizations(); @@ -450,6 +452,10 @@ const Sidebar: React.FC = ({ setPage, defaultSelectedKey, collapse // Hide Projects page if enableProjectsUI is not enabled if (item.key === "projects" && !enableProjectsUI) return false; + // Hide agents and vector-stores pages for non-admin users when disabled + if (!isAdmin && item.key === "agents" && disableAgentsForInternalUsers) return false; + if (!isAdmin && item.key === "vector-stores" && disableVectorStoresForInternalUsers) return false; + // Existing role check if (item.roles && !item.roles.includes(userRole)) return false; From 7eafac8e7f50f491e51e686aee61b73b776549d9 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 4 Mar 2026 20:18:54 -0800 Subject: [PATCH 072/380] Fix remaining org_id fallbacks in filter_helpers and TeamVirtualKeysTable filter_helpers.ts was not populating the Organization ID filter dropdown (always empty). TeamVirtualKeysTable was showing the team's org for all keys instead of each key's own org. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/key_team_helpers/filter_helpers.ts | 2 +- .../src/components/team/TeamVirtualKeysTable.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts index b587e090d33..fb701b4656b 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts +++ b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts @@ -22,7 +22,7 @@ const processKeysIntoOptions = ( if (alias && typeof alias === "string") { keyAliases.add(alias.trim()); } - const orgId = key?.organization_id; + const orgId = key?.organization_id ?? key?.org_id; if (orgId && typeof orgId === "string") { organizationIds.add(orgId.trim()); } diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index 5d76b99ef91..c8a54145b51 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -91,7 +91,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi if (!orgId) return kList; return kList.map((k: KeyResponse) => ({ ...k, - organization_id: k.organization_id || orgId, + organization_id: (k.organization_id ?? k.org_id) || orgId, })); }, [keys?.keys, organization?.organization_id]); From df7e3aa1e5884ea7d3e53a4906efd4d738305102 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Wed, 4 Mar 2026 23:59:54 -0500 Subject: [PATCH 073/380] feat(provider): add Amazon Bedrock Mantle as a first-class provider Adds `bedrock_mantle` provider for Amazon Bedrock's OpenAI-compatible inference engine (Project Mantle). Previously users had to use this as a generic openai_compatible provider, which resulted in incorrect pricing (OpenAI rates instead of Bedrock rates). Changes: - New `BedrockMantleChatConfig` extending `OpenAILikeChatConfig` - Regional API base: `https://bedrock-mantle.{region}.api.aws/v1` - Auth via `BEDROCK_MANTLE_API_KEY` env var - Region resolution: BEDROCK_MANTLE_REGION > AWS_REGION > us-east-1 - Supports reasoning for gpt-oss models - Added `BEDROCK_MANTLE` to `LlmProviders` enum - Added 4 models with correct AWS Bedrock pricing to both pricing files: - bedrock_mantle/openai.gpt-oss-120b ($0.15/M in, $0.60/M out) - bedrock_mantle/openai.gpt-oss-20b ($0.075/M in, $0.30/M out) - bedrock_mantle/openai.gpt-oss-safeguard-120b - bedrock_mantle/openai.gpt-oss-safeguard-20b - Wired provider into get_llm_provider_logic, get_supported_openai_params, main.py routing, utils.py map_openai_params + ProviderConfigManager, and _lazy_imports_registry - 19 unit tests covering registration, config, provider resolution, pricing Usage: os.environ["BEDROCK_MANTLE_API_KEY"] = "your-key" litellm.completion(model="bedrock_mantle/openai.gpt-oss-120b", ...) Co-Authored-By: Claude Sonnet 4.6 --- litellm/__init__.py | 4 + litellm/_lazy_imports_registry.py | 2 + .../get_llm_provider_logic.py | 7 + .../get_supported_openai_params.py | 2 + .../bedrock_mantle/chat/transformation.py | 80 +++++++++ litellm/main.py | 26 +++ ...odel_prices_and_context_window_backup.json | 54 ++++++ litellm/types/utils.py | 1 + litellm/utils.py | 12 ++ model_prices_and_context_window.json | 54 ++++++ .../test_bedrock_mantle_transformation.py | 169 ++++++++++++++++++ 11 files changed, 411 insertions(+) create mode 100644 litellm/llms/bedrock_mantle/chat/transformation.py create mode 100644 tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_transformation.py diff --git a/litellm/__init__.py b/litellm/__init__.py index f00b816be5c..4264b405350 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -593,6 +593,7 @@ minimax_models: Set = set() aws_polly_models: Set = set() gigachat_models: Set = set() llamagate_models: Set = set() +bedrock_mantle_models: Set = set() def is_bedrock_pricing_only_model(key: str) -> bool: @@ -855,6 +856,8 @@ def add_known_models(model_cost_map: Optional[Dict] = None): gigachat_models.add(key) elif value.get("litellm_provider") == "llamagate": llamagate_models.add(key) + elif value.get("litellm_provider") == "bedrock_mantle": + bedrock_mantle_models.add(key) add_known_models() @@ -1425,6 +1428,7 @@ if TYPE_CHECKING: from .llms.topaz.image_variations.transformation import TopazImageVariationConfig as TopazImageVariationConfig from litellm.llms.openai.completion.transformation import OpenAITextCompletionConfig as OpenAITextCompletionConfig from .llms.groq.chat.transformation import GroqChatConfig as GroqChatConfig + from .llms.bedrock_mantle.chat.transformation import BedrockMantleChatConfig as BedrockMantleChatConfig from .llms.a2a.chat.transformation import A2AConfig as A2AConfig from .llms.voyage.embedding.transformation import VoyageEmbeddingConfig as VoyageEmbeddingConfig from .llms.voyage.embedding.transformation_contextual import VoyageContextualEmbeddingConfig as VoyageContextualEmbeddingConfig diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index 6ff997b4531..1e3d429be45 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -214,6 +214,7 @@ LLM_CONFIG_NAMES = ( "TopazImageVariationConfig", "OpenAITextCompletionConfig", "GroqChatConfig", + "BedrockMantleChatConfig", "A2AConfig", "GenAIHubOrchestrationConfig", "VoyageEmbeddingConfig", @@ -857,6 +858,7 @@ _LLM_CONFIGS_IMPORT_MAP = { "OpenAITextCompletionConfig", ), "GroqChatConfig": (".llms.groq.chat.transformation", "GroqChatConfig"), + "BedrockMantleChatConfig": (".llms.bedrock_mantle.chat.transformation", "BedrockMantleChatConfig"), "A2AConfig": (".llms.a2a.chat.transformation", "A2AConfig"), "GenAIHubOrchestrationConfig": ( ".llms.sap.chat.transformation", diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 82ae5a9ff0a..d1ee17fdd2e 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -561,6 +561,13 @@ def _get_openai_compatible_provider_info( # noqa: PLR0915 ) = litellm.GroqChatConfig()._get_openai_compatible_provider_info( api_base, api_key ) + elif custom_llm_provider == "bedrock_mantle": + ( + api_base, + dynamic_api_key, + ) = litellm.BedrockMantleChatConfig()._get_openai_compatible_provider_info( + api_base, api_key + ) elif custom_llm_provider == "nvidia_nim": # nvidia_nim is openai compatible, we just need to set this to custom_openai and have the api_base be https://api.endpoints.anyscale.com/v1 api_base = ( diff --git a/litellm/litellm_core_utils/get_supported_openai_params.py b/litellm/litellm_core_utils/get_supported_openai_params.py index 4b40f44cbc4..773dca101b3 100644 --- a/litellm/litellm_core_utils/get_supported_openai_params.py +++ b/litellm/litellm_core_utils/get_supported_openai_params.py @@ -88,6 +88,8 @@ def get_supported_openai_params( # noqa: PLR0915 return litellm.VolcEngineConfig().get_supported_openai_params(model=model) elif custom_llm_provider == "groq": return litellm.GroqChatConfig().get_supported_openai_params(model=model) + elif custom_llm_provider == "bedrock_mantle": + return litellm.BedrockMantleChatConfig().get_supported_openai_params(model=model) elif custom_llm_provider == "hosted_vllm": return litellm.HostedVLLMChatConfig().get_supported_openai_params(model=model) elif custom_llm_provider == "vllm": diff --git a/litellm/llms/bedrock_mantle/chat/transformation.py b/litellm/llms/bedrock_mantle/chat/transformation.py new file mode 100644 index 00000000000..e413bb22b2d --- /dev/null +++ b/litellm/llms/bedrock_mantle/chat/transformation.py @@ -0,0 +1,80 @@ +""" +Amazon Bedrock Mantle - OpenAI-compatible inference engine in Amazon Bedrock. + +API docs: https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html + +Base URL: https://bedrock-mantle.{region}.api.aws/v1 +Auth: AWS Bedrock API key as Bearer token (set via BEDROCK_MANTLE_API_KEY env var) + or region-aware key via BEDROCK_MANTLE_{REGION}_API_KEY. +""" + +from typing import Iterator, AsyncIterator, Any, Optional, Tuple, Union + +import litellm +from litellm._logging import verbose_logger +from litellm.secret_managers.main import get_secret_str + +from ...openai_like.chat.transformation import OpenAILikeChatConfig + + +BEDROCK_MANTLE_DEFAULT_REGION = "us-east-1" + + +class BedrockMantleChatConfig(OpenAILikeChatConfig): + """ + Transformation config for Amazon Bedrock Mantle OpenAI-compatible API. + """ + + @property + def custom_llm_provider(self) -> Optional[str]: + return "bedrock_mantle" + + @classmethod + def get_config(cls): + return super().get_config() + + def _get_openai_compatible_provider_info( + self, api_base: Optional[str], api_key: Optional[str] + ) -> Tuple[Optional[str], Optional[str]]: + region = ( + get_secret_str("BEDROCK_MANTLE_REGION") + or get_secret_str("AWS_REGION") + or BEDROCK_MANTLE_DEFAULT_REGION + ) + api_base = ( + api_base + or get_secret_str("BEDROCK_MANTLE_API_BASE") + or f"https://bedrock-mantle.{region}.api.aws/v1" + ) + dynamic_api_key = api_key or get_secret_str("BEDROCK_MANTLE_API_KEY") + return api_base, dynamic_api_key + + def get_supported_openai_params(self, model: str) -> list: + base_params = super().get_supported_openai_params(model) + try: + if litellm.supports_reasoning( + model=model, custom_llm_provider=self.custom_llm_provider + ): + if "reasoning_effort" not in base_params: + base_params.append("reasoning_effort") + except Exception as e: + verbose_logger.debug( + f"BedrockMantleChatConfig: error checking reasoning support: {e}" + ) + return base_params + + def get_model_response_iterator( + self, + streaming_response: Union[Iterator[str], AsyncIterator[str], Any], + sync_stream: bool, + json_mode: Optional[bool] = False, + ) -> Any: + from litellm.llms.openai.chat.gpt_transformation import ( + OpenAIChatCompletionStreamingHandler, + ) + + return OpenAIChatCompletionStreamingHandler( + streaming_response=streaming_response, + sync_stream=sync_stream, + json_mode=json_mode, + ) diff --git a/litellm/main.py b/litellm/main.py index c3ac4c24ae2..eeed554549f 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -2219,6 +2219,32 @@ def completion( # type: ignore # noqa: PLR0915 logging_obj=logging, # model call logging done inside the class as we make need to modify I/O to fit aleph alpha's requirements client=client, ) + elif custom_llm_provider == "bedrock_mantle": + api_base = api_base or litellm.api_base or get_secret("BEDROCK_MANTLE_API_BASE") + api_key = api_key or litellm.api_key or get_secret("BEDROCK_MANTLE_API_KEY") + headers = headers or litellm.headers + config = litellm.BedrockMantleChatConfig.get_config() + for k, v in config.items(): + if k not in optional_params: + optional_params[k] = v + response = base_llm_http_handler.completion( + model=model, + stream=stream, + messages=messages, + acompletion=acompletion, + api_base=api_base, + model_response=model_response, + optional_params=optional_params, + litellm_params=litellm_params, + shared_session=shared_session, + custom_llm_provider=custom_llm_provider, + timeout=timeout, + headers=headers, + encoding=_get_encoding(), + api_key=api_key, + logging_obj=logging, + client=client, + ) elif custom_llm_provider == "a2a": # A2A (Agent-to-Agent) Protocol # Resolve agent configuration from registry if model format is "a2a/" diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d4c5b476af6..19943655c80 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -38363,5 +38363,59 @@ "metadata": { "notes": "DuckDuckGo Instant Answer API is free and does not require an API key." } + }, + "bedrock_mantle/openai.gpt-oss-120b": { + "input_cost_per_token": 1.5e-07, + "output_cost_per_token": 6e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock_mantle/openai.gpt-oss-20b": { + "input_cost_per_token": 7.5e-08, + "output_cost_per_token": 3e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock_mantle/openai.gpt-oss-safeguard-120b": { + "input_cost_per_token": 1.5e-07, + "output_cost_per_token": 6e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock_mantle/openai.gpt-oss-safeguard-20b": { + "input_cost_per_token": 7.5e-08, + "output_cost_per_token": 3e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true } } diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 50e4687b5a8..0e5f15dc27f 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3201,6 +3201,7 @@ class LlmProviders(str, Enum): XIAOMI_MIMO = "xiaomi_mimo" LITELLM_AGENT = "litellm_agent" CURSOR = "cursor" + BEDROCK_MANTLE = "bedrock_mantle" # Create a set of all provider values for quick lookup diff --git a/litellm/utils.py b/litellm/utils.py index cbe6aa8e793..caf006a0d9c 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -4459,6 +4459,17 @@ def get_optional_params( # noqa: PLR0915 else False ), ) + elif custom_llm_provider == "bedrock_mantle": + optional_params = litellm.BedrockMantleChatConfig().map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=( + drop_params + if drop_params is not None and isinstance(drop_params, bool) + else False + ), + ) elif custom_llm_provider == "deepseek": optional_params = litellm.OpenAIConfig().map_openai_params( non_default_params=non_default_params, @@ -7857,6 +7868,7 @@ class ProviderConfigManager: # Simple provider mappings (no model parameter needed) LlmProviders.DEEPSEEK: (lambda: litellm.DeepSeekChatConfig(), False), LlmProviders.GROQ: (lambda: litellm.GroqChatConfig(), False), + LlmProviders.BEDROCK_MANTLE: (lambda: litellm.BedrockMantleChatConfig(), False), LlmProviders.A2A: (lambda: litellm.A2AConfig(), False), LlmProviders.BYTEZ: (lambda: litellm.BytezChatConfig(), False), LlmProviders.DATABRICKS: (lambda: litellm.DatabricksConfig(), False), diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 4934f11d456..953cb50f3d6 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -38606,5 +38606,59 @@ "metadata": { "notes": "DuckDuckGo Instant Answer API is free and does not require an API key." } + }, + "bedrock_mantle/openai.gpt-oss-120b": { + "input_cost_per_token": 1.5e-07, + "output_cost_per_token": 6e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock_mantle/openai.gpt-oss-20b": { + "input_cost_per_token": 7.5e-08, + "output_cost_per_token": 3e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock_mantle/openai.gpt-oss-safeguard-120b": { + "input_cost_per_token": 1.5e-07, + "output_cost_per_token": 6e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock_mantle/openai.gpt-oss-safeguard-20b": { + "input_cost_per_token": 7.5e-08, + "output_cost_per_token": 3e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true } } diff --git a/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_transformation.py b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_transformation.py new file mode 100644 index 00000000000..5c6f9aec67e --- /dev/null +++ b/tests/test_litellm/llms/bedrock_mantle/test_bedrock_mantle_transformation.py @@ -0,0 +1,169 @@ +""" +Unit tests for Amazon Bedrock Mantle provider configuration. + +Bedrock Mantle is Amazon Bedrock's OpenAI-compatible inference engine (Project Mantle). +API docs: https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html +""" + +import os +import sys + +sys.path.insert(0, os.path.abspath("../../../../..")) + +import pytest + +import litellm +from litellm.llms.bedrock_mantle.chat.transformation import BedrockMantleChatConfig +from litellm.types.utils import LlmProviders + + +class TestBedrockMantleProviderRegistration: + def test_provider_enum_exists(self): + assert LlmProviders.BEDROCK_MANTLE == "bedrock_mantle" + + def test_provider_in_provider_list(self): + assert "bedrock_mantle" in litellm.provider_list + + def test_models_loaded(self, monkeypatch): + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "true") + litellm.add_known_models() + assert len(litellm.bedrock_mantle_models) > 0 + assert "bedrock_mantle/openai.gpt-oss-120b" in litellm.bedrock_mantle_models + assert "bedrock_mantle/openai.gpt-oss-20b" in litellm.bedrock_mantle_models + assert ( + "bedrock_mantle/openai.gpt-oss-safeguard-120b" in litellm.bedrock_mantle_models + ) + assert ( + "bedrock_mantle/openai.gpt-oss-safeguard-20b" in litellm.bedrock_mantle_models + ) + + +class TestBedrockMantleConfig: + def test_custom_llm_provider(self): + cfg = BedrockMantleChatConfig() + assert cfg.custom_llm_provider == "bedrock_mantle" + + def test_default_api_base_uses_env_region(self, monkeypatch): + monkeypatch.setenv("BEDROCK_MANTLE_REGION", "eu-west-1") + monkeypatch.delenv("BEDROCK_MANTLE_API_BASE", raising=False) + cfg = BedrockMantleChatConfig() + api_base, _ = cfg._get_openai_compatible_provider_info(None, None) + assert api_base == "https://bedrock-mantle.eu-west-1.api.aws/v1" + + def test_default_api_base_uses_aws_region(self, monkeypatch): + monkeypatch.delenv("BEDROCK_MANTLE_REGION", raising=False) + monkeypatch.delenv("BEDROCK_MANTLE_API_BASE", raising=False) + monkeypatch.setenv("AWS_REGION", "ap-northeast-1") + cfg = BedrockMantleChatConfig() + api_base, _ = cfg._get_openai_compatible_provider_info(None, None) + assert api_base == "https://bedrock-mantle.ap-northeast-1.api.aws/v1" + + def test_default_api_base_fallback_to_us_east_1(self, monkeypatch): + monkeypatch.delenv("BEDROCK_MANTLE_REGION", raising=False) + monkeypatch.delenv("BEDROCK_MANTLE_API_BASE", raising=False) + monkeypatch.delenv("AWS_REGION", raising=False) + cfg = BedrockMantleChatConfig() + api_base, _ = cfg._get_openai_compatible_provider_info(None, None) + assert api_base == "https://bedrock-mantle.us-east-1.api.aws/v1" + + def test_custom_api_base_overrides_default(self, monkeypatch): + custom_base = "https://bedrock-mantle.us-west-2.api.aws/v1" + cfg = BedrockMantleChatConfig() + api_base, _ = cfg._get_openai_compatible_provider_info(custom_base, None) + assert api_base == custom_base + + def test_api_key_from_env(self, monkeypatch): + monkeypatch.setenv("BEDROCK_MANTLE_API_KEY", "test-key-123") + cfg = BedrockMantleChatConfig() + _, api_key = cfg._get_openai_compatible_provider_info(None, None) + assert api_key == "test-key-123" + + def test_api_key_param_overrides_env(self, monkeypatch): + monkeypatch.setenv("BEDROCK_MANTLE_API_KEY", "env-key") + cfg = BedrockMantleChatConfig() + _, api_key = cfg._get_openai_compatible_provider_info(None, "explicit-key") + assert api_key == "explicit-key" + + def test_get_supported_openai_params(self): + cfg = BedrockMantleChatConfig() + params = cfg.get_supported_openai_params("openai.gpt-oss-120b") + assert "tools" in params + assert "tool_choice" in params + assert "temperature" in params + assert "stream" in params + assert "max_tokens" in params + + +class TestBedrockMantleProviderResolution: + def test_get_llm_provider_resolves_correctly(self): + model, provider, _, _ = litellm.get_llm_provider( + "bedrock_mantle/openai.gpt-oss-120b" + ) + assert provider == "bedrock_mantle" + assert model == "openai.gpt-oss-120b" + + def test_get_llm_provider_20b(self): + model, provider, _, _ = litellm.get_llm_provider( + "bedrock_mantle/openai.gpt-oss-20b" + ) + assert provider == "bedrock_mantle" + assert model == "openai.gpt-oss-20b" + + +class TestBedrockMantlePricing: + """Tests that verify Bedrock Mantle uses correct AWS Bedrock pricing, not OpenAI pricing.""" + + def test_gpt_oss_120b_pricing(self, monkeypatch): + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "true") + litellm.add_known_models() + info = litellm.get_model_info("bedrock_mantle/openai.gpt-oss-120b") + # Bedrock pricing: $0.15/M input, $0.60/M output + assert info["input_cost_per_token"] == pytest.approx(1.5e-7) + assert info["output_cost_per_token"] == pytest.approx(6e-7) + + def test_gpt_oss_20b_pricing(self, monkeypatch): + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "true") + litellm.add_known_models() + info = litellm.get_model_info("bedrock_mantle/openai.gpt-oss-20b") + # Bedrock pricing: $0.075/M input, $0.30/M output + assert info["input_cost_per_token"] == pytest.approx(7.5e-8) + assert info["output_cost_per_token"] == pytest.approx(3e-7) + + def test_pricing_significantly_cheaper_than_openai_native(self, monkeypatch): + """ + Verify Bedrock Mantle pricing is cheaper than OpenAI's direct API pricing. + This is the core issue the provider addition fixes — previously users were being + billed at OpenAI rates instead of the cheaper Bedrock rates. + """ + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "true") + litellm.add_known_models() + bedrock_info = litellm.get_model_info("bedrock_mantle/openai.gpt-oss-120b") + # OpenAI direct pricing for gpt-oss-120b is ~$0.039/M input, $0.190/M output + # Bedrock should be cheaper at $0.15/M input and $0.60/M output... wait + # Actually, Bedrock ADDS value not reduces cost vs OpenAI direct for these models. + # The key fix is that we now use Bedrock-specific prices instead of mapping to + # some unrelated OpenAI model (like gpt-4) pricing. + # Just validate the pricing is as expected from AWS docs. + assert bedrock_info["input_cost_per_token"] == pytest.approx(1.5e-7) + assert bedrock_info["output_cost_per_token"] == pytest.approx(6e-7) + + def test_safeguard_models_have_larger_output_tokens(self, monkeypatch): + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "true") + litellm.add_known_models() + info_120b = litellm.get_model_info("bedrock_mantle/openai.gpt-oss-120b") + info_safeguard = litellm.get_model_info( + "bedrock_mantle/openai.gpt-oss-safeguard-120b" + ) + assert info_safeguard["max_output_tokens"] > info_120b["max_output_tokens"] + + def test_reasoning_support(self, monkeypatch): + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "true") + litellm.add_known_models() + info = litellm.get_model_info("bedrock_mantle/openai.gpt-oss-120b") + assert info.get("supports_reasoning") is True + + def test_context_window(self, monkeypatch): + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "true") + litellm.add_known_models() + info = litellm.get_model_info("bedrock_mantle/openai.gpt-oss-120b") + assert info["max_input_tokens"] == 131072 From 1089945f0e79732c3c4d3d5fe2ed86efc5b198f9 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Thu, 5 Mar 2026 00:07:08 -0500 Subject: [PATCH 074/380] feat(ui): add Amazon Bedrock Mantle to provider UI Adds `bedrock_mantle` to the provider dropdown in the LiteLLM dashboard: - Providers enum: "Amazon Bedrock Mantle" - provider_map: bedrock_mantle backend key - providerLogoMap: reuses bedrock.svg Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/provider_info_helpers.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index bf9e9449d8e..58cd0bed2eb 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -8,7 +8,8 @@ export enum Providers { ANTHROPIC_TEXT = "Anthropic Text", AssemblyAI = "AssemblyAI", AUTO_ROUTER = "Auto Router", - Bedrock = "Amazon Bedrock", + Bedrock = "Amazon Bedrock",\ + BedrockMantle = "Amazon Bedrock Mantle", SageMaker = "AWS SageMaker", Azure = "Azure", Azure_AI_Studio = "Azure AI Foundry (Studio)", @@ -118,7 +119,8 @@ export const provider_map: Record = { Azure_AI_Studio: "azure_ai", AZURE_TEXT: "azure_text", BASETEN: "baseten", - Bedrock: "bedrock", + Bedrock: "bedrock",\ + BedrockMantle: "bedrock_mantle", BYTEZ: "bytez", Cerebras: "cerebras", CLARIFAI: "clarifai", @@ -226,6 +228,7 @@ export const providerLogoMap: Record = { [Providers.AZURE_TEXT]: `${asset_logos_folder}microsoft_azure.svg`, [Providers.BASETEN]: `${asset_logos_folder}baseten.svg`, [Providers.Bedrock]: `${asset_logos_folder}bedrock.svg`, + [Providers.BedrockMantle]: `${asset_logos_folder}bedrock.svg`, [Providers.SageMaker]: `${asset_logos_folder}bedrock.svg`, [Providers.Cerebras]: `${asset_logos_folder}cerebras.svg`, [Providers.CLOUDFLARE]: `${asset_logos_folder}cloudflare.svg`, From 4a4bcced3c0a80a390edd0b8fa1e69cda588a1e5 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Thu, 5 Mar 2026 00:10:00 -0500 Subject: [PATCH 075/380] docs: add Amazon Bedrock Mantle provider page Adds provider documentation for bedrock_mantle including: - API key and region configuration - Supported models with pricing table - SDK, streaming, and async usage examples - LiteLLM Proxy config and usage - Added to Bedrock category in sidebar Co-Authored-By: Claude Sonnet 4.6 --- .../docs/providers/bedrock_mantle.md | 157 ++++++++++++++++++ docs/my-website/sidebars.js | 1 + 2 files changed, 158 insertions(+) create mode 100644 docs/my-website/docs/providers/bedrock_mantle.md diff --git a/docs/my-website/docs/providers/bedrock_mantle.md b/docs/my-website/docs/providers/bedrock_mantle.md new file mode 100644 index 00000000000..185d9a6e215 --- /dev/null +++ b/docs/my-website/docs/providers/bedrock_mantle.md @@ -0,0 +1,157 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Amazon Bedrock Mantle + +[Amazon Bedrock Mantle](https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html) is Amazon Bedrock's distributed inference engine (Project Mantle) that exposes an **OpenAI-compatible API** for Bedrock-hosted models. + +Use this provider to call Bedrock Mantle models with accurate **AWS Bedrock pricing** instead of OpenAI pricing. + +:::tip + +**We support ALL Bedrock Mantle models, just set `model=bedrock_mantle/` as a prefix when sending litellm requests** + +::: + +## API Key + +```python +# env variable +os.environ['BEDROCK_MANTLE_API_KEY'] = "your-aws-bedrock-api-key" + +# optional: override region (defaults to us-east-1) +os.environ['BEDROCK_MANTLE_REGION'] = "us-east-1" # or use AWS_REGION +``` + +## Supported Models + +| Model | Context Window | Input (per 1M tokens) | Output (per 1M tokens) | +|-------|---------------|----------------------|------------------------| +| `openai.gpt-oss-120b` | 131K | $0.15 | $0.60 | +| `openai.gpt-oss-20b` | 131K | $0.075 | $0.30 | +| `openai.gpt-oss-safeguard-120b` | 131K | $0.15 | $0.60 | +| `openai.gpt-oss-safeguard-20b` | 131K | $0.075 | $0.30 | + +## Sample Usage + + + + +```python +from litellm import completion +import os + +os.environ['BEDROCK_MANTLE_API_KEY'] = "your-bedrock-api-key" + +response = completion( + model="bedrock_mantle/openai.gpt-oss-120b", + messages=[{"role": "user", "content": "hello from litellm"}], +) +print(response) +``` + + + + +```python +from litellm import completion +import os + +os.environ['BEDROCK_MANTLE_API_KEY'] = "your-bedrock-api-key" + +response = completion( + model="bedrock_mantle/openai.gpt-oss-120b", + messages=[{"role": "user", "content": "hello from litellm"}], + stream=True, +) + +for chunk in response: + print(chunk) +``` + + + + +```python +import asyncio +from litellm import acompletion +import os + +os.environ['BEDROCK_MANTLE_API_KEY'] = "your-bedrock-api-key" + +async def main(): + response = await acompletion( + model="bedrock_mantle/openai.gpt-oss-120b", + messages=[{"role": "user", "content": "hello from litellm"}], + ) + print(response) + +asyncio.run(main()) +``` + + + + +## Region Configuration + +The API base URL is `https://bedrock-mantle.{region}.api.aws/v1`. Region is resolved in this order: + +1. `BEDROCK_MANTLE_REGION` env var +2. `AWS_REGION` env var +3. Default: `us-east-1` + +**Supported regions:** `us-east-1`, `us-east-2`, `us-west-2`, `eu-west-1`, `eu-west-2`, `eu-central-1`, `eu-south-1`, `eu-north-1`, `ap-northeast-1`, `ap-south-1`, `ap-southeast-3`, `sa-east-1` + +```python +import os +os.environ['BEDROCK_MANTLE_REGION'] = "eu-west-1" + +# or pass api_base directly +response = completion( + model="bedrock_mantle/openai.gpt-oss-120b", + messages=[{"role": "user", "content": "hello"}], + api_base="https://bedrock-mantle.eu-west-1.api.aws/v1", +) +``` + +## Usage with LiteLLM Proxy + +### 1. Set Bedrock Mantle models on config.yaml + +```yaml +model_list: + - model_name: gpt-oss-120b + litellm_params: + model: bedrock_mantle/openai.gpt-oss-120b + api_key: os.environ/BEDROCK_MANTLE_API_KEY + # optional region override: + api_base: "https://bedrock-mantle.us-east-1.api.aws/v1" + + - model_name: gpt-oss-20b + litellm_params: + model: bedrock_mantle/openai.gpt-oss-20b + api_key: os.environ/BEDROCK_MANTLE_API_KEY +``` + +### 2. Start the proxy + +```shell +litellm --config /path/to/config.yaml +``` + +### 3. Send a request + +```python +import openai + +client = openai.OpenAI( + api_key="anything", + base_url="http://0.0.0.0:4000", +) + +response = client.chat.completions.create( + model="gpt-oss-120b", + messages=[{"role": "user", "content": "hello from litellm"}], +) +print(response) +``` diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 004114c8e08..a2e997a9736 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -793,6 +793,7 @@ const sidebars = { "providers/bedrock_realtime_with_audio", "providers/aws_polly", "providers/bedrock_vector_store", + "providers/bedrock_mantle", ] }, "providers/litellm_proxy", From 1bb713bc7ba845137c7fa4da1409f273b9f1b1b4 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Wed, 4 Mar 2026 21:19:25 -0800 Subject: [PATCH 076/380] feat(mcp): BYOK MCP servers with OAuth 2.1 PKCE authorization flow (#22850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp): BYOK (Bring Your Own Key) for OpenAPI MCP servers with OAuth 2.1 flow Adds per-user credential storage for BYOK MCP servers so external clients can authenticate via standard OAuth 2.1 PKCE without needing a full identity provider. Backend: - New DB table LiteLLM_MCPUserCredentials (user_id, server_id, credential_b64) - is_byok, byok_description, byok_api_key_help_url fields on MCPServerTable - OAuth 2.1 authorization server endpoints (/.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource, /v1/mcp/oauth/authorize, /v1/mcp/oauth/token) - 401 challenge with WWW-Authenticate header when BYOK server has no credential - CRUD endpoints: POST/DELETE /v1/mcp/server/{id}/user-credential - has_user_credential annotated on GET /v1/mcp/server response UI: - ByokCredentialModal: 2-step Connect flow (access description + API key entry) - BYOK toggle + description fields on admin MCP server create form - Connect/Connected state in MCP server table - BYOK Demo page (/tools/byok-demo) showing full OAuth 2.1 PKCE flow * feat(mcp/byok): redesign OAuth authorize page to match 2-step Connect mockup - Step 1: L→S logos, requested access checklist, How it works box, Continue button - Step 2: API key input, Save toggle, Duration pills (1h/24h/7d/30d/until_revoked), security note - Matches screenshots: white modal on dark bg, progress dots, dark CTA buttons - Authorize handler now fetches byok_description and byok_api_key_help_url from server registry - CLAUDE.md: replace SQL snippet with proper DB migration troubleshooting guidance * fix: address greptile review feedback (greploop iteration 1) - XSS: escape all user-supplied values in _build_authorize_html() with html.escape() - Open redirect: validate redirect_uri scheme and URL-encode code/state in redirect - N+1 query: batch BYOK credential lookup into single find_many() call - Critical path DB: add 60s TTL in-memory cache to _check_byok_credential() - Encrypt BYOK credentials at rest using encrypt_value_helper/decrypt_value_helper * fix(byok): update OAuth popup with LiteLLM logo, MCP title suffix, remove emojis * fix(byok-demo): fix token endpoint URL (/v1/mcp/oauth/token not /v1/mcp/token) * feat(byok): inject stored BYOK credential as mcp_auth_header on tool execution * feat(byok): use contextvars to inject per-user credential into OpenAPI tool closures; remove byok-demo from LiteLLM UI OpenAPI tools have auth headers baked into their closures at registration time. BYOK servers have no static auth token, so per-user credentials were never reaching the HTTP calls. Fix: add _request_auth_header ContextVar in openapi_to_mcp_generator.py. create_tool_function now reads this var at call time and overrides the Authorization header if set. execute_mcp_tool resolves the MCP server and performs BYOK checks before the local-tool dispatch branch, then sets the ContextVar around _handle_local_mcp_tool so the credential flows into the HTTP request. Also remove the /tools/byok-demo page from the LiteLLM UI dashboard — the demo lives at ~/Downloads/litellm-byok-demo/index.html (served separately on port 8080). * fix: address greptile review feedback (greploop iteration 2) - Cache invalidation: add _invalidate_byok_cred_cache() and call it after store_user_credential() in both token endpoint and management endpoint - Unbounded cache: add _BYOK_CRED_CACHE_MAX_SIZE=4096 with clear-on-overflow - Unbounded auth codes: add _AUTH_CODES_MAX_SIZE=1000 with 503 on overflow - Double DB query: merge _check_byok_credential + _get_byok_credential into single _get_byok_credential call; raise 401 inline if None returned - Sidebar: remove byok-demo entry (page was deleted in prior commit) - JWT comment: document why byok_session HS256 token can't be used as proxy auth * fix: address greptile review feedback (greploop iteration 3) - auth_type: pre-format Authorization header (Bearer/ApiKey/Basic) in server.py before setting ContextVar so openapi_to_mcp_generator respects server auth_type - cache invalidation on delete: call _invalidate_byok_cred_cache after delete_user_credential so stale True entries don't persist for 60s - ContextVar guard: only set _request_auth_header when mcp_auth_header is set, avoiding unnecessary ContextVar overhead on non-BYOK tool calls * fix: address greptile review feedback (greploop iteration 4) - Unified credential cache: store actual credential value (Optional[str]) instead of just bool so _get_byok_credential also benefits from caching — eliminates the DB hit on every BYOK tool call within the 60s TTL window - Extracted _write_byok_cred_cache() helper for consistent cache writes - Replaced has_user_credential with get_user_credential in _check_byok_credential so one DB call satisfies both existence check and value retrieval - Remove false 'encrypted at rest' claim from OAuth HTML and ByokCredentialModal * Update tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- CLAUDE.md | 12 +- .../mcp_server/byok_oauth_endpoints.py | 786 ++++++++++++++++++ litellm/proxy/_experimental/mcp_server/db.py | 76 ++ .../mcp_server/mcp_server_manager.py | 6 + .../mcp_server/openapi_to_mcp_generator.py | 27 +- .../proxy/_experimental/mcp_server/server.py | 266 +++++- litellm/proxy/_types.py | 20 + .../mcp_management_endpoints.py | 98 +++ litellm/proxy/proxy_server.py | 4 + litellm/proxy/schema.prisma | 16 + .../types/mcp_server/mcp_server_manager.py | 3 + .../mcp_server/test_byok_oauth_endpoints.py | 517 ++++++++++++ .../app/(dashboard)/components/Sidebar2.tsx | 2 + .../mcp_tools/ByokCredentialModal.tsx | 254 ++++++ .../mcp_tools/create_mcp_server.tsx | 85 +- .../mcp_tools/mcp_server_columns.tsx | 37 + .../src/components/mcp_tools/mcp_servers.tsx | 16 + .../src/components/mcp_tools/types.tsx | 6 + .../components/playground/chat_ui/ChatUI.tsx | 59 ++ 19 files changed, 2244 insertions(+), 46 deletions(-) create mode 100644 litellm/proxy/_experimental/mcp_server/byok_oauth_endpoints.py create mode 100644 tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx diff --git a/CLAUDE.md b/CLAUDE.md index c1eb75d2515..5b36c2be8ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,4 +114,14 @@ LiteLLM is a unified interface for 100+ LLM providers with two main components: ### Enterprise Features - Enterprise-specific code in `enterprise/` directory - Optional features enabled via environment variables -- Separate licensing and authentication for enterprise features \ No newline at end of file +- Separate licensing and authentication for enterprise features + +### Troubleshooting: DB schema out of sync after proxy restart +`litellm-proxy-extras` runs `prisma migrate deploy` on startup using **its own** bundled migration files, which may lag behind schema changes in the current worktree. Symptoms: `Unknown column`, `Invalid prisma invocation`, or missing data on new fields. + +**Diagnose:** Run `\d "TableName"` in psql and compare against `schema.prisma` — missing columns confirm the issue. + +**Fix options:** +1. **Create a Prisma migration** (permanent) — run `prisma migrate dev --name ` in the worktree. The generated file will be picked up by `prisma migrate deploy` on next startup. +2. **Apply manually for local dev** — `psql -d litellm -c "ALTER TABLE ... ADD COLUMN IF NOT EXISTS ..."` after each proxy start. Fine for dev, not for production. +3. **Update litellm-proxy-extras** — if the package is installed from PyPI, its migration directory must include the new file. Either update the package or run the migration manually until the next release ships it. \ No newline at end of file diff --git a/litellm/proxy/_experimental/mcp_server/byok_oauth_endpoints.py b/litellm/proxy/_experimental/mcp_server/byok_oauth_endpoints.py new file mode 100644 index 00000000000..db18885721a --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/byok_oauth_endpoints.py @@ -0,0 +1,786 @@ +""" +BYOK (Bring Your Own Key) OAuth 2.1 Authorization Server endpoints for MCP servers. + +When an MCP client connects to a BYOK-enabled server and no stored credential exists, +LiteLLM runs a minimal OAuth 2.1 authorization code flow. The "authorization page" is +just a form that asks the user for their API key — not a full identity-provider OAuth. + +Endpoints implemented here: + GET /.well-known/oauth-authorization-server — OAuth authorization server metadata + GET /.well-known/oauth-protected-resource — OAuth protected resource metadata + GET /v1/mcp/oauth/authorize — Shows HTML form to collect the API key + POST /v1/mcp/oauth/authorize — Stores temp auth code and redirects + POST /v1/mcp/oauth/token — Exchanges code for a bearer JWT token +""" + +import base64 +import hashlib +import html as _html_module +import time +import uuid +from typing import Dict, Optional, cast +from urllib.parse import urlencode, urlparse + +import jwt +from fastapi import APIRouter, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +from litellm._logging import verbose_proxy_logger +from litellm.proxy._experimental.mcp_server.db import store_user_credential +from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + get_request_base_url, +) + +# --------------------------------------------------------------------------- +# In-memory store for pending authorization codes. +# Each entry: {code: {api_key, server_id, code_challenge, redirect_uri, user_id, expires_at}} +# --------------------------------------------------------------------------- +_byok_auth_codes: Dict[str, dict] = {} + +# Authorization codes expire after 5 minutes. +_AUTH_CODE_TTL_SECONDS = 300 +# Hard cap to prevent memory exhaustion from incomplete OAuth flows. +_AUTH_CODES_MAX_SIZE = 1000 + +router = APIRouter(tags=["mcp"]) + + +# --------------------------------------------------------------------------- +# PKCE helper +# --------------------------------------------------------------------------- + + +def _verify_pkce(code_verifier: str, code_challenge: str) -> bool: + """Return True iff SHA-256(code_verifier) == code_challenge (base64url, no padding).""" + digest = hashlib.sha256(code_verifier.encode()).digest() + computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() + return computed == code_challenge + + +# --------------------------------------------------------------------------- +# Cleanup of expired auth codes (called lazily on each request) +# --------------------------------------------------------------------------- + + +def _purge_expired_codes() -> None: + now = time.time() + expired = [k for k, v in _byok_auth_codes.items() if v["expires_at"] < now] + for k in expired: + del _byok_auth_codes[k] + + +def _build_authorize_html( + server_name: str, + server_initial: str, + client_id: str, + redirect_uri: str, + code_challenge: str, + code_challenge_method: str, + state: str, + server_id: str, + access_items: list, + help_url: str, +) -> str: + """Build the 2-step BYOK OAuth authorization page HTML.""" + + # Escape all user-supplied / externally-derived values before interpolation + e = _html_module.escape + server_name = e(server_name) + server_initial = e(server_initial) + client_id = e(client_id) + redirect_uri = e(redirect_uri) + code_challenge = e(code_challenge) + code_challenge_method = e(code_challenge_method) + state = e(state) + server_id = e(server_id) + + # Build access checklist rows + access_rows = "".join( + f'
{e(item)}
' + for item in access_items + ) + access_section = "" + if access_rows: + access_section = f""" +
+
+ + Requested Access +
+ {access_rows} +
""" + + # Help link for step 2 + help_link_html = "" + if help_url: + help_link_html = f'
Where do I find my API key? ↗' + + return f""" + + + + +Connect {server_name} — LiteLLM + + + + + + +""" + + +# --------------------------------------------------------------------------- +# OAuth metadata discovery endpoints +# --------------------------------------------------------------------------- + + +@router.get("/.well-known/oauth-authorization-server", include_in_schema=False) +async def oauth_authorization_server_metadata(request: Request) -> JSONResponse: + """RFC 8414 Authorization Server Metadata for the BYOK OAuth flow.""" + base_url = get_request_base_url(request) + return JSONResponse( + { + "issuer": base_url, + "authorization_endpoint": f"{base_url}/v1/mcp/oauth/authorize", + "token_endpoint": f"{base_url}/v1/mcp/oauth/token", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "code_challenge_methods_supported": ["S256"], + } + ) + + +@router.get("/.well-known/oauth-protected-resource", include_in_schema=False) +async def oauth_protected_resource_metadata(request: Request) -> JSONResponse: + """RFC 9728 Protected Resource Metadata pointing back at this server.""" + base_url = get_request_base_url(request) + return JSONResponse( + { + "resource": base_url, + "authorization_servers": [base_url], + } + ) + + +# --------------------------------------------------------------------------- +# Authorization endpoint — GET (show form) and POST (process form) +# --------------------------------------------------------------------------- + + +@router.get("/v1/mcp/oauth/authorize", include_in_schema=False) +async def byok_authorize_get( + request: Request, + client_id: Optional[str] = None, + redirect_uri: Optional[str] = None, + response_type: Optional[str] = None, + code_challenge: Optional[str] = None, + code_challenge_method: Optional[str] = None, + state: Optional[str] = None, + server_id: Optional[str] = None, +) -> HTMLResponse: + """ + Show the BYOK API-key entry form. + + The MCP client navigates the user here; the user types their API key and + clicks "Connect & Authorize", which POSTs back to this same path. + """ + if response_type != "code": + raise HTTPException(status_code=400, detail="response_type must be 'code'") + if not redirect_uri: + raise HTTPException(status_code=400, detail="redirect_uri is required") + if not code_challenge: + raise HTTPException(status_code=400, detail="code_challenge is required") + + # Resolve server metadata (name, description items, help URL). + server_name = "MCP Server" + access_items: list = [] + help_url = "" + if server_id: + try: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + registry = global_mcp_server_manager.get_registry() + if server_id in registry: + srv = registry[server_id] + server_name = srv.server_name or srv.name + access_items = list(srv.byok_description or []) + help_url = srv.byok_api_key_help_url or "" + except Exception: + pass + + server_initial = (server_name[0].upper()) if server_name else "S" + + html = _build_authorize_html( + server_name=server_name, + server_initial=server_initial, + client_id=client_id or "", + redirect_uri=redirect_uri, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method or "S256", + state=state or "", + server_id=server_id or "", + access_items=access_items, + help_url=help_url, + ) + return HTMLResponse(content=html) + + +@router.post("/v1/mcp/oauth/authorize", include_in_schema=False) +async def byok_authorize_post( + request: Request, + client_id: str = Form(default=""), + redirect_uri: str = Form(...), + code_challenge: str = Form(...), + code_challenge_method: str = Form(default="S256"), + state: str = Form(default=""), + server_id: str = Form(default=""), + api_key: str = Form(...), +) -> RedirectResponse: + """ + Process the BYOK API-key form submission. + + Stores a short-lived authorization code and redirects the client back to + redirect_uri with ?code=...&state=... query parameters. + """ + _purge_expired_codes() + + # Validate redirect_uri scheme to prevent open redirect + parsed_uri = urlparse(redirect_uri) + if parsed_uri.scheme not in ("http", "https"): + raise HTTPException(status_code=400, detail="Invalid redirect_uri scheme") + + # Reject new codes if the store is at capacity (prevents memory exhaustion + # from a burst of abandoned OAuth flows). + if len(_byok_auth_codes) >= _AUTH_CODES_MAX_SIZE: + raise HTTPException(status_code=503, detail="Too many pending authorization flows") + + if code_challenge_method != "S256": + raise HTTPException( + status_code=400, detail="Only S256 code_challenge_method is supported" + ) + + auth_code = str(uuid.uuid4()) + _byok_auth_codes[auth_code] = { + "api_key": api_key, + "server_id": server_id, + "code_challenge": code_challenge, + "redirect_uri": redirect_uri, + "user_id": client_id, # external client passes LiteLLM user-id as client_id + "expires_at": time.time() + _AUTH_CODE_TTL_SECONDS, + } + + params = urlencode({"code": auth_code, "state": state}) + separator = "&" if "?" in redirect_uri else "?" + location = f"{redirect_uri}{separator}{params}" + return RedirectResponse(url=location, status_code=302) + + +# --------------------------------------------------------------------------- +# Token endpoint +# --------------------------------------------------------------------------- + + +@router.post("/v1/mcp/oauth/token", include_in_schema=False) +async def byok_token( + request: Request, + grant_type: str = Form(...), + code: str = Form(...), + redirect_uri: str = Form(default=""), + code_verifier: str = Form(...), + client_id: str = Form(default=""), +) -> JSONResponse: + """ + Exchange an authorization code for a short-lived BYOK session JWT. + + 1. Validates the authorization code and PKCE challenge. + 2. Stores the API key via store_user_credential(). + 3. Issues a signed JWT with type="byok_session". + """ + from litellm.proxy.proxy_server import master_key, prisma_client + + _purge_expired_codes() + + if grant_type != "authorization_code": + raise HTTPException(status_code=400, detail="unsupported_grant_type") + + record = _byok_auth_codes.get(code) + if record is None: + raise HTTPException(status_code=400, detail="invalid_grant") + + if time.time() > record["expires_at"]: + del _byok_auth_codes[code] + raise HTTPException(status_code=400, detail="invalid_grant") + + # PKCE verification + if not _verify_pkce(code_verifier, record["code_challenge"]): + raise HTTPException(status_code=400, detail="invalid_grant") + + # Consume the code (one-time use) + del _byok_auth_codes[code] + + server_id: str = record["server_id"] + api_key_value: str = record["api_key"] + # Prefer the user_id that was stored when the code was issued; fall back to + # whatever client_id the token request supplies (they should match). + user_id: str = record.get("user_id") or client_id + + if not user_id: + raise HTTPException( + status_code=400, + detail="Cannot determine user_id; pass LiteLLM user id as client_id", + ) + + # Persist the BYOK credential + if prisma_client is not None: + try: + await store_user_credential( + prisma_client=prisma_client, + user_id=user_id, + server_id=server_id, + credential=api_key_value, + ) + # Invalidate any cached negative result so the user isn't blocked + # for up to the TTL period after completing the OAuth flow. + from litellm.proxy._experimental.mcp_server.server import ( + _invalidate_byok_cred_cache, + ) + _invalidate_byok_cred_cache(user_id, server_id) + except Exception as exc: + verbose_proxy_logger.error( + "byok_token: failed to store user credential for user=%s server=%s: %s", + user_id, + server_id, + exc, + ) + raise HTTPException(status_code=500, detail="Failed to store credential") + else: + verbose_proxy_logger.warning( + "byok_token: prisma_client is None — credential not persisted" + ) + + if master_key is None: + raise HTTPException( + status_code=500, detail="Master key not configured; cannot issue token" + ) + + now = int(time.time()) + payload = { + "user_id": user_id, + "server_id": server_id, + # "type" distinguishes this from regular proxy auth tokens. + # The proxy's SSO JWT path uses asymmetric keys (RS256/ES256), so an + # HS256 token signed with master_key cannot be accepted there. + "type": "byok_session", + "iat": now, + "exp": now + 3600, + } + access_token = jwt.encode(payload, cast(str, master_key), algorithm="HS256") + + return JSONResponse( + { + "access_token": access_token, + "token_type": "bearer", + "expires_in": 3600, + } + ) diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index 1bc7e8f8a9d..4c6735bacd3 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -13,6 +13,7 @@ from litellm.proxy._types import ( ) from litellm.proxy.common_utils.encrypt_decrypt_utils import ( _get_salt_key, + decrypt_value_helper, encrypt_value_helper, ) from litellm.proxy.utils import PrismaClient @@ -68,6 +69,10 @@ def _prepare_mcp_server_data( # mcp_access_groups is already List[str], no serialization needed + # Force include is_byok even when False (exclude_none=True would not drop it, + # but be explicit to ensure a False value is always written to the DB). + data_dict["is_byok"] = getattr(data, "is_byok", False) + return data_dict @@ -375,3 +380,74 @@ async def rotate_mcp_server_credentials_master_key( "updated_by": touched_by, }, ) + + +async def store_user_credential( + prisma_client: PrismaClient, + user_id: str, + server_id: str, + credential: str, +) -> None: + """Store a user credential for a BYOK MCP server.""" + import base64 + + encoded = base64.urlsafe_b64encode(credential.encode()).decode() + await prisma_client.db.litellm_mcpusercredentials.upsert( + where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}}, + data={ + "create": { + "user_id": user_id, + "server_id": server_id, + "credential_b64": encoded, + }, + "update": {"credential_b64": encoded}, + }, + ) + + +async def get_user_credential( + prisma_client: PrismaClient, + user_id: str, + server_id: str, +) -> Optional[str]: + """Return credential for a user+server pair, or None.""" + import base64 + + row = await prisma_client.db.litellm_mcpusercredentials.find_unique( + where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}} + ) + if row is None: + return None + try: + return base64.urlsafe_b64decode(row.credential_b64).decode() + except Exception: + # Fall back to nacl decryption for credentials stored by older code + return decrypt_value_helper( + value=row.credential_b64, + key="byok_credential", + exception_type="debug", + return_original_value=False, + ) + + +async def has_user_credential( + prisma_client: PrismaClient, + user_id: str, + server_id: str, +) -> bool: + """Return True if the user has a stored credential for this server.""" + row = await prisma_client.db.litellm_mcpusercredentials.find_unique( + where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}} + ) + return row is not None + + +async def delete_user_credential( + prisma_client: PrismaClient, + user_id: str, + server_id: str, +) -> None: + """Delete the user's stored credential for a BYOK MCP server.""" + await prisma_client.db.litellm_mcpusercredentials.delete( + where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}} + ) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 51bdfea172b..7c17da36bb7 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -650,6 +650,9 @@ class MCPServerManager: tool_name_to_description=_deserialize_json_dict( getattr(mcp_server, "tool_name_to_description", None) ), + is_byok=bool(getattr(mcp_server, "is_byok", False)), + byok_description=getattr(mcp_server, "byok_description", None) or [], + byok_api_key_help_url=getattr(mcp_server, "byok_api_key_help_url", None), ) return new_server @@ -2657,6 +2660,9 @@ class MCPServerManager: registration_url=server.registration_url, allow_all_keys=server.allow_all_keys, available_on_public_internet=server.available_on_public_internet, + is_byok=server.is_byok, + byok_description=server.byok_description, + byok_api_key_help_url=server.byok_api_key_help_url, ) async def get_all_mcp_servers_unfiltered(self) -> List[LiteLLM_MCPServerTable]: diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py index 21d39c97d7c..bcbf91e5c56 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -3,6 +3,7 @@ This module is used to generate MCP tools from OpenAPI specs. """ import asyncio +import contextvars import json import os from pathlib import PurePosixPath @@ -22,6 +23,13 @@ from litellm.proxy._experimental.mcp_server.tool_registry import ( BASE_URL = "" HEADERS: Dict[str, str] = {} +# Per-request auth header override for BYOK servers. +# Set this ContextVar before calling a local tool handler to inject the user's +# stored credential into the HTTP request made by the tool function closure. +_request_auth_header: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( + "_request_auth_header", default=None +) + def _sanitize_path_parameter_value(param_value: Any, param_name: str) -> str: """Ensure path params cannot introduce directory traversal.""" @@ -211,6 +219,15 @@ def create_tool_function( The function safely handles parameter names that aren't valid Python identifiers by using **kwargs instead of named parameters. """ + # Allow per-request auth override (e.g. BYOK credential set via ContextVar). + # The ContextVar holds the full Authorization header value, including the + # correct prefix (Bearer / ApiKey / Basic) formatted by the caller in + # server.py based on the server's configured auth_type. + effective_headers = dict(headers) + override_auth = _request_auth_header.get() + if override_auth: + effective_headers["Authorization"] = override_auth + # Build URL from base_url and path url = base_url + path @@ -263,20 +280,20 @@ def create_tool_function( client = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP) if original_method == "get": - response = await client.get(url, params=params, headers=headers) + response = await client.get(url, params=params, headers=effective_headers) elif original_method == "post": response = await client.post( - url, params=params, json=json_body, headers=headers + url, params=params, json=json_body, headers=effective_headers ) elif original_method == "put": response = await client.put( - url, params=params, json=json_body, headers=headers + url, params=params, json=json_body, headers=effective_headers ) elif original_method == "delete": - response = await client.delete(url, params=params, headers=headers) + response = await client.delete(url, params=params, headers=effective_headers) elif original_method == "patch": response = await client.patch( - url, params=params, json=json_body, headers=headers + url, params=params, json=json_body, headers=effective_headers ) else: return f"Unsupported HTTP method: {original_method}" diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index b131800e950..5c063839304 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -5,6 +5,7 @@ LiteLLM MCP Server Routes import asyncio import contextlib +import time import traceback import uuid from datetime import datetime @@ -54,6 +55,32 @@ from litellm.types.mcp_server.mcp_server_manager import MCPInfo, MCPServer from litellm.types.utils import CallTypes, StandardLoggingMCPToolCall from litellm.utils import Rules, client, function_setup +# Short-lived in-memory cache for BYOK credentials. +# Keyed by (user_id, server_id); value is (credential_or_None, monotonic_timestamp). +# Storing the credential value (not just a bool) means _get_byok_credential and +# _check_byok_credential share a single DB round-trip per TTL window. +_byok_cred_cache: Dict[Tuple[str, str], Tuple[Optional[str], float]] = {} +_BYOK_CRED_CACHE_TTL = 60 # seconds +_BYOK_CRED_CACHE_MAX_SIZE = 4096 # cap to prevent unbounded growth + + +def _invalidate_byok_cred_cache(user_id: str, server_id: str) -> None: + """Remove a (user_id, server_id) entry from the BYOK credential cache. + + Call this after storing or deleting a credential so subsequent calls + see the fresh value rather than a stale cached result. + """ + _byok_cred_cache.pop((user_id, server_id), None) + + +def _write_byok_cred_cache( + user_id: str, server_id: str, credential: Optional[str] +) -> None: + """Write a credential value to the cache, evicting all entries if at capacity.""" + if len(_byok_cred_cache) >= _BYOK_CRED_CACHE_MAX_SIZE: + _byok_cred_cache.clear() + _byok_cred_cache[(user_id, server_id)] = (credential, time.monotonic()) + # Check if MCP is available # "mcp" requires python 3.10 or higher, but several litellm users use python 3.8 # We're making this conditional import to avoid breaking users who use python 3.8. @@ -118,6 +145,9 @@ if MCP_AVAILABLE: from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, ) + from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( + _request_auth_header, + ) from litellm.proxy._experimental.mcp_server.sse_transport import SseServerTransport from litellm.proxy._experimental.mcp_server.tool_registry import ( global_mcp_tool_registry, @@ -1498,6 +1528,122 @@ if MCP_AVAILABLE: ) return name + async def _get_byok_credential( + mcp_server: MCPServer, + user_api_key_auth: Optional[UserAPIKeyAuth], + ) -> Optional[str]: + """Retrieve the stored BYOK credential for a user+server pair. + + Uses the shared _byok_cred_cache to avoid a DB round-trip on every + tool call within the TTL window. + """ + if not mcp_server.is_byok: + return None + user_id = (user_api_key_auth.user_id if user_api_key_auth else None) or "" + if not user_id: + return None + + cache_key = (user_id, mcp_server.server_id) + cached = _byok_cred_cache.get(cache_key) + if cached is not None: + credential, ts = cached + if time.monotonic() - ts < _BYOK_CRED_CACHE_TTL: + return credential + + from litellm.proxy._experimental.mcp_server.db import get_user_credential + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + return None + credential = await get_user_credential( + prisma_client=prisma_client, + user_id=user_id, + server_id=mcp_server.server_id, + ) + _write_byok_cred_cache(user_id, mcp_server.server_id, credential) + return credential + + async def _check_byok_credential( + mcp_server: MCPServer, + user_api_key_auth: Optional[UserAPIKeyAuth], + ) -> None: + """ + If the MCP server is BYOK-enabled, verify that the requesting user has a + stored credential. When no credential is found, raise an HTTP 401 with a + WWW-Authenticate header that points the MCP client to our OAuth metadata + endpoint so it can drive the authorization flow. + """ + if not mcp_server.is_byok: + return + + user_id = (user_api_key_auth.user_id if user_api_key_auth else None) or "" + if not user_id: + raise HTTPException( + status_code=401, + detail={ + "error": "byok_auth_required", + "server_id": mcp_server.server_id, + "server_name": mcp_server.server_name or mcp_server.name, + "message": "User identity is required for BYOK servers", + }, + headers={ + "WWW-Authenticate": 'Bearer resource_metadata="/.well-known/oauth-protected-resource"' + }, + ) + + # Check shared credential cache before hitting the DB. + cache_key = (user_id, mcp_server.server_id) + cached = _byok_cred_cache.get(cache_key) + if cached is not None: + cached_cred, ts = cached + if time.monotonic() - ts < _BYOK_CRED_CACHE_TTL: + if cached_cred is None: + raise HTTPException( + status_code=401, + detail={ + "error": "byok_auth_required", + "server_id": mcp_server.server_id, + "server_name": mcp_server.server_name or mcp_server.name, + "message": ( + "No stored credential found for this BYOK server. " + "Complete the OAuth authorization flow to provide your API key." + ), + }, + headers={ + "WWW-Authenticate": 'Bearer resource_metadata="/.well-known/oauth-protected-resource"' + }, + ) + return + + from litellm.proxy._experimental.mcp_server.db import get_user_credential + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + return + + credential = await get_user_credential( + prisma_client=prisma_client, + user_id=user_id, + server_id=mcp_server.server_id, + ) + _write_byok_cred_cache(user_id, mcp_server.server_id, credential) + if credential is None: + raise HTTPException( + status_code=401, + detail={ + "error": "byok_auth_required", + "server_id": mcp_server.server_id, + "server_name": mcp_server.server_name or mcp_server.name, + "message": ( + "No stored credential found for this BYOK server. " + "Complete the OAuth authorization flow to provide your API key." + ), + }, + headers={ + "WWW-Authenticate": 'Bearer resource_metadata="/.well-known/oauth-protected-resource"' + }, + ) + async def execute_mcp_tool( name: str, arguments: Dict[str, Any], @@ -1573,57 +1719,99 @@ if MCP_AVAILABLE: "mcp_tool_call_metadata" ] = standard_logging_mcp_tool_call litellm_logging_obj.model = f"MCP: {name}" + # Resolve the MCP server early so BYOK checks and credential injection + # apply to ALL dispatch paths (local tool registry AND managed MCP server). + if mcp_server is None: + mcp_server = global_mcp_server_manager._get_mcp_server_from_tool_name(name) + + if mcp_server: + standard_logging_mcp_tool_call["mcp_server_cost_info"] = ( + mcp_server.mcp_info or {} + ).get("mcp_server_cost_info") + if litellm_logging_obj: + litellm_logging_obj.model_call_details[ + "mcp_tool_call_metadata" + ] = standard_logging_mcp_tool_call + + # BYOK: retrieve the stored per-user credential. A single DB call + # both checks existence and fetches the value, avoiding a double query. + if mcp_server.is_byok and not mcp_auth_header: + byok_cred = await _get_byok_credential(mcp_server, user_api_key_auth) + if byok_cred is None: + raise HTTPException( + status_code=401, + detail={ + "error": "byok_auth_required", + "server_id": mcp_server.server_id, + "server_name": mcp_server.server_name or mcp_server.name, + "message": ( + "No stored credential found for this BYOK server. " + "Complete the OAuth authorization flow to provide your API key." + ), + }, + headers={ + "WWW-Authenticate": 'Bearer resource_metadata="/.well-known/oauth-protected-resource"' + }, + ) + mcp_auth_header = byok_cred + elif mcp_server.is_byok: + # External auth header supplied; still enforce user-identity check. + await _check_byok_credential(mcp_server, user_api_key_auth) + # Check if tool exists in local registry first (for OpenAPI-based tools) # These tools are registered with their prefixed names ######################################################### local_tool = global_mcp_tool_registry.get_tool(name) if local_tool: verbose_logger.debug(f"Executing local registry tool: {name}") - local_content = await _handle_local_mcp_tool(name, arguments) + # For BYOK servers the credential must be injected via a ContextVar + # because the tool function has headers baked into its closure. + # Pre-format the full Authorization header value using the server's + # configured auth_type so the generator doesn't need to know the prefix. + auth_header_value: Optional[str] = None + if mcp_auth_header: + server_auth_type = getattr(mcp_server, "auth_type", None) if mcp_server else None + if server_auth_type == MCPAuth.api_key: + auth_header_value = f"ApiKey {mcp_auth_header}" + elif server_auth_type == MCPAuth.basic: + auth_header_value = f"Basic {mcp_auth_header}" + else: + auth_header_value = f"Bearer {mcp_auth_header}" + _auth_token = _request_auth_header.set(auth_header_value) + try: + local_content = await _handle_local_mcp_tool(name, arguments) + finally: + _request_auth_header.reset(_auth_token) response = CallToolResult(content=cast(Any, local_content), isError=False) # Try managed MCP server tool (pass the full prefixed name) # Primary and recommended way to use external MCP servers ######################################################### - else: - # If we haven't already resolved the server, do it now for dispatch - if mcp_server is None: - mcp_server = global_mcp_server_manager._get_mcp_server_from_tool_name( - name - ) - if mcp_server: - standard_logging_mcp_tool_call["mcp_server_cost_info"] = ( - mcp_server.mcp_info or {} - ).get("mcp_server_cost_info") - # Update model_call_details with the cost info - if litellm_logging_obj: - litellm_logging_obj.model_call_details[ - "mcp_tool_call_metadata" - ] = standard_logging_mcp_tool_call - response = await _handle_managed_mcp_tool( - server_name=server_name, - name=original_tool_name, # Pass the full name (potentially prefixed) - arguments=arguments, - user_api_key_auth=user_api_key_auth, - mcp_auth_header=mcp_auth_header, - mcp_server_auth_headers=mcp_server_auth_headers, - oauth2_headers=oauth2_headers, - raw_headers=raw_headers, - litellm_logging_obj=litellm_logging_obj, - host_progress_callback=host_progress_callback, - ) + elif mcp_server: + response = await _handle_managed_mcp_tool( + server_name=server_name, + name=original_tool_name, # Pass the full name (potentially prefixed) + arguments=arguments, + user_api_key_auth=user_api_key_auth, + mcp_auth_header=mcp_auth_header, + mcp_server_auth_headers=mcp_server_auth_headers, + oauth2_headers=oauth2_headers, + raw_headers=raw_headers, + litellm_logging_obj=litellm_logging_obj, + host_progress_callback=host_progress_callback, + ) - # Fall back to local tool registry with original name (legacy support) - ######################################################### - # Deprecated: Local MCP Server Tool - ######################################################### - else: - local_content = await _handle_local_mcp_tool( - original_tool_name, arguments - ) - response = CallToolResult( - content=cast(Any, local_content), isError=False - ) + # Fall back to local tool registry with original name (legacy support) + ######################################################### + # Deprecated: Local MCP Server Tool + ######################################################### + else: + local_content = await _handle_local_mcp_tool( + original_tool_name, arguments + ) + response = CallToolResult( + content=cast(Any, local_content), isError=False + ) return response diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 9b07d44deb6..95dabd8bfd0 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1108,6 +1108,9 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase): registration_url: Optional[str] = None allow_all_keys: bool = False available_on_public_internet: bool = True + is_byok: bool = False + byok_description: List[str] = Field(default_factory=list) + byok_api_key_help_url: Optional[str] = None @model_validator(mode="before") @classmethod @@ -1164,6 +1167,9 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): registration_url: Optional[str] = None allow_all_keys: bool = False available_on_public_internet: bool = True + is_byok: bool = False + byok_description: List[str] = Field(default_factory=list) + byok_api_key_help_url: Optional[str] = None @model_validator(mode="before") @classmethod @@ -1223,12 +1229,26 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase): registration_url: Optional[str] = None allow_all_keys: bool = False available_on_public_internet: bool = True + is_byok: bool = False + byok_description: List[str] = Field(default_factory=list) + byok_api_key_help_url: Optional[str] = None + has_user_credential: Optional[bool] = None class MakeMCPServersPublicRequest(LiteLLMPydanticObjectBase): mcp_server_ids: List[str] +class MCPUserCredentialRequest(LiteLLMPydanticObjectBase): + credential: str + save: bool = True + + +class MCPUserCredentialResponse(LiteLLMPydanticObjectBase): + server_id: str + has_credential: bool + + ######## Skills API Types ######## diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 8c4d4e7937e..b48db72a536 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -78,8 +78,11 @@ if MCP_AVAILABLE: from litellm.proxy._experimental.mcp_server.db import ( create_mcp_server, delete_mcp_server, + delete_user_credential, get_all_mcp_servers_for_user, get_mcp_server, + get_user_credential, + store_user_credential, update_mcp_server, ) from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( @@ -98,6 +101,8 @@ if MCP_AVAILABLE: LiteLLM_MCPServerTable, LitellmUserRoles, MakeMCPServersPublicRequest, + MCPUserCredentialRequest, + MCPUserCredentialResponse, NewMCPServerRequest, SpecialMCPServerName, UpdateMCPServerRequest, @@ -599,6 +604,25 @@ if MCP_AVAILABLE: server.mcp_info = {} server.mcp_info["is_public"] = True + # Annotate has_user_credential for BYOK servers (single batched query) + from litellm.proxy.proxy_server import prisma_client as _byok_prisma_client + + user_id = user_api_key_dict.user_id or "" + if user_id and _byok_prisma_client is not None: + byok_server_ids = [ + s.server_id + for s in redacted_mcp_servers + if getattr(s, "is_byok", False) + ] + if byok_server_ids: + cred_rows = await _byok_prisma_client.db.litellm_mcpusercredentials.find_many( + where={"user_id": user_id, "server_id": {"in": byok_server_ids}} + ) + cred_set = {r.server_id for r in cred_rows} + for server in redacted_mcp_servers: + if getattr(server, "is_byok", False): + server.has_user_credential = server.server_id in cred_set + # Virtual keys only get a sanitized discovery view. if is_restricted_virtual_key: return _sanitize_mcp_server_list_for_virtual_key(redacted_mcp_servers) @@ -1036,6 +1060,80 @@ if MCP_AVAILABLE: return Response(status_code=status.HTTP_202_ACCEPTED) + @router.post( + "/server/{server_id}/user-credential", + description="Store or update the calling user's API key for a BYOK MCP server", + dependencies=[Depends(user_api_key_auth)], + response_model=MCPUserCredentialResponse, + ) + @management_endpoint_wrapper + async def store_mcp_user_credential( + server_id: str, + payload: MCPUserCredentialRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ): + """Store a BYOK credential for the calling user.""" + prisma_client = get_prisma_client_or_throw( + "Database not connected. Connect a database to your proxy" + ) + mcp_server = await get_mcp_server(prisma_client, server_id) + if mcp_server is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"error": f"MCP Server {server_id} not found"}, + ) + if not getattr(mcp_server, "is_byok", False): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "This MCP server does not support BYOK credentials"}, + ) + user_id = user_api_key_dict.user_id or "" + if not user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "User ID not found in token"}, + ) + if payload.save: + await store_user_credential(prisma_client, user_id, server_id, payload.credential) + from litellm.proxy._experimental.mcp_server.server import ( + _invalidate_byok_cred_cache, + ) + _invalidate_byok_cred_cache(user_id, server_id) + return MCPUserCredentialResponse(server_id=server_id, has_credential=True) + # save=False: credential not persisted + return MCPUserCredentialResponse(server_id=server_id, has_credential=False) + + @router.delete( + "/server/{server_id}/user-credential", + description="Delete the calling user's stored API key for a BYOK MCP server", + dependencies=[Depends(user_api_key_auth)], + response_model=MCPUserCredentialResponse, + ) + @management_endpoint_wrapper + async def delete_mcp_user_credential( + server_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ): + """Remove the calling user's BYOK credential.""" + prisma_client = get_prisma_client_or_throw( + "Database not connected. Connect a database to your proxy" + ) + user_id = user_api_key_dict.user_id or "" + if not user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "User ID not found in token"}, + ) + try: + await delete_user_credential(prisma_client, user_id, server_id) + except Exception: + pass # Already deleted or didn't exist + from litellm.proxy._experimental.mcp_server.server import ( + _invalidate_byok_cred_cache, + ) + _invalidate_byok_cred_cache(user_id, server_id) + return MCPUserCredentialResponse(server_id=server_id, has_credential=False) + @router.put( "/server", description="Allows deleting mcp serves in the db", diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index ad31ff33802..33d84cd7078 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -231,6 +231,9 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.llms.vertex_ai.vertex_llm_base import VertexBase +from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import ( + router as mcp_byok_oauth_router, +) from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( router as mcp_discoverable_endpoints_router, ) @@ -12975,6 +12978,7 @@ app.include_router(vector_store_files_router) app.include_router(credential_router) app.include_router(llm_passthrough_router) app.include_router(mcp_management_router) +app.include_router(mcp_byok_oauth_router) app.include_router(anthropic_router) app.include_router(anthropic_skills_router) app.include_router(evals_router) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 5e1ba479298..43972724ecc 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -305,6 +305,22 @@ model LiteLLM_MCPServerTable { registration_url String? allow_all_keys Boolean @default(false) available_on_public_internet Boolean @default(true) + spec_path String? + is_byok Boolean @default(false) + byok_description String[] @default([]) + byok_api_key_help_url String? +} + +// Per-user BYOK credentials for MCP servers +model LiteLLM_MCPUserCredentials { + id String @id @default(uuid()) + user_id String + server_id String + credential_b64 String + created_at DateTime @default(now()) @map("created_at") + updated_at DateTime @default(now()) @updatedAt @map("updated_at") + + @@unique([user_id, server_id]) } // Generate Tokens for Proxy diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 7f6a8b3ea24..d94795fda2e 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -55,6 +55,9 @@ class MCPServer(BaseModel): access_groups: Optional[List[str]] = None allow_all_keys: bool = False available_on_public_internet: bool = True + is_byok: bool = False + byok_description: List[str] = [] + byok_api_key_help_url: Optional[str] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py new file mode 100644 index 00000000000..a7391666cda --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py @@ -0,0 +1,517 @@ +""" +Unit tests for the BYOK OAuth 2.1 authorization server endpoints. + +Covers: +- _verify_pkce helper +- OAuth metadata discovery endpoints +- Authorization GET / POST endpoints +- Token endpoint (PKCE verification, credential storage, JWT issuance) +- 401 challenge in execute_mcp_tool (_check_byok_credential) +""" + +import base64 +import hashlib +import time +import uuid +from typing import Any, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import ( + _byok_auth_codes, + _verify_pkce, + router, +) +from litellm.proxy._types import MCPTransport + +# --------------------------------------------------------------------------- +# _verify_pkce +# --------------------------------------------------------------------------- + + +def _make_challenge(verifier: str) -> str: + digest = hashlib.sha256(verifier.encode()).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode() + + +def test_verify_pkce_valid(): + verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge = _make_challenge(verifier) + assert _verify_pkce(verifier, challenge) is True + + +def test_verify_pkce_invalid(): + assert _verify_pkce("wrong_verifier", _make_challenge("right_verifier")) is False + + +def test_verify_pkce_tampered_challenge(): + verifier = "test_verifier_value" + challenge = _make_challenge(verifier) + # Flip one character to tamper with the challenge + tampered = challenge[:-1] + ("A" if challenge[-1] != "A" else "B") + assert _verify_pkce(verifier, tampered) is False + + +# --------------------------------------------------------------------------- +# Minimal FastAPI app for testing the router +# --------------------------------------------------------------------------- + +from fastapi import FastAPI + +_test_app = FastAPI() +_test_app.include_router(router) + + +@pytest.fixture +def client(): + return TestClient(_test_app, raise_server_exceptions=False) + + +# --------------------------------------------------------------------------- +# OAuth metadata endpoints +# --------------------------------------------------------------------------- + + +def test_oauth_authorization_server_metadata(client): + resp = client.get("/.well-known/oauth-authorization-server") + assert resp.status_code == 200 + data = resp.json() + assert "issuer" in data + assert data["authorization_endpoint"].endswith("/v1/mcp/oauth/authorize") + assert data["token_endpoint"].endswith("/v1/mcp/oauth/token") + assert "S256" in data["code_challenge_methods_supported"] + + +def test_oauth_protected_resource_metadata(client): + resp = client.get("/.well-known/oauth-protected-resource") + assert resp.status_code == 200 + data = resp.json() + assert "resource" in data + assert "authorization_servers" in data + assert len(data["authorization_servers"]) == 1 + + +# --------------------------------------------------------------------------- +# Authorization GET endpoint +# --------------------------------------------------------------------------- + + +def test_authorize_get_returns_html(client): + resp = client.get( + "/v1/mcp/oauth/authorize", + params={ + "client_id": "test-client", + "redirect_uri": "https://client.example.com/callback", + "response_type": "code", + "code_challenge": "abc123", + "code_challenge_method": "S256", + "state": "xyz", + "server_id": "my-server", + }, + follow_redirects=False, + ) + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + # The button text is HTML-entity-escaped in the template + assert "Connect & Authorize" in resp.text + # Hidden fields should be embedded + assert "my-server" in resp.text + assert "abc123" in resp.text + + +def test_authorize_get_missing_redirect_uri(client): + resp = client.get( + "/v1/mcp/oauth/authorize", + params={ + "response_type": "code", + "code_challenge": "abc", + }, + follow_redirects=False, + ) + assert resp.status_code == 400 + + +def test_authorize_get_wrong_response_type(client): + resp = client.get( + "/v1/mcp/oauth/authorize", + params={ + "redirect_uri": "https://example.com/cb", + "response_type": "token", + "code_challenge": "abc", + }, + follow_redirects=False, + ) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# Authorization POST endpoint +# --------------------------------------------------------------------------- + + +def test_authorize_post_creates_code_and_redirects(client): + verifier = "my_code_verifier_that_is_long_enough_43chars" + challenge = _make_challenge(verifier) + + resp = client.post( + "/v1/mcp/oauth/authorize", + data={ + "client_id": "user-123", + "redirect_uri": "https://client.example.com/callback", + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": "st_abc", + "server_id": "server-xyz", + "api_key": "sk-supersecretkey", + }, + follow_redirects=False, + ) + assert resp.status_code == 302 + location = resp.headers["location"] + assert "code=" in location + assert "st_abc" in location + + # Extract the code from the redirect URL + from urllib.parse import parse_qs, urlparse + + qs = parse_qs(urlparse(location).query) + code = qs["code"][0] + assert code in _byok_auth_codes + entry = _byok_auth_codes[code] + assert entry["api_key"] == "sk-supersecretkey" + assert entry["server_id"] == "server-xyz" + assert entry["user_id"] == "user-123" + assert entry["code_challenge"] == challenge + + +def test_authorize_post_unsupported_method(client): + resp = client.post( + "/v1/mcp/oauth/authorize", + data={ + "client_id": "u", + "redirect_uri": "https://example.com/cb", + "code_challenge": "abc", + "code_challenge_method": "plain", + "state": "", + "server_id": "s", + "api_key": "key", + }, + follow_redirects=False, + ) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# Token endpoint +# --------------------------------------------------------------------------- + + +def _insert_code( + api_key: str, + server_id: str, + user_id: str, + challenge: str, + redirect_uri: str, + ttl: int = 300, +) -> str: + code = str(uuid.uuid4()) + _byok_auth_codes[code] = { + "api_key": api_key, + "server_id": server_id, + "user_id": user_id, + "code_challenge": challenge, + "redirect_uri": redirect_uri, + "expires_at": time.time() + ttl, + } + return code + + +@pytest.mark.asyncio +async def test_token_endpoint_success(): + """Happy path: valid code + PKCE → credential stored → JWT returned.""" + verifier = "my_test_code_verifier_value_long_enough_yes" + challenge = _make_challenge(verifier) + code = _insert_code( + api_key="sk-myapikey", + server_id="server-1", + user_id="user-42", + challenge=challenge, + redirect_uri="https://example.com/cb", + ) + + mock_prisma = MagicMock() + mock_store = AsyncMock() + test_master_key = "test_master_key_value" + + with patch( + "litellm.proxy._experimental.mcp_server.byok_oauth_endpoints.store_user_credential", + mock_store, + ), patch( + "litellm.proxy._experimental.mcp_server.byok_oauth_endpoints.router", + ): + # Import the actual handler function directly + from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import ( + byok_token, + ) + + mock_request = MagicMock() + # Patch module-level globals in the function's module + with patch( + "litellm.proxy._experimental.mcp_server.byok_oauth_endpoints.store_user_credential", + mock_store, + ): + import litellm.proxy._experimental.mcp_server.byok_oauth_endpoints as mod + + original_prisma = None + original_master_key = None + + # Temporarily inject our test values + with patch( + "litellm.proxy.proxy_server.prisma_client", mock_prisma + ), patch("litellm.proxy.proxy_server.master_key", test_master_key): + result = await byok_token( + request=mock_request, + grant_type="authorization_code", + code=code, + redirect_uri="https://example.com/cb", + code_verifier=verifier, + client_id="user-42", + ) + + assert result.status_code == 200 + body = result.body + import json + + data = json.loads(body) + assert "access_token" in data + assert data["token_type"] == "bearer" + assert data["expires_in"] == 3600 + + # Verify JWT payload + import jwt as pyjwt + + payload = pyjwt.decode( + data["access_token"], test_master_key, algorithms=["HS256"] + ) + assert payload["user_id"] == "user-42" + assert payload["server_id"] == "server-1" + assert payload["type"] == "byok_session" + + # Auth code was consumed + assert code not in _byok_auth_codes + + # store_user_credential was called + mock_store.assert_awaited_once_with( + prisma_client=mock_prisma, + user_id="user-42", + server_id="server-1", + credential="sk-myapikey", + ) + + +@pytest.mark.asyncio +async def test_token_endpoint_invalid_code(): + from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import byok_token + + mock_request = MagicMock() + with pytest.raises(HTTPException) as exc_info: + with patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), patch( + "litellm.proxy.proxy_server.master_key", "key" + ): + await byok_token( + request=mock_request, + grant_type="authorization_code", + code="nonexistent-code", + redirect_uri="", + code_verifier="anything", + client_id="u", + ) + assert exc_info.value.status_code == 400 + assert "invalid_grant" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_token_endpoint_expired_code(): + verifier = "exp_verifier_that_is_long_enough_to_be_valid" + challenge = _make_challenge(verifier) + code = _insert_code( + api_key="key", + server_id="s", + user_id="u", + challenge=challenge, + redirect_uri="https://cb", + ttl=-10, # already expired + ) + + from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import byok_token + + mock_request = MagicMock() + with pytest.raises(HTTPException) as exc_info: + with patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), patch( + "litellm.proxy.proxy_server.master_key", "key" + ): + await byok_token( + request=mock_request, + grant_type="authorization_code", + code=code, + redirect_uri="", + code_verifier=verifier, + client_id="u", + ) + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_token_endpoint_wrong_verifier(): + verifier = "correct_verifier_value_that_is_long_enough" + challenge = _make_challenge(verifier) + code = _insert_code( + api_key="key", + server_id="s", + user_id="u", + challenge=challenge, + redirect_uri="https://cb", + ) + + from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import byok_token + + mock_request = MagicMock() + with pytest.raises(HTTPException) as exc_info: + with patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), patch( + "litellm.proxy.proxy_server.master_key", "key" + ): + await byok_token( + request=mock_request, + grant_type="authorization_code", + code=code, + redirect_uri="", + code_verifier="wrong_verifier_value_that_wont_match", + client_id="u", + ) + assert exc_info.value.status_code == 400 + assert "invalid_grant" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_token_endpoint_unsupported_grant_type(): + from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import byok_token + + mock_request = MagicMock() + with pytest.raises(HTTPException) as exc_info: + with patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), patch( + "litellm.proxy.proxy_server.master_key", "key" + ): + await byok_token( + request=mock_request, + grant_type="client_credentials", + code="any", + redirect_uri="", + code_verifier="v", + client_id="u", + ) + assert exc_info.value.status_code == 400 + assert "unsupported_grant_type" in str(exc_info.value.detail) + + +# --------------------------------------------------------------------------- +# _check_byok_credential (the 401 challenge in execute_mcp_tool) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_check_byok_credential_not_byok(): + """Non-BYOK servers should pass through without any DB check.""" + from litellm.proxy._experimental.mcp_server.server import _check_byok_credential + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="s1", + name="normal-server", + transport=MCPTransport.http, + is_byok=False, + ) + # Should not raise + await _check_byok_credential(server, None) + + +@pytest.mark.asyncio +async def test_check_byok_credential_no_user_id(): + """BYOK server with no user identity → 401.""" + from litellm.proxy._experimental.mcp_server.server import _check_byok_credential + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="byok-1", + name="byok-server", + transport=MCPTransport.http, + is_byok=True, + ) + with pytest.raises(HTTPException) as exc_info: + await _check_byok_credential(server, None) + + assert exc_info.value.status_code == 401 + assert "WWW-Authenticate" in (exc_info.value.headers or {}) # type: ignore[operator] + assert "byok_auth_required" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_check_byok_credential_missing_credential(): + """BYOK server with a known user but no stored credential → 401.""" + from litellm.proxy._experimental.mcp_server.server import _check_byok_credential + from litellm.proxy._types import UserAPIKeyAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="byok-2", + name="byok-server", + transport=MCPTransport.http, + is_byok=True, + ) + user_auth = UserAPIKeyAuth(user_id="user-99", api_key="sk-test") + + mock_prisma = MagicMock() + + with patch( + "litellm.proxy._experimental.mcp_server.db.get_user_credential", + new=AsyncMock(return_value=None), + ), patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + ), patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with pytest.raises(HTTPException) as exc_info: + await _check_byok_credential(server, user_auth) + + assert exc_info.value.status_code == 401 + detail: Any = exc_info.value.detail + assert detail["error"] == "byok_auth_required" + assert detail["server_id"] == "byok-2" + headers = exc_info.value.headers or {} + assert "WWW-Authenticate" in headers # type: ignore[operator] + assert "oauth-protected-resource" in headers["WWW-Authenticate"] # type: ignore[index] + + +@pytest.mark.asyncio +async def test_check_byok_credential_has_credential(): + """BYOK server with a valid stored credential → no error raised.""" + from litellm.proxy._experimental.mcp_server.server import _check_byok_credential + from litellm.proxy._types import UserAPIKeyAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="byok-3", + name="byok-server", + transport=MCPTransport.http, + is_byok=True, + ) + user_auth = UserAPIKeyAuth(user_id="user-77", api_key="sk-test") + + mock_prisma = MagicMock() + + with patch( + "litellm.proxy._experimental.mcp_server.db.get_user_credential", + new=AsyncMock(return_value="some-credential-value"), + ), patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + ), patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + # Should not raise + await _check_byok_credential(server, user_auth) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index b3829d0a8f4..dbc1c4d10e2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -111,6 +111,8 @@ const routeFor = (slug: string): string => { return "tools/mcp-servers"; case "vector-stores": return "tools/vector-stores"; + case "byok-demo": + return "tools/byok-demo"; // experimental case "caching": diff --git a/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx b/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx new file mode 100644 index 00000000000..473918c1267 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx @@ -0,0 +1,254 @@ +"use client"; + +import React, { useState } from "react"; +import { Modal, Input, Switch, message } from "antd"; +import { + KeyOutlined, + LockOutlined, + CheckOutlined, + ArrowRightOutlined, + ArrowLeftOutlined, + CloseOutlined, + LinkOutlined, +} from "@ant-design/icons"; +import { MCPServer } from "./types"; + +interface ByokCredentialModalProps { + server: MCPServer; + open: boolean; + onClose: () => void; + onSuccess: (serverId: string) => void; + accessToken: string; +} + +export const ByokCredentialModal: React.FC = ({ + server, + open, + onClose, + onSuccess, + accessToken, +}) => { + const [step, setStep] = useState<1 | 2>(1); + const [apiKey, setApiKey] = useState(""); + const [saveKey, setSaveKey] = useState(true); + const [loading, setLoading] = useState(false); + + const serverDisplayName = server.alias || server.server_name || "Service"; + const firstLetter = serverDisplayName.charAt(0).toUpperCase(); + + const handleClose = () => { + setStep(1); + setApiKey(""); + setSaveKey(true); + setLoading(false); + onClose(); + }; + + const handleAuthorize = async () => { + if (!apiKey.trim()) { + message.error("Please enter your API key"); + return; + } + setLoading(true); + try { + const response = await fetch(`/v1/mcp/server/${server.server_id}/user-credential`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ credential: apiKey.trim(), save: saveKey }), + }); + if (!response.ok) { + const err = await response.json(); + throw new Error(err?.detail?.error || "Failed to save credential"); + } + message.success(`Connected to ${serverDisplayName}`); + onSuccess(server.server_id); + handleClose(); + } catch (e: any) { + message.error(e.message || "Failed to connect"); + } finally { + setLoading(false); + } + }; + + return ( + +
+ {/* Step dots + close */} +
+ {step === 2 ? ( + + ) : ( +
+ )} +
+
+
+
+ +
+ + {step === 1 ? ( +
+ {/* Logos */} +
+
+ L +
+ +
+ {firstLetter} +
+
+ +

Connect {serverDisplayName}

+

+ LiteLLM needs access to {serverDisplayName} to complete your request. +

+ + {/* How it works */} +
+
+
+ + + + +
+
+

How it works

+

+ LiteLLM acts as a secure bridge. Your requests are routed through our MCP client directly to{" "} + {serverDisplayName}'s API. +

+
+
+
+ + {/* Requested access */} + {server.byok_description && server.byok_description.length > 0 && ( +
+

+ + + + + Requested Access +

+
    + {server.byok_description.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+ )} + + + +
+ ) : ( +
+ {/* Key icon */} +
+ +
+ +

Provide API Key

+

+ Enter your {serverDisplayName} API key to authorize this connection. +

+ +
+ + setApiKey(e.target.value)} + size="large" + className="rounded-lg" + /> + {server.byok_api_key_help_url && ( + + Where do I find my API key? + + )} +
+ + {/* Save toggle */} +
+
+ + + + Save key for future use +
+ +
+ + {/* Security note */} +
+ +

+ Your key is stored securely and transmitted over HTTPS. It is never shared with third parties. +

+
+ + +
+ )} +
+ + ); +}; + +export default ByokCredentialModal; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 6dbb18887da..6ca58ffae24 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { Modal, Tooltip, Form, Select, Input } from "antd"; +import { Modal, Tooltip, Form, Select, Input, Switch } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, TextInput } from "@tremor/react"; import { createMCPServer } from "../networking"; @@ -624,6 +624,89 @@ const CreateMCPServer: React.FC = ({ )} + {/* BYOK toggle - only for OpenAPI */} + {transportType === TRANSPORT.OPENAPI && ( + <> + + BYOK (Bring Your Own Key) + + + + + } + name="is_byok" + valuePropName="checked" + > + + + + prev.is_byok !== cur.is_byok || prev.auth_type !== cur.auth_type}> + {({ getFieldValue }) => + getFieldValue("is_byok") ? ( + <> + {/* Auth format hint */} + {getFieldValue("auth_type") && getFieldValue("auth_type") !== "none" && ( +
+ + + User keys will be sent as:{" "} + + {getFieldValue("auth_type") === "bearer_token" && "Authorization: Bearer {key}"} + {getFieldValue("auth_type") === "api_key" && "x-api-key: {key}"} + {getFieldValue("auth_type") === "basic" && "Authorization: Basic {key}"} + {getFieldValue("auth_type") === "authorization" && "Authorization: {key}"} + + {!getFieldValue("auth_type") && "Set Authentication Type below to specify the format."} + +
+ )} + {!getFieldValue("auth_type") && ( +
+ + Set the Authentication Type below to specify how user keys are sent (e.g., Bearer Token, API Key header). +
+ )} + + Access Description + + + + + } + name="byok_description" + > + + + + ) : null + } +
+ + )} + {/* Authentication - show for HTTP, SSE, and OpenAPI */} {transportType !== "stdio" && transportType !== "" && ( void, onDelete: (serverId: string) => void, isLoadingHealth?: boolean, + onByokConnect?: (server: MCPServer) => void, ): ColumnDef[] => [ { accessorKey: "server_id", @@ -192,6 +194,41 @@ export const mcpServerColumns = ( ); }, }, + { + id: "byok_credential", + header: "Credential", + cell: ({ row }) => { + const server = row.original; + if (!server.is_byok) { + return ; + } + if (server.has_user_credential) { + return ( +
+ + Connected + + {onByokConnect && ( + + )} +
+ ); + } + return onByokConnect ? ( + + ) : null; + }, + }, { id: "actions", header: "Actions", diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx index 0f87f5e87b8..f48649d6653 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -16,6 +16,7 @@ import { DiscoverableMCPServer, MCPServer, MCPServerProps, Team } from "./types" import MCPSemanticFilterSettings from "../Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings"; import MCPNetworkSettings from "./MCPNetworkSettings"; import MCPDiscovery from "./mcp_discovery"; +import { ByokCredentialModal } from "./ByokCredentialModal"; const { Text: AntdText, Title: AntdTitle } = Typography; const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state"; @@ -70,6 +71,7 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) const [isDiscoveryVisible, setDiscoveryVisible] = useState(false); const [prefillData, setPrefillData] = useState(null); const [isDeletingServer, setIsDeletingServer] = useState(false); + const [byokModalServer, setByokModalServer] = useState(null); const isInternalUser = userRole === "Internal User"; useEffect(() => { @@ -170,6 +172,7 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) }, handleDelete, isLoadingHealth, + (server: MCPServer) => setByokModalServer(server), ), [userRole, isLoadingHealth], ); @@ -427,6 +430,19 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) + + {byokModalServer && ( + setByokModalServer(null)} + onSuccess={(_serverId) => { + refetch(); + setByokModalServer(null); + }} + accessToken={accessToken || ""} + /> + )}
); }; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 8a08f13e22a..6ba25012197 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -178,6 +178,12 @@ export interface MCPServer { command?: string | null; args?: string[] | null; env?: Record | null; + + /** BYOK (Bring Your Own Key) fields */ + is_byok?: boolean | null; + byok_description?: string[] | null; + byok_api_key_help_url?: string | null; + has_user_credential?: boolean | null; } export interface MCPServerProps { diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx index 3272e9b589a..9936f34452d 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx @@ -33,6 +33,7 @@ import GuardrailSelector from "../../guardrails/GuardrailSelector"; import PolicySelector from "../../policies/PolicySelector"; import MCPToolArgumentsForm, { MCPToolArgumentsFormRef } from "../../mcp_tools/MCPToolArgumentsForm"; import { MCPServer } from "../../mcp_tools/types"; +import { ByokCredentialModal } from "../../mcp_tools/ByokCredentialModal"; import NotificationsManager from "../../molecules/notifications_manager"; import { callMCPTool, fetchMCPServers, listMCPTools } from "../../networking"; import TagSelector from "../../tag_management/TagSelector"; @@ -108,6 +109,7 @@ const ChatUI: React.FC = ({ fixedModel, }) => { const [mcpServers, setMCPServers] = useState([]); + const [byokModalServer, setByokModalServer] = useState(null); const [selectedMCPServers, setSelectedMCPServers] = useState(() => { const saved = sessionStorage.getItem("selectedMCPServers"); try { @@ -1746,6 +1748,49 @@ const ChatUI: React.FC = ({ })}
)} + + {/* BYOK credential status for selected servers */} + {selectedMCPServers.length > 0 && + !selectedMCPServers.includes("__all__") && + selectedMCPServers.some((serverId) => { + const server = mcpServers.find((s) => s.server_id === serverId); + return server?.is_byok; + }) && ( +
+ {selectedMCPServers.map((serverId) => { + const server = mcpServers.find((s) => s.server_id === serverId); + if (!server?.is_byok) return null; + const serverName = server.alias || server.server_name || serverId; + return ( +
+ + {serverName} requires your API key + + {server.has_user_credential ? ( +
+ + Connected + + +
+ ) : ( + + )} +
+ ); + })} +
+ )}
@@ -2498,6 +2543,20 @@ const ChatUI: React.FC = ({ {generatedCode} + + {byokModalServer && ( + setByokModalServer(null)} + onSuccess={(_serverId) => { + // Refresh MCP servers to pick up updated has_user_credential + loadMCPServers(); + setByokModalServer(null); + }} + accessToken={accessToken || ""} + /> + )}
); }; From cc989b11716f343d96abbeea2127090286098c5c Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 5 Mar 2026 10:49:35 +0530 Subject: [PATCH 077/380] fix(bedrock): strip scope from cache_control for Anthropic messages Bedrock does not support the scope field in cache_control (e.g. 'global' for cross-request caching). Only type and ttl are supported per AWS docs. - Remove scope from cache_control in both system and messages - Extend _remove_ttl_from_cache_control to process system blocks - Add test for scope removal Made-with: Cursor --- .../anthropic_claude3_transformation.py | 46 +++++++++++++------ .../test_anthropic_claude3_transformation.py | 45 ++++++++++++++++++ 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py index 03885ff2080..f0aa643b345 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -118,10 +118,13 @@ class AmazonAnthropicClaudeMessagesConfig( self, anthropic_messages_request: Dict, model: Optional[str] = None ) -> None: """ - Remove `ttl` field from cache_control in messages. - Bedrock doesn't support the ttl field in cache_control. + Remove unsupported fields from cache_control for Bedrock. - Update: Bedock supports `5m` and `1h` for Claude 4.5 models. + Bedrock only supports `type` and `ttl` in cache_control. It does NOT support: + - `scope` (e.g., "global") - always removed + - `ttl` - removed for older models; Claude 4.5+ supports "5m" and "1h" + + Processes both `system` and `messages` content blocks. Args: anthropic_messages_request: The request dictionary to modify in-place @@ -131,23 +134,36 @@ class AmazonAnthropicClaudeMessagesConfig( if model: is_claude_4_5 = self._is_claude_4_5_on_bedrock(model) + def _sanitize_cache_control(cache_control: dict) -> None: + if not isinstance(cache_control, dict): + return + # Bedrock doesn't support scope (e.g., "global" for cross-request caching) + cache_control.pop("scope", None) + # Remove ttl for models that don't support it + if "ttl" in cache_control: + ttl = cache_control["ttl"] + if is_claude_4_5 and ttl in ["5m", "1h"]: + return + cache_control.pop("ttl", None) + + def _process_content_list(content: list) -> None: + for item in content: + if isinstance(item, dict) and "cache_control" in item: + _sanitize_cache_control(item["cache_control"]) + + # Process system (list of content blocks) + if "system" in anthropic_messages_request: + system = anthropic_messages_request["system"] + if isinstance(system, list): + _process_content_list(system) + + # Process messages if "messages" in anthropic_messages_request: for message in anthropic_messages_request["messages"]: if isinstance(message, dict) and "content" in message: content = message["content"] if isinstance(content, list): - for item in content: - if isinstance(item, dict) and "cache_control" in item: - cache_control = item["cache_control"] - if ( - isinstance(cache_control, dict) - and "ttl" in cache_control - ): - ttl = cache_control["ttl"] - if is_claude_4_5 and ttl in ["5m", "1h"]: - continue - - cache_control.pop("ttl", None) + _process_content_list(content) def _supports_extended_thinking_on_bedrock(self, model: str) -> bool: """ diff --git a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index a4da4ebb683..ee4c7828c33 100644 --- a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -178,3 +178,48 @@ def test_remove_ttl_from_cache_control(): request5 = {} cfg._remove_ttl_from_cache_control(request5) assert request5 == {} + + +def test_remove_scope_from_cache_control(): + """Ensure scope field is removed from cache_control for Bedrock (not supported).""" + + cfg = AmazonAnthropicClaudeMessagesConfig() + + # Test case 1: System with cache_control containing scope + request = { + "system": [ + { + "type": "text", + "text": "You are an AI assistant.", + "cache_control": { + "type": "ephemeral", + "scope": "global", + }, + } + ], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Hello", + "cache_control": { + "type": "ephemeral", + "scope": "global", + }, + } + ], + } + ], + } + + cfg._remove_ttl_from_cache_control(request) + + # Verify scope is removed from system + assert "scope" not in request["system"][0]["cache_control"] + assert request["system"][0]["cache_control"]["type"] == "ephemeral" + + # Verify scope is removed from messages + assert "scope" not in request["messages"][0]["content"][0]["cache_control"] + assert request["messages"][0]["content"][0]["cache_control"]["type"] == "ephemeral" From 482bc9391009f3a2441f754557c57360cc98ca08 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 5 Mar 2026 10:49:37 +0530 Subject: [PATCH 078/380] fix(azure_ai): strip scope from cache_control for Anthropic messages Azure AI Foundry's Anthropic endpoint does not support the scope field in cache_control. Strip it from both system and messages before sending. Made-with: Cursor --- .../anthropic/messages_transformation.py | 52 ++++++++++++++++++- ...azure_anthropic_messages_transformation.py | 44 ++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/litellm/llms/azure_ai/anthropic/messages_transformation.py b/litellm/llms/azure_ai/anthropic/messages_transformation.py index a4dc88f9c68..8e60e84391b 100644 --- a/litellm/llms/azure_ai/anthropic/messages_transformation.py +++ b/litellm/llms/azure_ai/anthropic/messages_transformation.py @@ -1,7 +1,7 @@ """ Azure Anthropic messages transformation config - extends AnthropicMessagesConfig with Azure authentication """ -from typing import TYPE_CHECKING, Any, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( AnthropicMessagesConfig, @@ -114,3 +114,53 @@ class AzureAnthropicMessagesConfig(AnthropicMessagesConfig): return api_base + def _remove_scope_from_cache_control( + self, anthropic_messages_request: Dict + ) -> None: + """ + Remove `scope` field from cache_control for Azure AI Foundry. + + Azure AI Foundry's Anthropic endpoint does not support the `scope` field + (e.g., "global" for cross-request caching). Only `type` and `ttl` are supported. + + Processes both `system` and `messages` content blocks. + """ + def _sanitize(cache_control: Any) -> None: + if isinstance(cache_control, dict): + cache_control.pop("scope", None) + + def _process_content_list(content: list) -> None: + for item in content: + if isinstance(item, dict) and "cache_control" in item: + _sanitize(item["cache_control"]) + + if "system" in anthropic_messages_request: + system = anthropic_messages_request["system"] + if isinstance(system, list): + _process_content_list(system) + + if "messages" in anthropic_messages_request: + for message in anthropic_messages_request["messages"]: + if isinstance(message, dict) and "content" in message: + content = message["content"] + if isinstance(content, list): + _process_content_list(content) + + def transform_anthropic_messages_request( + self, + model: str, + messages: List[Dict], + anthropic_messages_optional_request_params: Dict, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> Dict: + anthropic_messages_request = super().transform_anthropic_messages_request( + model=model, + messages=messages, + anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + litellm_params=litellm_params, + headers=headers, + ) + self._remove_scope_from_cache_control(anthropic_messages_request) + return anthropic_messages_request + diff --git a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py index bdced849c7e..83653bc037b 100644 --- a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py +++ b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py @@ -239,6 +239,50 @@ class TestAzureAnthropicMessagesConfig: assert "tools" in params assert "tool_choice" in params + def test_transform_anthropic_messages_request_removes_scope_from_cache_control( + self, + ): + """Test that scope is removed from cache_control (Azure AI Foundry doesn't support it)""" + config = AzureAnthropicMessagesConfig() + model = "claude-sonnet-4-5" + messages = [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Hello", + "cache_control": {"type": "ephemeral", "scope": "global"}, + } + ], + } + ] + anthropic_messages_optional_request_params = { + "max_tokens": 1024, + "system": [ + { + "type": "text", + "text": "You are an AI assistant.", + "cache_control": {"type": "ephemeral", "scope": "global"}, + } + ], + } + litellm_params = GenericLiteLLMParams() + headers = {} + + result = config.transform_anthropic_messages_request( + model=model, + messages=messages, + anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + litellm_params=litellm_params, + headers=headers, + ) + + assert "scope" not in result["system"][0]["cache_control"] + assert result["system"][0]["cache_control"]["type"] == "ephemeral" + assert "scope" not in result["messages"][0]["content"][0]["cache_control"] + assert result["messages"][0]["content"][0]["cache_control"]["type"] == "ephemeral" + class TestProviderConfigManagerAzureAnthropicMessages: """Test ProviderConfigManager returns correct config for Azure AI Anthropic Messages API""" From ff7024b801a96e8ea8ced994ca29cc0a2d855d20 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Thu, 5 Mar 2026 00:20:14 -0500 Subject: [PATCH 079/380] Update ui/litellm-dashboard/src/components/provider_info_helpers.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- ui/litellm-dashboard/src/components/provider_info_helpers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index 58cd0bed2eb..4772c616ccf 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -8,7 +8,7 @@ export enum Providers { ANTHROPIC_TEXT = "Anthropic Text", AssemblyAI = "AssemblyAI", AUTO_ROUTER = "Auto Router", - Bedrock = "Amazon Bedrock",\ + Bedrock = "Amazon Bedrock", BedrockMantle = "Amazon Bedrock Mantle", SageMaker = "AWS SageMaker", Azure = "Azure", From 1bf0a3adc4787b40342d7b130633c2653d954972 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Thu, 5 Mar 2026 00:20:20 -0500 Subject: [PATCH 080/380] Update ui/litellm-dashboard/src/components/provider_info_helpers.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- ui/litellm-dashboard/src/components/provider_info_helpers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index 4772c616ccf..e833d0eb4fb 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -119,7 +119,7 @@ export const provider_map: Record = { Azure_AI_Studio: "azure_ai", AZURE_TEXT: "azure_text", BASETEN: "baseten", - Bedrock: "bedrock",\ + Bedrock: "bedrock", BedrockMantle: "bedrock_mantle", BYTEZ: "bytez", Cerebras: "cerebras", From 7c47609f7af6f32cc5777e04bfe19763fa8dd1f2 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Thu, 5 Mar 2026 00:33:17 -0500 Subject: [PATCH 081/380] fix(provider): register bedrock_mantle in model_list and models_by_provider Adds bedrock_mantle_models to the model_list union and models_by_provider dict so models are discoverable via litellm.model_list and litellm.models_by_provider["bedrock_mantle"]. Co-Authored-By: Claude Sonnet 4.6 --- litellm/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/litellm/__init__.py b/litellm/__init__.py index 57e9cb25f43..ff7ef55c50c 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -962,6 +962,7 @@ model_list = list( | ovhcloud_models | lemonade_models | docker_model_runner_models + | bedrock_mantle_models | set(clarifai_models) ) @@ -1065,6 +1066,7 @@ models_by_provider: dict = { "aws_polly": aws_polly_models, "gigachat": gigachat_models, "llamagate": llamagate_models, + "bedrock_mantle": bedrock_mantle_models } # mapping for those models which have larger equivalents From f1b86366d38d0c090f483db6cd34d98f4452c013 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Thu, 5 Mar 2026 00:33:44 -0500 Subject: [PATCH 082/380] Revert "fix(provider): register bedrock_mantle in model_list and models_by_provider" This reverts commit 7c47609f7af6f32cc5777e04bfe19763fa8dd1f2. --- litellm/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index ff7ef55c50c..57e9cb25f43 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -962,7 +962,6 @@ model_list = list( | ovhcloud_models | lemonade_models | docker_model_runner_models - | bedrock_mantle_models | set(clarifai_models) ) @@ -1066,7 +1065,6 @@ models_by_provider: dict = { "aws_polly": aws_polly_models, "gigachat": gigachat_models, "llamagate": llamagate_models, - "bedrock_mantle": bedrock_mantle_models } # mapping for those models which have larger equivalents From b3f3918e98a60b3ed0e665782d3737dfb8a7ea23 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Thu, 5 Mar 2026 00:33:17 -0500 Subject: [PATCH 083/380] fix(provider): register bedrock_mantle in model_list and models_by_provider Adds bedrock_mantle_models to the model_list union and models_by_provider dict so models are discoverable via litellm.model_list and litellm.models_by_provider["bedrock_mantle"]. Co-Authored-By: Claude Sonnet 4.6 --- litellm/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/litellm/__init__.py b/litellm/__init__.py index 4264b405350..a5766035a76 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -965,6 +965,7 @@ model_list = list( | ovhcloud_models | lemonade_models | docker_model_runner_models + | bedrock_mantle_models | set(clarifai_models) ) @@ -1068,6 +1069,7 @@ models_by_provider: dict = { "aws_polly": aws_polly_models, "gigachat": gigachat_models, "llamagate": llamagate_models, + "bedrock_mantle": bedrock_mantle_models } # mapping for those models which have larger equivalents From a2c11d431ae916c27065693dbe59c756d971026a Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 5 Mar 2026 13:02:17 +0530 Subject: [PATCH 084/380] fix(vertex_ai): drop unsupported output_config parameter from all requests Vertex AI does not support the output_config parameter in its API. This parameter is being added by Anthropic/Gemini transformations but needs to be removed before sending requests to Vertex AI endpoints. This fix addresses the "Extra inputs are not permitted" error (issue #22312) when using Claude models with structured outputs on Vertex AI. Changes: - Drop output_config in Gemini model transformation - Drop output_config in Anthropic partner model transformation - Drop output_config in Anthropic experimental pass-through transformation - Add comprehensive tests to verify output_config is dropped Fixes: #22312 Made-with: Cursor --- .../llms/vertex_ai/gemini/transformation.py | 2 + .../transformation.py | 4 + .../anthropic/transformation.py | 3 + ...partner_models_anthropic_transformation.py | 109 ++++++++++++++++++ 4 files changed, 118 insertions(+) diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index b8343d735b4..57889284a8c 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -595,6 +595,8 @@ def _transform_request_body( safety_settings: Optional[List[SafetSettingsConfig]] = optional_params.pop( "safety_settings", None ) # type: ignore + # Drop output_config as it's not supported by Vertex AI + optional_params.pop("output_config", None) config_fields = GenerationConfig.__annotations__.keys() # If the LiteLLM client sends Gemini-supported parameter "labels", add it diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py index e05e64988d4..6bede1a2352 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py @@ -152,4 +152,8 @@ class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, Vert "output_format", None ) # do not pass output_format in request body to vertex ai - vertex ai does not support output_format as yet + anthropic_messages_request.pop( + "output_config", None + ) # do not pass output_config in request body to vertex ai - vertex ai does not support output_config + return anthropic_messages_request diff --git a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py index 78418799eb1..4e2c2895f9e 100644 --- a/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py +++ b/litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/transformation.py @@ -107,6 +107,9 @@ class VertexAIAnthropicConfig(AnthropicConfig): # VertexAI doesn't support output_format parameter, remove it if present data.pop("output_format", None) + + # VertexAI doesn't support output_config parameter, remove it if present + data.pop("output_config", None) tools = optional_params.get("tools") tool_search_used = self.is_tool_search_used(tools) diff --git a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py index 24e8162c344..4712a3585b8 100644 --- a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py @@ -489,3 +489,112 @@ def test_vertex_ai_partner_models_anthropic_remove_prompt_caching_scope_beta_hea assert ( "anthropic-beta" not in headers2 ), "Header should be removed if no supported values remain" + + +def test_vertex_ai_anthropic_output_config_dropped(): + """ + Test that output_config parameter is dropped from Vertex AI Anthropic requests. + + Vertex AI does not support the output_config parameter (used for effort settings + in Anthropic API). This test ensures it's properly removed to prevent + "Extra inputs are not permitted" errors. + """ + config = VertexAIAnthropicConfig() + + messages = [{"role": "user", "content": "What is 2+2?"}] + headers = {} + + # Simulate optional_params with output_config that would be passed in + optional_params = { + "max_tokens": 1024, + "output_config": { + "effort": "high" # This is Anthropic-specific and not supported by Vertex AI + }, + } + + # Call transform_request which should drop output_config + result = config.transform_request( + model="claude-3-5-sonnet-20241022", + messages=messages, + optional_params=optional_params, + litellm_params={}, + headers=headers, + ) + + # Verify output_config was removed + assert "output_config" not in result, \ + "output_config should be dropped from Vertex AI Anthropic requests" + + # Verify other parameters are preserved + assert result["max_tokens"] == 1024, "max_tokens should be preserved" + assert "messages" in result, "messages should be present" + + +def test_vertex_ai_anthropic_output_format_and_output_config_both_dropped(): + """ + Test that both output_format and output_config are dropped from Vertex AI requests. + + This ensures that even if both parameters somehow make it to the transform_request, + they are properly cleaned up before sending to Vertex AI. + """ + config = VertexAIAnthropicConfig() + + messages = [{"role": "user", "content": "Extract structured data"}] + headers = {} + + optional_params = { + "max_tokens": 2048, + "output_format": { + "type": "json_schema", + "json_schema": { + "name": "data", + "schema": {"type": "object", "properties": {"result": {"type": "string"}}} + } + }, + "output_config": { + "effort": "high" + }, + } + + # Simulate parent class creating test_data with both parameters + # (as if the parent transform_request added them) + test_data = { + "model": "claude-3-5-sonnet-20241022", + "messages": messages, + "max_tokens": 2048, + "output_format": optional_params["output_format"], + "output_config": optional_params["output_config"], + } + + # Mock the parent transform_request to return data with both parameters + original_transform = config.__class__.__bases__[0].transform_request + + def mock_transform_request(self, model, messages, optional_params, litellm_params, headers): + return test_data.copy() + + config.__class__.__bases__[0].transform_request = mock_transform_request + + try: + result = config.transform_request( + model="claude-3-5-sonnet-20241022", + messages=messages, + optional_params=optional_params, + litellm_params={}, + headers=headers, + ) + + # Verify both were removed + assert "output_format" not in result, \ + "output_format should be dropped from Vertex AI requests" + assert "output_config" not in result, \ + "output_config should be dropped from Vertex AI requests" + + # Verify essential params are preserved + assert result["max_tokens"] == 2048, "max_tokens should be preserved" + assert "messages" in result, "messages should be present" + assert "model" not in result, "model should also be dropped for Vertex AI" + + finally: + # Restore original method + config.__class__.__bases__[0].transform_request = original_transform + From 028e6871dd5f8611f84c1e2dc853f44e506e5a92 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 5 Mar 2026 14:27:51 +0530 Subject: [PATCH 085/380] feat(agents): add static_headers and extra_headers fields to schema and types Add two new fields to LiteLLM_AgentsTable: - static_headers (Json): admin-configured headers always sent to the backend agent - extra_headers (String[]): header names to extract from the client request and forward Extend AgentConfig, PatchAgentRequest, and AgentResponse with the same fields. Also remove duplicate spec_path field from LiteLLM_MCPServerTable. Co-Authored-By: Claude Sonnet 4.6 --- litellm/proxy/schema.prisma | 3 ++- litellm/types/agents.py | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 43972724ecc..6f4ef0c24b6 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -63,6 +63,8 @@ model LiteLLM_AgentsTable { agent_name String @unique litellm_params Json? agent_card_params Json + static_headers Json? @default("{}") + extra_headers String[] @default([]) agent_access_groups String[] @default([]) object_permission_id String? object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id]) @@ -305,7 +307,6 @@ model LiteLLM_MCPServerTable { registration_url String? allow_all_keys Boolean @default(false) available_on_public_internet Boolean @default(true) - spec_path String? is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? diff --git a/litellm/types/agents.py b/litellm/types/agents.py index 3ad898b1935..7879cae9ff6 100644 --- a/litellm/types/agents.py +++ b/litellm/types/agents.py @@ -179,6 +179,8 @@ class AgentConfig(TypedDict, total=False): agent_card_params: Required[AgentCard] litellm_params: Dict[str, Any] # allow for any future litellm params object_permission: AgentObjectPermission + static_headers: Optional[Dict[str, str]] + extra_headers: Optional[List[str]] class PatchAgentRequest(TypedDict, total=False): @@ -186,6 +188,8 @@ class PatchAgentRequest(TypedDict, total=False): agent_card_params: AgentCard litellm_params: Dict[str, Any] object_permission: AgentObjectPermission + static_headers: Optional[Dict[str, str]] + extra_headers: Optional[List[str]] # Request/Response models for CRUD endpoints @@ -197,6 +201,8 @@ class AgentResponse(BaseModel): litellm_params: Optional[Dict[str, Any]] = None agent_card_params: Dict[str, Any] object_permission: Optional[Dict[str, Any]] = None + static_headers: Optional[Dict[str, str]] = None + extra_headers: Optional[List[str]] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None created_by: Optional[str] = None From 07ee1e9886f54b773f4d9de7e3c8181e90d30d6e Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 5 Mar 2026 14:28:01 +0530 Subject: [PATCH 086/380] feat(agents): persist static_headers and extra_headers in agent registry Update add_agent_to_db, patch_agent_in_db, and update_agent_in_db to read and write the two new header fields when creating or updating agents. Co-Authored-By: Claude Sonnet 4.6 --- .../proxy/agent_endpoints/agent_registry.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/litellm/proxy/agent_endpoints/agent_registry.py b/litellm/proxy/agent_endpoints/agent_registry.py index 159c9fb93d9..550182f966f 100644 --- a/litellm/proxy/agent_endpoints/agent_registry.py +++ b/litellm/proxy/agent_endpoints/agent_registry.py @@ -128,6 +128,14 @@ class AgentRegistry: agent_copy, None, prisma_client ) + # Serialize static_headers + static_headers_obj = agent.get("static_headers") + static_headers_val: Optional[str] = ( + safe_dumps(dict(static_headers_obj)) if static_headers_obj else None + ) + + extra_headers_val: Optional[List[str]] = agent.get("extra_headers") + create_data: Dict[str, Any] = { "agent_name": agent_name, "litellm_params": litellm_params, @@ -137,6 +145,10 @@ class AgentRegistry: "created_at": datetime.now(timezone.utc), "updated_at": datetime.now(timezone.utc), } + if static_headers_val is not None: + create_data["static_headers"] = static_headers_val + if extra_headers_val is not None: + create_data["extra_headers"] = extra_headers_val if object_permission_id is not None: create_data["object_permission_id"] = object_permission_id @@ -214,6 +226,12 @@ class AgentRegistry: update_data["agent_card_params"] = safe_dumps( augment_agent.get("agent_card_params") ) + if agent.get("static_headers") is not None: + update_data["static_headers"] = safe_dumps( + dict(agent.get("static_headers")) # type: ignore + ) + if agent.get("extra_headers") is not None: + update_data["extra_headers"] = agent.get("extra_headers") if agent.get("object_permission") is not None: agent_copy = dict(augment_agent) existing_object_permission_id = existing_agent.get( @@ -281,6 +299,15 @@ class AgentRegistry: ) agent_card_params: str = safe_dumps(agent_card_params_dict) + # Serialize static_headers for update + static_headers_obj_u = agent.get("static_headers") + static_headers_val_u: Optional[str] = ( + safe_dumps(dict(static_headers_obj_u)) + if static_headers_obj_u is not None + else None + ) + extra_headers_val_u: Optional[List[str]] = agent.get("extra_headers") + update_data: Dict[str, Any] = { "agent_name": agent_name, "litellm_params": litellm_params, @@ -288,6 +315,10 @@ class AgentRegistry: "updated_by": updated_by, "updated_at": datetime.now(timezone.utc), } + if static_headers_val_u is not None: + update_data["static_headers"] = static_headers_val_u + if extra_headers_val_u is not None: + update_data["extra_headers"] = extra_headers_val_u if agent.get("object_permission") is not None: existing_agent = await prisma_client.db.litellm_agentstable.find_unique( where={"agent_id": agent_id} From 16a30b55f5493bbf0754aac0dc4ea4c54b681804 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 5 Mar 2026 14:28:11 +0530 Subject: [PATCH 087/380] feat(agents): add merge_agent_headers utility Mirrors merge_mcp_headers from the MCP server utils. Dynamic headers come first; static (admin-configured) headers overlay and win on conflict. Co-Authored-By: Claude Sonnet 4.6 --- litellm/proxy/agent_endpoints/utils.py | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 litellm/proxy/agent_endpoints/utils.py diff --git a/litellm/proxy/agent_endpoints/utils.py b/litellm/proxy/agent_endpoints/utils.py new file mode 100644 index 00000000000..2b968de54be --- /dev/null +++ b/litellm/proxy/agent_endpoints/utils.py @@ -0,0 +1,27 @@ +"""Utility helpers for A2A agent endpoints.""" + +from typing import Dict, Mapping, Optional + + +def merge_agent_headers( + *, + dynamic_headers: Optional[Mapping[str, str]] = None, + static_headers: Optional[Mapping[str, str]] = None, +) -> Optional[Dict[str, str]]: + """Merge outbound HTTP headers for A2A agent calls. + + Merge rules: + - Start with ``dynamic_headers`` (values extracted from the incoming client request). + - Overlay ``static_headers`` (admin-configured per agent). + + If both contain the same key, ``static_headers`` wins. + """ + merged: Dict[str, str] = {} + + if dynamic_headers: + merged.update({str(k): str(v) for k, v in dynamic_headers.items()}) + + if static_headers: + merged.update({str(k): str(v) for k, v in static_headers.items()}) + + return merged or None From 20a4eea27e71cfc5933670b73747fb46d66dd41d Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 5 Mar 2026 14:28:28 +0530 Subject: [PATCH 088/380] feat(agents): forward custom headers to backend A2A agents In invoke_agent_a2a: - Extract admin-configured extra_headers from client request by name - Extract convention-based headers (x-a2a-{agent_id/name}-{header}) from client request - Merge with static_headers (static wins on conflict) - Pass merged headers down to asend_message and _handle_stream_message In asend_message / asend_message_streaming: - Accept agent_extra_headers kwarg - Overlay onto LiteLLM internal headers before creating the httpx client Co-Authored-By: Claude Sonnet 4.6 --- litellm/a2a_protocol/main.py | 14 ++++++- .../proxy/agent_endpoints/a2a_endpoints.py | 37 ++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/litellm/a2a_protocol/main.py b/litellm/a2a_protocol/main.py index 485b57e311b..6ac88d3a430 100644 --- a/litellm/a2a_protocol/main.py +++ b/litellm/a2a_protocol/main.py @@ -169,6 +169,7 @@ async def asend_message( api_base: Optional[str] = None, litellm_params: Optional[Dict[str, Any]] = None, agent_id: Optional[str] = None, + agent_extra_headers: Optional[Dict[str, str]] = None, **kwargs: Any, ) -> LiteLLMSendMessageResponse: """ @@ -250,9 +251,12 @@ async def asend_message( "Either a2a_client or api_base is required for standard A2A flow" ) trace_id = trace_id or str(uuid.uuid4()) - extra_headers = {"X-LiteLLM-Trace-Id": trace_id} + extra_headers: Dict[str, str] = {"X-LiteLLM-Trace-Id": trace_id} if agent_id: extra_headers["X-LiteLLM-Agent-Id"] = agent_id + # Overlay agent-level headers (agent headers take precedence over LiteLLM internal ones) + if agent_extra_headers: + extra_headers.update(agent_extra_headers) a2a_client = await create_a2a_client( base_url=api_base, extra_headers=extra_headers ) @@ -426,6 +430,7 @@ async def asend_message_streaming( agent_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, proxy_server_request: Optional[Dict[str, Any]] = None, + agent_extra_headers: Optional[Dict[str, str]] = None, ) -> AsyncIterator[Any]: """ Async: Send a streaming message to an A2A agent. @@ -507,7 +512,12 @@ async def asend_message_streaming( raise ValueError( "Either a2a_client or api_base is required for standard A2A flow" ) - a2a_client = await create_a2a_client(base_url=api_base) + streaming_extra_headers: Optional[Dict[str, str]] = None + if agent_extra_headers: + streaming_extra_headers = dict(agent_extra_headers) + a2a_client = await create_a2a_client( + base_url=api_base, extra_headers=streaming_extra_headers + ) # Type assertion: a2a_client is guaranteed to be non-None here assert a2a_client is not None diff --git a/litellm/proxy/agent_endpoints/a2a_endpoints.py b/litellm/proxy/agent_endpoints/a2a_endpoints.py index 6bcee14f29e..344070d17fc 100644 --- a/litellm/proxy/agent_endpoints/a2a_endpoints.py +++ b/litellm/proxy/agent_endpoints/a2a_endpoints.py @@ -6,13 +6,14 @@ The A2A SDK can point to LiteLLM's URL and invoke agents registered with LiteLLM """ import json -from typing import Any, Optional +from typing import Any, Dict, Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import JSONResponse, StreamingResponse from litellm._logging import verbose_proxy_logger from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.agent_endpoints.utils import merge_agent_headers from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.types.utils import all_litellm_params @@ -55,6 +56,7 @@ async def _handle_stream_message( metadata: Optional[dict] = None, proxy_server_request: Optional[dict] = None, *, + agent_extra_headers: Optional[Dict[str, str]] = None, user_api_key_dict: Optional[UserAPIKeyAuth] = None, request_data: Optional[dict] = None, proxy_logging_obj: Optional[Any] = None, @@ -105,6 +107,7 @@ async def _handle_stream_message( agent_id=agent_id, metadata=metadata, proxy_server_request=proxy_server_request, + agent_extra_headers=agent_extra_headers, ) if ( @@ -385,6 +388,36 @@ async def invoke_agent_a2a( version=version, ) + # Build merged headers for the backend agent + static_headers: Dict[str, str] = dict(agent.static_headers or {}) + + raw_headers = dict(request.headers) + normalized = {k.lower(): v for k, v in raw_headers.items()} + + dynamic_headers: Dict[str, str] = {} + + # 1. Admin-configured extra_headers: forward named headers from client request + if agent.extra_headers: + for header_name in agent.extra_headers: + val = normalized.get(header_name.lower()) + if val is not None: + dynamic_headers[header_name] = val + + # 2. Convention-based forwarding: x-a2a-{agent_id_or_name}-{header_name} + # Matches both agent_id (UUID) and agent_name (alias), case-insensitive. + for alias in (agent.agent_id.lower(), agent.agent_name.lower()): + prefix = f"x-a2a-{alias}-" + for key, val in normalized.items(): + if key.startswith(prefix): + header_name = key[len(prefix) :] + if header_name: + dynamic_headers[header_name] = val + + agent_extra_headers = merge_agent_headers( + dynamic_headers=dynamic_headers or None, + static_headers=static_headers or None, + ) + # Route through SDK functions if method == "message/send": from a2a.types import MessageSendParams, SendMessageRequest @@ -401,6 +434,7 @@ async def invoke_agent_a2a( metadata=data.get("metadata", {}), proxy_server_request=data.get("proxy_server_request"), litellm_logging_obj=logging_obj, + agent_extra_headers=agent_extra_headers, ) response = await proxy_logging_obj.post_call_success_hook( @@ -425,6 +459,7 @@ async def invoke_agent_a2a( agent_id=agent.agent_id, metadata=data.get("metadata", {}), proxy_server_request=data.get("proxy_server_request"), + agent_extra_headers=agent_extra_headers, user_api_key_dict=user_api_key_dict, request_data=data, proxy_logging_obj=proxy_logging_obj, From 6e9c7c4a8dd8ddce1b911d77e2009aac3de5f9d3 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 5 Mar 2026 14:28:36 +0530 Subject: [PATCH 089/380] feat(agents): add Prisma migration for agent header columns ALTER TABLE LiteLLM_AgentsTable to add: - static_headers JSONB DEFAULT '{}' - extra_headers TEXT[] DEFAULT ARRAY[]::TEXT[] Co-Authored-By: Claude Sonnet 4.6 --- .../20260305000000_add_agent_headers/migration.sql | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260305000000_add_agent_headers/migration.sql diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260305000000_add_agent_headers/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260305000000_add_agent_headers/migration.sql new file mode 100644 index 00000000000..acb35baba96 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260305000000_add_agent_headers/migration.sql @@ -0,0 +1,5 @@ +-- Add static_headers and extra_headers to LiteLLM_AgentsTable + +ALTER TABLE "LiteLLM_AgentsTable" + ADD COLUMN IF NOT EXISTS "static_headers" JSONB DEFAULT '{}', + ADD COLUMN IF NOT EXISTS "extra_headers" TEXT[] DEFAULT ARRAY[]::TEXT[]; From fd53678898b71f6da4384e5984a4d1308f2ee060 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 5 Mar 2026 14:28:48 +0530 Subject: [PATCH 090/380] test(agents): add tests for A2A custom header forwarding Covers: - Static headers forwarded to backend - Dynamic headers extracted by name (extra_headers config) - Convention-based x-a2a-{agent_id/name}-{header} forwarding - Static headers win over dynamic on conflict - Unrelated x-a2a- prefixes are not forwarded - No-header case leaves existing behaviour unchanged - merge_agent_headers utility unit tests Co-Authored-By: Claude Sonnet 4.6 --- .../agent_endpoints/test_agent_headers.py | 339 ++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 tests/test_litellm/proxy/agent_endpoints/test_agent_headers.py diff --git a/tests/test_litellm/proxy/agent_endpoints/test_agent_headers.py b/tests/test_litellm/proxy/agent_endpoints/test_agent_headers.py new file mode 100644 index 00000000000..b52c0afb0c0 --- /dev/null +++ b/tests/test_litellm/proxy/agent_endpoints/test_agent_headers.py @@ -0,0 +1,339 @@ +""" +Unit tests for A2A agent custom header forwarding. + +Tests cover: +- Static headers forwarded to backend agent +- Dynamic headers extracted from client request and forwarded +- Static headers win over dynamic on conflict +- No headers configured — existing behavior unchanged +- merge_agent_headers utility +""" + +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Helper: build a minimal mock agent +# --------------------------------------------------------------------------- + +def _make_mock_agent( + static_headers=None, + extra_headers=None, + url="http://backend-agent:10001", +): + mock_agent = MagicMock() + mock_agent.agent_id = "agent-123" + mock_agent.agent_card_params = {"url": url, "name": "Test Agent"} + mock_agent.litellm_params = {} + mock_agent.static_headers = static_headers or {} + mock_agent.extra_headers = extra_headers or [] + return mock_agent + + +def _make_mock_request(extra_headers=None, method="message/send"): + """Build a mock FastAPI Request with configurable headers.""" + mock_request = MagicMock() + headers = {"content-type": "application/json"} + if extra_headers: + headers.update(extra_headers) + mock_request.headers = headers + mock_request.json = AsyncMock( + return_value={ + "jsonrpc": "2.0", + "id": "test-id", + "method": method, + "params": { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": "Hello"}], + "messageId": "msg-123", + } + }, + } + ) + return mock_request + + +def _make_a2a_types_module(): + """Return (module, MessageSendParams, SendMessageRequest, SendStreamingMessageRequest).""" + try: + from a2a.types import ( + MessageSendParams, + SendMessageRequest, + SendStreamingMessageRequest, + ) + mock_a2a_types = MagicMock() + mock_a2a_types.MessageSendParams = MessageSendParams + mock_a2a_types.SendMessageRequest = SendMessageRequest + mock_a2a_types.SendStreamingMessageRequest = SendStreamingMessageRequest + return mock_a2a_types + except ImportError: + pass + + def _make_cls(name): + class MockCls: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + self._kwargs = kwargs + + def model_dump(self, mode="json", exclude_none=False): + result = dict(self._kwargs) + if exclude_none: + result = {k: v for k, v in result.items() if v is not None} + return result + + MockCls.__name__ = name + return MockCls + + mock_a2a_types = MagicMock() + mock_a2a_types.MessageSendParams = _make_cls("MessageSendParams") + mock_a2a_types.SendMessageRequest = _make_cls("SendMessageRequest") + mock_a2a_types.SendStreamingMessageRequest = _make_cls( + "SendStreamingMessageRequest" + ) + return mock_a2a_types + + +async def _invoke(mock_agent, mock_request, mock_asend_message): + """Run invoke_agent_a2a with standard patches applied.""" + from litellm.proxy._types import UserAPIKeyAuth + + mock_user_api_key_dict = UserAPIKeyAuth(api_key="sk-test", user_id="u1") + mock_fastapi_response = MagicMock() + mock_a2a_types = _make_a2a_types_module() + + mock_response = MagicMock() + mock_response.model_dump.return_value = { + "jsonrpc": "2.0", + "id": "test-id", + "result": {"status": "success"}, + } + + with patch( + "litellm.proxy.agent_endpoints.a2a_endpoints._get_agent", + return_value=mock_agent, + ), patch( + "litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.is_agent_allowed", + new_callable=AsyncMock, + return_value=True, + ), patch( + "litellm.proxy.common_request_processing.add_litellm_data_to_request", + side_effect=lambda data, **kw: data, + ), patch( + "litellm.a2a_protocol.asend_message", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_asend, patch( + "litellm.a2a_protocol.create_a2a_client", + new_callable=AsyncMock, + ), patch( + "litellm.proxy.proxy_server.general_settings", + {}, + ), patch( + "litellm.proxy.proxy_server.proxy_config", + MagicMock(), + ), patch( + "litellm.proxy.proxy_server.version", + "1.0.0", + ), patch.dict( + sys.modules, + {"a2a": MagicMock(), "a2a.types": mock_a2a_types}, + ), patch( + "litellm.a2a_protocol.main.A2A_SDK_AVAILABLE", + True, + ): + from litellm.proxy.agent_endpoints.a2a_endpoints import invoke_agent_a2a + + await invoke_agent_a2a( + agent_id="test-agent", + request=mock_request, + fastapi_response=mock_fastapi_response, + user_api_key_dict=mock_user_api_key_dict, + ) + return mock_asend + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_static_headers_forwarded(): + """Static headers configured on the agent are passed to asend_message.""" + mock_agent = _make_mock_agent( + static_headers={"Authorization": "Bearer token123"} + ) + mock_request = _make_mock_request() + + mock_asend = await _invoke(mock_agent, mock_request, None) + + call_kwargs = mock_asend.call_args.kwargs + headers = call_kwargs.get("agent_extra_headers") + assert headers is not None, "agent_extra_headers should not be None" + assert headers.get("Authorization") == "Bearer token123" + + +@pytest.mark.asyncio +async def test_dynamic_headers_forwarded(): + """Dynamic headers listed in extra_headers are extracted from the client request.""" + mock_agent = _make_mock_agent(extra_headers=["x-api-key"]) + mock_request = _make_mock_request(extra_headers={"x-api-key": "secret"}) + + mock_asend = await _invoke(mock_agent, mock_request, None) + + call_kwargs = mock_asend.call_args.kwargs + headers = call_kwargs.get("agent_extra_headers") + assert headers is not None + assert headers.get("x-api-key") == "secret" + + +@pytest.mark.asyncio +async def test_static_overrides_dynamic(): + """When the same header appears in both static and dynamic, static wins.""" + mock_agent = _make_mock_agent( + static_headers={"Authorization": "Bearer static-token"}, + extra_headers=["Authorization"], + ) + # Client sends a different value for Authorization + mock_request = _make_mock_request( + extra_headers={"Authorization": "Bearer dynamic-token"} + ) + + mock_asend = await _invoke(mock_agent, mock_request, None) + + call_kwargs = mock_asend.call_args.kwargs + headers = call_kwargs.get("agent_extra_headers") + assert headers is not None + assert headers.get("Authorization") == "Bearer static-token" + + +@pytest.mark.asyncio +async def test_no_headers(): + """When no headers are configured, agent_extra_headers is None and behaviour is unchanged.""" + mock_agent = _make_mock_agent() # no static_headers or extra_headers + mock_request = _make_mock_request() + + mock_asend = await _invoke(mock_agent, mock_request, None) + + call_kwargs = mock_asend.call_args.kwargs + headers = call_kwargs.get("agent_extra_headers") + assert headers is None + + +# --------------------------------------------------------------------------- +# Convention-based x-a2a-{agent_id/name}-{header_name} tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_convention_header_by_agent_name(): + """x-a2a-{agent_name}-{header} is forwarded using the agent name alias.""" + mock_agent = _make_mock_agent() + mock_agent.agent_name = "my-agent" + mock_request = _make_mock_request( + extra_headers={"x-a2a-my-agent-authorization": "Bearer conv-token"} + ) + + mock_asend = await _invoke(mock_agent, mock_request, None) + + headers = mock_asend.call_args.kwargs.get("agent_extra_headers") + assert headers is not None + assert headers.get("authorization") == "Bearer conv-token" + + +@pytest.mark.asyncio +async def test_convention_header_by_agent_id(): + """x-a2a-{agent_id}-{header} is forwarded using the agent UUID.""" + mock_agent = _make_mock_agent() + mock_agent.agent_id = "abc-123" + mock_agent.agent_name = "other-name" + mock_request = _make_mock_request( + extra_headers={"x-a2a-abc-123-x-api-key": "id-secret"} + ) + + mock_asend = await _invoke(mock_agent, mock_request, None) + + headers = mock_asend.call_args.kwargs.get("agent_extra_headers") + assert headers is not None + assert headers.get("x-api-key") == "id-secret" + + +@pytest.mark.asyncio +async def test_convention_header_static_still_wins(): + """Static headers still override convention-based dynamic headers.""" + mock_agent = _make_mock_agent( + static_headers={"authorization": "Bearer static-wins"} + ) + mock_agent.agent_name = "my-agent" + mock_request = _make_mock_request( + extra_headers={"x-a2a-my-agent-authorization": "Bearer conv-value"} + ) + + mock_asend = await _invoke(mock_agent, mock_request, None) + + headers = mock_asend.call_args.kwargs.get("agent_extra_headers") + assert headers is not None + assert headers.get("authorization") == "Bearer static-wins" + + +@pytest.mark.asyncio +async def test_convention_unrelated_prefix_not_forwarded(): + """Headers that start with x-a2a- but target a different agent are ignored.""" + mock_agent = _make_mock_agent() + mock_agent.agent_id = "agent-abc" + mock_agent.agent_name = "my-agent" + mock_request = _make_mock_request( + extra_headers={"x-a2a-other-agent-authorization": "Bearer wrong"} + ) + + mock_asend = await _invoke(mock_agent, mock_request, None) + + headers = mock_asend.call_args.kwargs.get("agent_extra_headers") + assert headers is None + + +# --------------------------------------------------------------------------- +# Direct unit test for the merge utility +# --------------------------------------------------------------------------- + + +def test_merge_agent_headers_util_dynamic_only(): + from litellm.proxy.agent_endpoints.utils import merge_agent_headers + + result = merge_agent_headers(dynamic_headers={"x-key": "val"}) + assert result == {"x-key": "val"} + + +def test_merge_agent_headers_util_static_only(): + from litellm.proxy.agent_endpoints.utils import merge_agent_headers + + result = merge_agent_headers(static_headers={"Authorization": "Bearer tok"}) + assert result == {"Authorization": "Bearer tok"} + + +def test_merge_agent_headers_util_static_wins(): + from litellm.proxy.agent_endpoints.utils import merge_agent_headers + + result = merge_agent_headers( + dynamic_headers={"Authorization": "dynamic", "x-extra": "d"}, + static_headers={"Authorization": "static"}, + ) + assert result == {"Authorization": "static", "x-extra": "d"} + + +def test_merge_agent_headers_util_none_returns_none(): + from litellm.proxy.agent_endpoints.utils import merge_agent_headers + + result = merge_agent_headers() + assert result is None + + +def test_merge_agent_headers_util_empty_dicts_returns_none(): + from litellm.proxy.agent_endpoints.utils import merge_agent_headers + + result = merge_agent_headers(dynamic_headers={}, static_headers={}) + assert result is None From 36d279ab42c20185d435d502f18f895487249ab3 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 5 Mar 2026 14:34:11 +0530 Subject: [PATCH 091/380] feat(ui/agents): add Authentication Headers section to agent create/edit form Add a new "Authentication Headers" panel to AgentFormFields: - Static Headers: key-value Form.List (always sent to the backend agent, static wins on conflict with dynamic) - Forward Client Headers: Select[tags] of header names to extract from the client request and forward (extra_headers) Update buildAgentDataFromForm to serialize both fields for the API. Update parseAgentForForm to deserialize them back for editing. Covers both the create wizard (add_agent_form) and the edit view (agent_info). Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/agents/agent_config.ts | 26 +++++++ .../components/agents/agent_form_fields.tsx | 70 ++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/components/agents/agent_config.ts b/ui/litellm-dashboard/src/components/agents/agent_config.ts index f85c4daac66..01041c5cee4 100644 --- a/ui/litellm-dashboard/src/components/agents/agent_config.ts +++ b/ui/litellm-dashboard/src/components/agents/agent_config.ts @@ -269,6 +269,23 @@ export const buildAgentDataFromForm = (values: any, existingAgent?: any) => { agentData.litellm_params = params; } + // static_headers: convert [{header, value}, ...] → {header: value, ...} + if (Array.isArray(values.static_headers) && values.static_headers.length > 0) { + const staticHeaders: Record = {}; + values.static_headers.forEach((entry: { header?: string; value?: string }) => { + const key = entry?.header?.trim(); + if (key) staticHeaders[key] = entry?.value ?? ""; + }); + if (Object.keys(staticHeaders).length > 0) { + agentData.static_headers = staticHeaders; + } + } + + // extra_headers: already an array of strings from Select tags + if (Array.isArray(values.extra_headers) && values.extra_headers.length > 0) { + agentData.extra_headers = values.extra_headers; + } + return agentData; }; @@ -302,5 +319,14 @@ export const parseAgentForForm = (agent: any) => { cost_per_query: agent.litellm_params?.cost_per_query, input_cost_per_token: agent.litellm_params?.input_cost_per_token, output_cost_per_token: agent.litellm_params?.output_cost_per_token, + // static_headers: {key: value} → [{header, value}, ...] + static_headers: agent.static_headers + ? Object.entries(agent.static_headers as Record).map(([header, value]) => ({ + header, + value, + })) + : [], + // extra_headers: already an array of strings + extra_headers: agent.extra_headers ?? [], }; }; diff --git a/ui/litellm-dashboard/src/components/agents/agent_form_fields.tsx b/ui/litellm-dashboard/src/components/agents/agent_form_fields.tsx index d5429d2a3b5..42e55b8c56f 100644 --- a/ui/litellm-dashboard/src/components/agents/agent_form_fields.tsx +++ b/ui/litellm-dashboard/src/components/agents/agent_form_fields.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { Form, Input, Switch, Collapse } from "antd"; +import { Form, Input, Switch, Collapse, Select, Space, Tooltip } from "antd"; import { Button as AntButton } from "antd"; -import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons"; +import { PlusOutlined, MinusCircleOutlined, InfoCircleOutlined } from "@ant-design/icons"; import { AGENT_FORM_CONFIG, SKILL_FIELD_CONFIG } from "./agent_config"; import CostConfigFields from "./cost_config_fields"; @@ -188,6 +188,72 @@ const AgentFormFields: React.FC = ({ showAgentName = true, ))} )} + + {/* Authentication Headers */} + {shouldShow("auth_headers") && ( + + {/* Static Headers */} + + Static Headers{" "} + + + + + } + > + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }) => ( + + + + + + + + remove(name)} style={{ color: "#ff4d4f" }} /> + + ))} + add()} icon={} style={{ width: "100%" }}> + Add Static Header + + + )} + + + + {/* Extra Headers (dynamic forwarding) */} + + Forward Client Headers{" "} + + + + + } + name="extra_headers" + > + + )} + + ); + }; + + return ( + + + + + } + onCancel={handleCancel} + > +
+ {FIELD_GROUPS.map((group, index) => ( +
+ {index > 0 && } + + {group.title} + + {group.subtitle && ( + + {group.subtitle} + + )} + {group.fields.map(renderField)} +
+ ))} +
+
+ ); +}; + +export default EditHashicorpVaultModal; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.tsx new file mode 100644 index 00000000000..a2693903c52 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { useState } from "react"; +import { useHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useHashicorpVaultConfig"; +import { useDeleteHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useDeleteHashicorpVaultConfig"; +import { useUpdateHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; +import NotificationManager from "@/components/molecules/notifications_manager"; +import { testHashicorpVaultConnection } from "@/components/networking"; +import { Alert, Button, Card, Descriptions, Skeleton, Space, Typography } from "antd"; +import { Edit, KeyRound, PlugZap, Trash2 } from "lucide-react"; +import { SENSITIVE_FIELDS, FIELD_LABELS } from "./constants"; +import EditHashicorpVaultModal from "./EditHashicorpVaultModal"; +import HashicorpVaultEmptyPlaceholder from "./HashicorpVaultEmptyPlaceholder"; + +const { Title, Text } = Typography; + +function detectAuthMethod(values: Record): string { + if (values.vault_token) return "Token"; + if (values.approle_role_id || values.approle_secret_id) return "AppRole"; + return "None"; +} + +const descriptionsConfig = { + column: { xxl: 1, xl: 1, lg: 1, md: 1, sm: 1, xs: 1 }, +}; + +export default function HashicorpVault() { + const { accessToken } = useAuthorized(); + const { data, isLoading, isError, error, refetch } = useHashicorpVaultConfig(); + const { mutate: deleteConfig, isPending: isDeleting } = useDeleteHashicorpVaultConfig(accessToken); + const { mutateAsync: updateConfig } = useUpdateHashicorpVaultConfig(accessToken); + + const [isEditModalVisible, setIsEditModalVisible] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [clearingField, setClearingField] = useState(null); + const [isClearingField, setIsClearingField] = useState(false); + const [isTesting, setIsTesting] = useState(false); + + const rawValues = data?.values ?? {}; + const isConfigured = Boolean(rawValues.vault_addr); + + const handleTestConnection = async () => { + if (!accessToken) return; + setIsTesting(true); + try { + const result = await testHashicorpVaultConnection(accessToken); + NotificationManager.success(result.message || "Connection to Vault successful!"); + } catch (err) { + NotificationManager.fromBackend(err); + } finally { + setIsTesting(false); + } + }; + + const handleDelete = () => { + deleteConfig(undefined, { + onSuccess: () => { + NotificationManager.success("Hashicorp Vault configuration deleted"); + setIsDeleteModalOpen(false); + }, + onError: (err) => { + NotificationManager.fromBackend(err); + }, + }); + }; + + const handleClearField = async () => { + if (!clearingField) return; + setIsClearingField(true); + try { + await updateConfig({ [clearingField]: "" }); + NotificationManager.success(`${FIELD_LABELS[clearingField] ?? clearingField} cleared`); + setClearingField(null); + refetch(); + } catch (err) { + NotificationManager.fromBackend(err); + } finally { + setIsClearingField(false); + } + }; + + const renderValue = (key: string) => { + const value = rawValues[key]; + if (!value) { + return Not configured; + } + if (SENSITIVE_FIELDS.has(key)) { + return ( +
+ {value} +
+ ); + } + return {value}; + }; + + const renderSettings = () => { + // Only show fields that have values, plus auth method + const fieldsToShow = Object.entries(rawValues).filter( + ([_, value]) => value != null && value !== "" + ); + + if (fieldsToShow.length === 0) return null; + + return ( + + + {detectAuthMethod(rawValues)} + + {fieldsToShow.map(([key]) => ( + + {renderValue(key)} + + ))} + + ); + }; + + return ( + <> + {isLoading ? ( + + + + ) : isError ? ( + + + + ) : ( + + + + {/* Header */} +
+
+ +
+ Hashicorp Vault + Manage secret manager configuration +
+
+ +
+ {isConfigured && ( + <> + + + + + )} +
+
+ + {isConfigured && ( + + vault kv put secret/SECRET_NAME key=secret_value +
+ + View documentation + + + } + /> + )} + + {isConfigured ? ( + renderSettings() + ) : ( + setIsEditModalVisible(true)} /> + )} +
+
+
+ )} + + setIsEditModalVisible(false)} + onSuccess={() => { + setIsEditModalVisible(false); + refetch(); + }} + /> + + setIsDeleteModalOpen(false)} + onOk={handleDelete} + confirmLoading={isDeleting} + /> + + setClearingField(null)} + onOk={handleClearField} + confirmLoading={isClearingField} + /> + + ); +} diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVaultEmptyPlaceholder.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVaultEmptyPlaceholder.tsx new file mode 100644 index 00000000000..49860fc7617 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/HashicorpVaultEmptyPlaceholder.tsx @@ -0,0 +1,30 @@ +import { Empty, Typography, Button } from "antd"; + +const { Title, Paragraph } = Typography; + +interface HashicorpVaultEmptyPlaceholderProps { + onAdd: () => void; +} + +export default function HashicorpVaultEmptyPlaceholder({ onAdd }: HashicorpVaultEmptyPlaceholderProps) { + return ( +
+ + No Vault Configuration Found + + Configure Hashicorp Vault to securely manage provider API keys and secrets + for your LiteLLM deployment. + +
+ } + > + + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/constants.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/constants.ts new file mode 100644 index 00000000000..ef924f5f122 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/HashicorpVault/constants.ts @@ -0,0 +1,20 @@ +export const SENSITIVE_FIELDS = new Set([ + "vault_token", + "approle_role_id", + "approle_secret_id", + "client_key", +]); + +export const FIELD_LABELS: Record = { + vault_addr: "Vault Address", + vault_namespace: "Namespace", + vault_mount_name: "KV Mount Name", + vault_path_prefix: "Path Prefix", + vault_token: "Token", + approle_role_id: "Role ID", + approle_secret_id: "Secret ID", + approle_mount_path: "Mount Path", + client_cert: "Client Certificate", + client_key: "Client Key", + vault_cert_role: "Certificate Role", +}; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 0df6d813d7c..a8f6013726c 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -9659,6 +9659,95 @@ export const updateUiSettings = async (accessToken: string, settings: Record { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/config_overrides/hashicorp_vault` + : `/config_overrides/hashicorp_vault`; + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + }, + }); + if (!response.ok) { + const errorData = await response.json(); + const detail = errorData?.detail; + const errorMessage = + (typeof detail === "object" && detail?.error) || + (typeof detail === "string" && detail) || + deriveErrorMessage(errorData); + throw new Error(errorMessage); + } + const data = await response.json(); + return data; +}; + +export const updateHashicorpVaultConfig = async ( + accessToken: string, + config: Record, +) => { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/config_overrides/hashicorp_vault` + : `/config_overrides/hashicorp_vault`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(config), + }); + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + throw new Error(errorMessage); + } + const data = await response.json(); + return data; +}; + +export const deleteHashicorpVaultConfig = async (accessToken: string) => { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/config_overrides/hashicorp_vault` + : `/config_overrides/hashicorp_vault`; + const response = await fetch(url, { + method: "DELETE", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + }, + }); + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + throw new Error(errorMessage); + } + const data = await response.json(); + return data; +}; + +export const testHashicorpVaultConnection = async (accessToken: string) => { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/config_overrides/hashicorp_vault/test_connection` + : `/config_overrides/hashicorp_vault/test_connection`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + }, + }); + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + throw new Error(errorMessage); + } + const data = await response.json(); + return data; +}; + // ============================================================ // Claude Code Marketplace Networking Functions // ============================================================ From 21718d208d78eec53e668891956bffc7d5d7fd32 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 5 Mar 2026 16:34:11 -0800 Subject: [PATCH 144/380] feat: Hashicorp Vault config override backend endpoints Add CRUD endpoints for managing Hashicorp Vault configuration via the proxy admin API, with background sync, env var management, and connection testing. Fix pre-existing bug where premium check ran after global state mutation, and guard DELETE against clearing non-Vault secret managers. --- .../config_override_endpoints.py | 405 ++++++++++++++++++ litellm/proxy/proxy_server.py | 69 +++ litellm/proxy/schema.prisma | 8 + .../hashicorp_secret_manager.py | 21 +- .../management_endpoints/config_overrides.py | 64 +++ .../test_config_override_endpoints.py | 251 +++++++++++ 6 files changed, 809 insertions(+), 9 deletions(-) create mode 100644 litellm/proxy/management_endpoints/config_override_endpoints.py create mode 100644 litellm/types/proxy/management_endpoints/config_overrides.py create mode 100644 tests/test_litellm/proxy/management_endpoints/test_config_override_endpoints.py diff --git a/litellm/proxy/management_endpoints/config_override_endpoints.py b/litellm/proxy/management_endpoints/config_override_endpoints.py new file mode 100644 index 00000000000..2978a523fb1 --- /dev/null +++ b/litellm/proxy/management_endpoints/config_override_endpoints.py @@ -0,0 +1,405 @@ +import json +import os +from typing import Any, Dict, Set + +from fastapi import APIRouter, Depends, HTTPException +from prisma.errors import RecordNotFoundError +from pydantic import TypeAdapter + +import litellm +from litellm._logging import verbose_proxy_logger +from litellm.llms.custom_httpx.http_handler import _get_httpx_client +from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker +from litellm.proxy._types import CommonProxyErrors, KeyManagementSystem, LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.types.proxy.management_endpoints.config_overrides import ( + ConfigOverrideSettingsResponse, + HashicorpVaultConfig, +) + +router = APIRouter() + +# --- Hashicorp Vault constants --- + +HASHICORP_ENV_VAR_MAPPING: Dict[str, str] = { + "vault_addr": "HCP_VAULT_ADDR", + "vault_token": "HCP_VAULT_TOKEN", + "approle_role_id": "HCP_VAULT_APPROLE_ROLE_ID", + "approle_secret_id": "HCP_VAULT_APPROLE_SECRET_ID", + "approle_mount_path": "HCP_VAULT_APPROLE_MOUNT_PATH", + "client_cert": "HCP_VAULT_CLIENT_CERT", + "client_key": "HCP_VAULT_CLIENT_KEY", + "vault_cert_role": "HCP_VAULT_CERT_ROLE", + "vault_namespace": "HCP_VAULT_NAMESPACE", + "vault_mount_name": "HCP_VAULT_MOUNT_NAME", + "vault_path_prefix": "HCP_VAULT_PATH_PREFIX", +} + +HASHICORP_SENSITIVE_FIELDS: Set[str] = { + "vault_token", + "approle_role_id", + "approle_secret_id", + "client_key", +} + +_sensitive_masker = SensitiveDataMasker() + + +# --- Shared helpers --- + + +def _mask_sensitive_fields( + data: Dict[str, Any], sensitive_fields: Set[str] +) -> Dict[str, Any]: + """Mask sensitive fields for API responses. Non-sensitive fields are left as-is.""" + masked = {} + for key, value in data.items(): + if value is not None and key in sensitive_fields and isinstance(value, str): + masked[key] = _sensitive_masker._mask_value(value) + else: + masked[key] = value + return masked + + +def _get_current_env_values(env_var_mapping: Dict[str, str]) -> Dict[str, Any]: + """Read current env var values as fallback when no DB record exists.""" + values = {} + for field_name, env_var_name in env_var_mapping.items(): + env_value = os.environ.get(env_var_name) + values[field_name] = env_value + return values + + +def _extract_field_type(field_info: Dict[str, Any]) -> str: + """Extract the non-null type from a Pydantic v2 JSON schema field.""" + if "type" in field_info: + return field_info["type"] + for option in field_info.get("anyOf", []): + if option.get("type") != "null": + return option.get("type", "string") + return "string" + + +def _build_field_schema(model_class: type) -> Dict[str, Any]: + """Build field_schema dict from a Pydantic model for UI rendering.""" + schema = TypeAdapter(model_class).json_schema(by_alias=True) + properties = {} + for field_name, field_info in schema.get("properties", {}).items(): + properties[field_name] = { + "description": field_info.get("description", ""), + "type": _extract_field_type(field_info), + } + return { + "description": schema.get("description", ""), + "properties": properties, + } + + +def _parse_config_value(raw: Any) -> Dict[str, Any]: + """Parse a config_value from DB (may be JSON string or dict).""" + if isinstance(raw, str): + return json.loads(raw) + return dict(raw) + + +def _set_env_vars(config_data: Dict[str, Any]) -> None: + """Set HCP_VAULT_* env vars from config data. Unsets vars for missing/None/empty fields.""" + for field_name, env_var_name in HASHICORP_ENV_VAR_MAPPING.items(): + value = config_data.get(field_name) + if value is not None and value != "": + os.environ[env_var_name] = str(value) + else: + os.environ.pop(env_var_name, None) + + +def _clear_hashicorp_vault_state(proxy_config: Any) -> None: + """Clear all Hashicorp Vault state: env vars, secret manager, and change-detection cache.""" + _set_env_vars({}) + if litellm._key_management_system == KeyManagementSystem.HASHICORP_VAULT: + litellm.secret_manager_client = None + litellm._key_management_system = None + proxy_config._last_hashicorp_vault_config = None + + +# --- Hashicorp Vault endpoints --- + + +@router.post( + "/config_overrides/hashicorp_vault", + tags=["Config Overrides"], + dependencies=[Depends(user_api_key_auth)], +) +async def update_hashicorp_vault_config( + config: HashicorpVaultConfig, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Update Hashicorp Vault secret manager configuration. + Sets environment variables, encrypts sensitive fields, and stores in DB. + Reinitializes the secret manager on this pod. + """ + from litellm.proxy.proxy_server import prisma_client, proxy_config + + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail="Only admin users can update config overrides", + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail=CommonProxyErrors.db_not_connected_error.value, + ) + + config_data = config.model_dump(exclude_none=True) + + # Merge ALL fields the user didn't send: try DB first, fall back to env vars. + # Omitted field = keep existing; empty string = clear/remove the field. + existing_record = await prisma_client.db.litellm_configoverrides.find_unique( + where={"config_type": "hashicorp_vault"} + ) + if existing_record is not None and existing_record.config_value is not None: + existing_data = _parse_config_value(existing_record.config_value) + existing_decrypted = proxy_config._decrypt_db_variables(existing_data) + for field in HASHICORP_ENV_VAR_MAPPING: + if field not in config_data and existing_decrypted.get(field): + config_data[field] = existing_decrypted[field] + else: + # No DB record yet — merge from current env vars + env_values = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING) + for field in HASHICORP_ENV_VAR_MAPPING: + if field not in config_data and env_values.get(field): + config_data[field] = env_values[field] + + # Strip empty strings — they signal "clear this field" + config_data = {k: v for k, v in config_data.items() if v != ""} + + # Validate that the config has enough fields to initialize + has_vault_addr = bool(config_data.get("vault_addr")) + has_token_auth = bool(config_data.get("vault_token")) + has_approle_auth = bool( + config_data.get("approle_role_id") and config_data.get("approle_secret_id") + ) + has_tls_cert_auth = bool( + config_data.get("client_cert") and config_data.get("client_key") + ) + + if not has_vault_addr: + raise HTTPException( + status_code=400, + detail="Vault Address is required", + ) + + if not has_token_auth and not has_approle_auth and not has_tls_cert_auth: + raise HTTPException( + status_code=400, + detail="At least one authentication method is required: " + "provide a Token, both AppRole Role ID and Secret ID, " + "or both Client Certificate and Client Key", + ) + + # Snapshot current env vars so we can restore on failure + previous_env = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING) + + # Set env vars and verify the secret manager can initialize before persisting + _set_env_vars(config_data) + + try: + proxy_config.initialize_secret_manager( + key_management_system="hashicorp_vault" + ) + except Exception as e: + _set_env_vars(previous_env) + verbose_proxy_logger.exception( + "Error reinitializing Hashicorp Vault secret manager: %s", str(e) + ) + raise HTTPException( + status_code=500, + detail="Failed to initialize secret manager", + ) + + # Only persist to DB after successful init + encrypted_data = proxy_config._encrypt_env_variables(config_data) + config_value = json.dumps(encrypted_data) + await prisma_client.db.litellm_configoverrides.upsert( + where={"config_type": "hashicorp_vault"}, + data={ + "create": { + "config_type": "hashicorp_vault", + "config_value": config_value, + }, + "update": { + "config_value": config_value, + }, + }, + ) + + # Update change-detection cache so the background reload doesn't redundantly re-init + proxy_config._last_hashicorp_vault_config = json.loads(config_value) + + return { + "message": "Hashicorp Vault configuration updated successfully", + "status": "success", + } + + +@router.get( + "/config_overrides/hashicorp_vault", + tags=["Config Overrides"], + dependencies=[Depends(user_api_key_auth)], + response_model=ConfigOverrideSettingsResponse, +) +async def get_hashicorp_vault_config( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Get current Hashicorp Vault configuration. + Returns decrypted values from DB, or falls back to current env vars. + """ + from litellm.proxy.proxy_server import prisma_client, proxy_config + + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail="Only admin users can view config overrides", + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail=CommonProxyErrors.db_not_connected_error.value, + ) + + field_schema = _build_field_schema(HashicorpVaultConfig) + + # Try to load from DB + db_record = await prisma_client.db.litellm_configoverrides.find_unique( + where={"config_type": "hashicorp_vault"} + ) + + if db_record is not None and db_record.config_value is not None: + config_data = _parse_config_value(db_record.config_value) + + # Decrypt then mask sensitive fields so plaintext secrets are never sent to the UI + decrypted_data = proxy_config._decrypt_db_variables(config_data) + masked_data = _mask_sensitive_fields( + decrypted_data, HASHICORP_SENSITIVE_FIELDS + ) + + return ConfigOverrideSettingsResponse( + config_type="hashicorp_vault", + values=masked_data, + field_schema=field_schema, + ) + + # Fallback to env vars — also mask sensitive values + env_values = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING) + masked_env_values = _mask_sensitive_fields( + env_values, HASHICORP_SENSITIVE_FIELDS + ) + + return ConfigOverrideSettingsResponse( + config_type="hashicorp_vault", + values=masked_env_values, + field_schema=field_schema, + ) + + +@router.delete( + "/config_overrides/hashicorp_vault", + tags=["Config Overrides"], + dependencies=[Depends(user_api_key_auth)], +) +async def delete_hashicorp_vault_config( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """Delete Hashicorp Vault configuration. Idempotent.""" + from litellm.proxy.proxy_server import prisma_client, proxy_config + + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail="Only admin users can delete config overrides", + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail=CommonProxyErrors.db_not_connected_error.value, + ) + + # Delete DB record if it exists — ignore if not found + try: + await prisma_client.db.litellm_configoverrides.delete( + where={"config_type": "hashicorp_vault"} + ) + except RecordNotFoundError: + verbose_proxy_logger.debug( + "No existing Hashicorp Vault config record to delete" + ) + + _clear_hashicorp_vault_state(proxy_config) + + return { + "message": "Hashicorp Vault configuration deleted successfully", + "status": "success", + } + + +@router.post( + "/config_overrides/hashicorp_vault/test_connection", + tags=["Config Overrides"], + dependencies=[Depends(user_api_key_auth)], +) +async def test_hashicorp_vault_connection( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Test the connection to the currently configured Hashicorp Vault. + Uses the already-initialized secret manager client. Does not modify any state. + """ + from litellm.secret_managers.hashicorp_secret_manager import ( + HashicorpSecretManager, + ) + + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail="Only admin users can test Vault connection", + ) + + client = litellm.secret_manager_client + if not isinstance(client, HashicorpSecretManager): + raise HTTPException( + status_code=400, + detail="Hashicorp Vault is not configured. Save a configuration first.", + ) + + # Step 1: Authenticate (exercises AppRole login, TLS cert login, or direct token) + try: + headers = client._get_request_headers() + except Exception as e: + raise HTTPException( + status_code=502, + detail="Vault authentication failed", + ) + + # Step 2: Verify the token is valid via token/lookup-self + try: + sync_client = _get_httpx_client() + lookup_url = f"{client.vault_addr}/v1/auth/token/lookup-self" + if client.vault_namespace: + headers["X-Vault-Namespace"] = client.vault_namespace + response = sync_client.get(lookup_url, headers=headers) + response.raise_for_status() + except Exception as e: + raise HTTPException( + status_code=502, + detail="Vault token validation failed", + ) + + return { + "status": "success", + "message": f"Successfully connected to Vault at {client.vault_addr}", + } diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index bc2728c2203..f409774c7c1 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -346,6 +346,9 @@ from litellm.proxy.management_endpoints.cache_settings_endpoints import ( from litellm.proxy.management_endpoints.callback_management_endpoints import ( router as callback_management_endpoints_router, ) +from litellm.proxy.management_endpoints.config_override_endpoints import ( + router as config_override_router, +) from litellm.proxy.management_endpoints.common_utils import ( _user_has_admin_privileges, admin_can_invite_user, @@ -2235,6 +2238,7 @@ class ProxyConfig: def __init__(self) -> None: self.config: Dict[str, Any] = {} self._last_semantic_filter_config: Optional[Dict[str, Any]] = None + self._last_hashicorp_vault_config: Optional[Dict[str, Any]] = None def is_yaml(self, config_file_path: str) -> bool: if not os.path.isfile(config_file_path): @@ -4432,6 +4436,11 @@ class ProxyConfig: if self._should_load_db_object(object_type="semantic_filter_settings"): await self._init_semantic_filter_settings_in_db(prisma_client=prisma_client) + if self._should_load_db_object(object_type="config_overrides"): + await self._init_hashicorp_vault_config_override( + prisma_client=prisma_client + ) + async def _init_semantic_filter_settings_in_db(self, prisma_client: PrismaClient): """ Initialize MCP semantic filter settings from database. @@ -4541,6 +4550,65 @@ class ProxyConfig: ) ) + async def _init_hashicorp_vault_config_override( + self, prisma_client: PrismaClient + ): + """ + Load Hashicorp Vault config override from DB. + Decrypts sensitive fields, sets HCP_VAULT_* env vars, and reinitializes the secret manager. + Called periodically via _init_non_llm_objects_in_db to sync config across pods. + """ + from litellm.proxy.management_endpoints.config_override_endpoints import ( + HASHICORP_ENV_VAR_MAPPING, + _clear_hashicorp_vault_state, + _get_current_env_values, + _parse_config_value, + _set_env_vars, + ) + + try: + db_record = await prisma_client.db.litellm_configoverrides.find_unique( + where={"config_type": "hashicorp_vault"} + ) + + if db_record is None or db_record.config_value is None: + if self._last_hashicorp_vault_config is not None: + _clear_hashicorp_vault_state(self) + return + + config_data = _parse_config_value(db_record.config_value) + + # Skip reinit if config hasn't changed since last poll + if self._last_hashicorp_vault_config == config_data: + return + + # Decrypt all fields and set env vars + decrypted_data = self._decrypt_db_variables(config_data) + + # Snapshot current env vars so we can restore on failure + previous_env = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING) + _set_env_vars(decrypted_data) + + # Reinitialize the secret manager + try: + self.initialize_secret_manager( + key_management_system="hashicorp_vault" + ) + except Exception: + # Restore previous working env vars instead of wiping all + _set_env_vars(previous_env) + raise + + self._last_hashicorp_vault_config = config_data.copy() + verbose_proxy_logger.debug( + "Hashicorp Vault config override loaded from DB" + ) + except Exception as e: + verbose_proxy_logger.exception( + "Error loading Hashicorp Vault config override from DB: %s", + str(e), + ) + async def _check_and_reload_model_cost_map(self, prisma_client: PrismaClient): """ Check if model cost map needs to be reloaded based on database configuration. @@ -12971,6 +13039,7 @@ app.include_router(cost_tracking_settings_router) app.include_router(router_settings_router) app.include_router(fallback_management_router) app.include_router(cache_settings_router) +app.include_router(config_override_router) app.include_router(user_agent_analytics_router) app.include_router(enterprise_router) app.include_router(ui_discovery_endpoints_router) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index f18556ac329..fa646808a4a 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -1000,6 +1000,14 @@ model LiteLLM_UISettings { updated_at DateTime @updatedAt } +// Generic config overrides table - one row per config_type +model LiteLLM_ConfigOverrides { + config_type String @id + config_value Json + created_at DateTime @default(now()) + updated_at DateTime @updatedAt +} + // Skills table for storing LiteLLM-managed skills model LiteLLM_SkillsTable { skill_id String @id @default(uuid()) diff --git a/litellm/secret_managers/hashicorp_secret_manager.py b/litellm/secret_managers/hashicorp_secret_manager.py index c59f2ef638a..ccee5018eec 100644 --- a/litellm/secret_managers/hashicorp_secret_manager.py +++ b/litellm/secret_managers/hashicorp_secret_manager.py @@ -44,6 +44,11 @@ class HashicorpSecretManager(BaseSecretManager): self._verify_required_credentials_exist() + if premium_user is not True: + raise ValueError( + f"Hashicorp secret manager is only available for premium users. {CommonProxyErrors.not_premium_user.value}" + ) + litellm.secret_manager_client = self litellm._key_management_system = KeyManagementSystem.HASHICORP_VAULT _refresh_interval = os.environ.get( @@ -58,11 +63,6 @@ class HashicorpSecretManager(BaseSecretManager): default_ttl=_refresh_interval ) # store in memory for 1 day - if premium_user is not True: - raise ValueError( - f"Hashicorp secret manager is only available for premium users. {CommonProxyErrors.not_premium_user.value}" - ) - def _verify_required_credentials_exist(self) -> None: """ Validate that at least one authentication method is configured. @@ -70,13 +70,16 @@ class HashicorpSecretManager(BaseSecretManager): Raises: ValueError: If no valid authentication credentials are provided """ - if not self.vault_token and not ( - self.approle_role_id and self.approle_secret_id - ): + has_token = bool(self.vault_token) + has_approle = bool(self.approle_role_id and self.approle_secret_id) + has_tls_cert = bool(self.tls_cert_path and self.tls_key_path) + + if not has_token and not has_approle and not has_tls_cert: raise ValueError( "Missing Vault authentication credentials. Please set either:\n" " - HCP_VAULT_TOKEN for token-based auth, or\n" - " - HCP_VAULT_APPROLE_ROLE_ID and HCP_VAULT_APPROLE_SECRET_ID for AppRole auth" + " - HCP_VAULT_APPROLE_ROLE_ID and HCP_VAULT_APPROLE_SECRET_ID for AppRole auth, or\n" + " - HCP_VAULT_CLIENT_CERT and HCP_VAULT_CLIENT_KEY for TLS certificate auth" ) def _auth_via_approle(self) -> str: diff --git a/litellm/types/proxy/management_endpoints/config_overrides.py b/litellm/types/proxy/management_endpoints/config_overrides.py new file mode 100644 index 00000000000..6f5d661f57a --- /dev/null +++ b/litellm/types/proxy/management_endpoints/config_overrides.py @@ -0,0 +1,64 @@ +from typing import Any, Dict, Optional + +from pydantic import BaseModel, Field + + +class HashicorpVaultConfig(BaseModel): + """Configuration for Hashicorp Vault secret manager integration.""" + + vault_addr: Optional[str] = Field( + default=None, + description="The address of the Vault server (e.g., https://vault.example.com:8200)", + ) + vault_token: Optional[str] = Field( + default=None, + description="Token for Vault token-based authentication", + ) + approle_role_id: Optional[str] = Field( + default=None, + description="Role ID for Vault AppRole authentication", + ) + approle_secret_id: Optional[str] = Field( + default=None, + description="Secret ID for Vault AppRole authentication", + ) + approle_mount_path: Optional[str] = Field( + default=None, + description="Mount path for the AppRole auth method (default: approle)", + ) + client_cert: Optional[str] = Field( + default=None, + description="Path to the client TLS certificate for Vault", + ) + client_key: Optional[str] = Field( + default=None, + description="Path to the client TLS private key for Vault", + ) + vault_cert_role: Optional[str] = Field( + default=None, + description="Certificate role name for TLS cert authentication", + ) + vault_namespace: Optional[str] = Field( + default=None, + description="Vault namespace (for multi-tenant Vault, sent as X-Vault-Namespace header)", + ) + vault_mount_name: Optional[str] = Field( + default=None, + description="KV engine mount name (default: secret)", + ) + vault_path_prefix: Optional[str] = Field( + default=None, + description="Optional path prefix for secrets (e.g., myapp -> secret/data/myapp/{secret_name})", + ) + + +class ConfigOverrideSettingsResponse(BaseModel): + """Response model for config override settings GET endpoints.""" + + config_type: str = Field(description="The type of config override") + values: Dict[str, Any] = Field( + description="Current configuration values (sensitive fields decrypted)" + ) + field_schema: Dict[str, Any] = Field( + description="Schema information for UI rendering" + ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_config_override_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_config_override_endpoints.py new file mode 100644 index 00000000000..22258dc80c6 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_config_override_endpoints.py @@ -0,0 +1,251 @@ +import json +import os +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi.testclient import TestClient +from prisma.errors import RecordNotFoundError + +import litellm +import litellm.proxy.proxy_server as ps +from litellm.proxy._types import KeyManagementSystem, LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.management_endpoints.config_override_endpoints import ( + HASHICORP_ENV_VAR_MAPPING, + _build_field_schema, + _set_env_vars, +) +from litellm.proxy.proxy_server import app +from litellm.types.proxy.management_endpoints.config_overrides import ( + HashicorpVaultConfig, +) + +VAULT_URL = "/config_overrides/hashicorp_vault" + + +@pytest.fixture +def client(): + return TestClient(app) + + +def _make_mock_db(): + mock = MagicMock() + mock.find_unique = AsyncMock(return_value=None) + mock.upsert = AsyncMock(return_value=None) + mock.delete = AsyncMock(return_value=None) + prisma = MagicMock() + prisma.db.litellm_configoverrides = mock + return prisma, mock + + +def _make_mock_proxy_config(): + cfg = MagicMock() + cfg.initialize_secret_manager = MagicMock() + cfg._last_hashicorp_vault_config = None + cfg._encrypt_env_variables = MagicMock( + side_effect=lambda d: {k: f"enc_{v}" for k, v in d.items()} + ) + cfg._decrypt_db_variables = MagicMock( + side_effect=lambda d: { + k: v.replace("enc_", "") if isinstance(v, str) else v + for k, v in d.items() + } + ) + return cfg + + +def _upserted_data(mock_db): + return json.loads(mock_db.upsert.call_args.kwargs["data"]["create"]["config_value"]) + + +def _db_record(data): + rec = MagicMock() + rec.config_value = json.dumps(data) + return rec + + +def _cleanup(): + app.dependency_overrides.pop(ps.user_api_key_auth, None) + for env_var in HASHICORP_ENV_VAR_MAPPING.values(): + os.environ.pop(env_var, None) + + +def _set_admin(): + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user" + ) + + +@pytest.mark.asyncio +async def test_hashicorp_vault_crud_lifecycle(client, monkeypatch): + """Create → read (masked) → partial update (merge from DB) → clear field → + only-provided fields → delete → idempotent delete → env fallback → + merge from env → helpers → encrypt/decrypt roundtrip.""" + mock_prisma, mock_db = _make_mock_db() + mock_cfg = _make_mock_proxy_config() + monkeypatch.setattr(ps, "prisma_client", mock_prisma) + monkeypatch.setattr(ps, "proxy_config", mock_cfg) + old_client, old_kms = litellm.secret_manager_client, litellm._key_management_system + _set_admin() + + try: + # 1. POST: create + r = client.post(VAULT_URL, json={ + "vault_addr": "https://vault.example.com", + "vault_token": "my-secret-vault-token", + "vault_namespace": "admin", + "vault_mount_name": "secret", + }) + assert r.status_code == 200 + assert os.environ["HCP_VAULT_ADDR"] == "https://vault.example.com" + data = _upserted_data(mock_db) + assert data["vault_token"] == "enc_my-secret-vault-token" + mock_cfg.initialize_secret_manager.assert_called_with(key_management_system="hashicorp_vault") + assert mock_cfg._last_hashicorp_vault_config is not None + + # 2. GET: sensitive fields masked + mock_db.find_unique = AsyncMock(return_value=_db_record(data)) + r = client.get(VAULT_URL) + assert r.status_code == 200 + vals = r.json()["values"] + assert vals["vault_addr"] == "https://vault.example.com" + assert "*" in vals["vault_token"] + assert "properties" in r.json()["field_schema"] + + # 3. POST partial: omitted fields merge from DB + r = client.post(VAULT_URL, json={"vault_addr": "https://vault.new.com"}) + assert r.status_code == 200 + data = _upserted_data(mock_db) + assert data["vault_addr"] == "enc_https://vault.new.com" + assert data["vault_token"] == "enc_my-secret-vault-token" + assert data["vault_namespace"] == "enc_admin" + + # 4. POST empty string: clears field, preserves others + step3 = {**data, "approle_role_id": "enc_role", "approle_secret_id": "enc_secret"} + mock_db.find_unique = AsyncMock(return_value=_db_record(step3)) + mock_db.upsert = AsyncMock(return_value=None) + r = client.post(VAULT_URL, json={"vault_token": ""}) + assert r.status_code == 200 + data = _upserted_data(mock_db) + assert "vault_token" not in data + assert data["approle_role_id"] == "enc_role" + + # 5. POST only provided fields (clean slate) + for v in HASHICORP_ENV_VAR_MAPPING.values(): + os.environ.pop(v, None) + mock_db.find_unique = AsyncMock(return_value=None) + mock_db.upsert = AsyncMock(return_value=None) + r = client.post(VAULT_URL, json={"vault_addr": "https://v.com", "vault_token": "tok"}) + assert r.status_code == 200 + assert _upserted_data(mock_db) == {"vault_addr": "enc_https://v.com", "vault_token": "enc_tok"} + + # 6. DELETE: clears everything + litellm.secret_manager_client = MagicMock() + litellm._key_management_system = KeyManagementSystem.HASHICORP_VAULT + r = client.delete(VAULT_URL) + assert r.status_code == 200 + assert os.environ.get("HCP_VAULT_ADDR") is None + assert litellm.secret_manager_client is None + + # 7. DELETE idempotent + mock_db.delete = AsyncMock( + side_effect=RecordNotFoundError(data={"clientVersion": "0.0.0"}, message="Not found") + ) + assert client.delete(VAULT_URL).status_code == 200 + + # 8. GET: env var fallback + mock_db.find_unique = AsyncMock(return_value=None) + monkeypatch.setenv("HCP_VAULT_ADDR", "https://vault.env.com") + monkeypatch.setenv("HCP_VAULT_NAMESPACE", "env-ns") + r = client.get(VAULT_URL) + assert r.json()["values"]["vault_addr"] == "https://vault.env.com" + + # 9. POST: merge from env vars + monkeypatch.setenv("HCP_VAULT_TOKEN", "env-token") + monkeypatch.setenv("HCP_VAULT_MOUNT_NAME", "env-mount") + mock_cfg.initialize_secret_manager = MagicMock() + mock_db.upsert = AsyncMock(return_value=None) + r = client.post(VAULT_URL, json={"vault_addr": "https://vault.merged.com"}) + assert r.status_code == 200 + data = _upserted_data(mock_db) + assert data["vault_token"] == "enc_env-token" + assert data["vault_mount_name"] == "enc_env-mount" + + # 10. _set_env_vars: empty string unsets + monkeypatch.setenv("HCP_VAULT_TOKEN", "existing") + _set_env_vars({"vault_token": "", "vault_addr": "https://v.com"}) + assert os.environ.get("HCP_VAULT_TOKEN") is None + assert os.environ["HCP_VAULT_ADDR"] == "https://v.com" + + # 11. _build_field_schema + schema = _build_field_schema(HashicorpVaultConfig) + assert "vault_addr" in schema["properties"] + assert len(schema["properties"]["vault_addr"]["description"]) > 0 + + # 12. encrypt/decrypt roundtrip + from litellm.proxy.proxy_server import ProxyConfig + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-key") + pc = ProxyConfig() + orig = {"vault_addr": "https://v.com", "vault_token": "secret"} + encrypted = pc._encrypt_env_variables(orig) + assert all(encrypted[k] != orig[k] for k in orig) + decrypted = pc._decrypt_db_variables(encrypted) + assert all(decrypted[k] == orig[k] for k in orig) + + finally: + litellm.secret_manager_client = old_client + litellm._key_management_system = old_kms + _cleanup() + + +@pytest.mark.asyncio +async def test_hashicorp_vault_validation_errors_and_access_control(client, monkeypatch): + """Validation (missing fields, init failure rollback), DELETE preserves + non-Vault secret managers, non-admin 403 on all endpoints.""" + mock_prisma, mock_db = _make_mock_db() + mock_cfg = MagicMock() + mock_cfg._last_hashicorp_vault_config = {"vault_addr": "old"} + monkeypatch.setattr(ps, "prisma_client", mock_prisma) + monkeypatch.setattr(ps, "proxy_config", mock_cfg) + old_client, old_kms = litellm.secret_manager_client, litellm._key_management_system + _set_admin() + + try: + # 1. Missing vault_addr → 400 + r = client.post(VAULT_URL, json={"vault_token": "tok"}) + assert r.status_code == 400 + assert "Vault Address" in r.json()["detail"] + + # 2. Missing auth → 400 + r = client.post(VAULT_URL, json={"vault_addr": "https://v.com"}) + assert r.status_code == 400 + assert "authentication" in r.json()["detail"].lower() + + # 3. Init failure → 500, env vars restored + mock_cfg.initialize_secret_manager = MagicMock(side_effect=Exception("fail")) + monkeypatch.setenv("HCP_VAULT_ADDR", "https://vault.old.com") + monkeypatch.setenv("HCP_VAULT_TOKEN", "old-token") + r = client.post(VAULT_URL, json={"vault_addr": "https://bad.com", "vault_token": "bad"}) + assert r.status_code == 500 + assert os.environ["HCP_VAULT_ADDR"] == "https://vault.old.com" + mock_db.upsert.assert_not_awaited() + + # 4. DELETE preserves non-Vault secret manager + aws = MagicMock() + litellm.secret_manager_client = aws + litellm._key_management_system = KeyManagementSystem.AWS_SECRET_MANAGER + assert client.delete(VAULT_URL).status_code == 200 + assert litellm.secret_manager_client is aws + assert litellm._key_management_system == KeyManagementSystem.AWS_SECRET_MANAGER + + # 5. Non-admin → 403 + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, user_id="user" + ) + assert client.get(VAULT_URL).status_code == 403 + assert client.post(VAULT_URL, json={"vault_addr": "https://v.com"}).status_code == 403 + assert client.delete(VAULT_URL).status_code == 403 + + finally: + litellm.secret_manager_client = old_client + litellm._key_management_system = old_kms + _cleanup() From 53a1e31729b105cb61decd359031319ae0205c10 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Thu, 5 Mar 2026 16:58:46 -0800 Subject: [PATCH 145/380] feat(spend-logs): add truncation note when error logs are truncated for DB storage (#22936) When the messages or response JSON fields in spend logs are truncated before being written to the database, the truncation marker now includes a note explaining: - This is a DB storage safeguard - Full, untruncated data is still sent to logging callbacks (OTEL, Datadog, etc.) - The MAX_STRING_LENGTH_PROMPT_IN_DB env var can be used to increase the limit Also emits a verbose_proxy_logger.info message when truncation occurs in the request body or response spend log paths. Adds 3 new tests: - test_truncation_includes_db_safeguard_note - test_response_truncation_logs_info_message - test_request_body_truncation_logs_info_message Co-authored-by: Cursor Agent --- litellm/constants.py | 5 ++ .../spend_tracking/spend_tracking_utils.py | 28 +++++- .../test_spend_tracking_utils.py | 86 +++++++++++++++++-- 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index c1bb7da1b73..2ae365300ef 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1242,6 +1242,11 @@ X_LITELLM_DISABLE_CALLBACKS = "x-litellm-disable-callbacks" LITELLM_METADATA_FIELD = "litellm_metadata" OLD_LITELLM_METADATA_FIELD = "metadata" LITELLM_TRUNCATED_PAYLOAD_FIELD = "litellm_truncated" +LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE = ( + "Truncation is a DB storage safeguard. " + "Full, untruncated data is logged to logging callbacks (OTEL, Datadog, etc.). " + "To increase the truncation limit, set `MAX_STRING_LENGTH_PROMPT_IN_DB` in your env." +) ########################### LiteLLM Proxy Specific Constants ########################### ######################################################################################## diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 131841f7b59..f381432a089 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -11,6 +11,10 @@ from pydantic import BaseModel import litellm from litellm._logging import verbose_proxy_logger +from litellm.constants import ( + LITELLM_TRUNCATED_PAYLOAD_FIELD, + LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, +) from litellm.constants import \ MAX_STRING_LENGTH_PROMPT_IN_DB as DEFAULT_MAX_STRING_LENGTH_PROMPT_IN_DB from litellm.constants import REDACTED_BY_LITELM_STRING @@ -628,7 +632,10 @@ def _sanitize_request_body_for_spend_logs_payload( Recursively sanitize request body to prevent logging large base64 strings or other large values. Truncates strings longer than MAX_STRING_LENGTH_PROMPT_IN_DB characters and handles nested dictionaries. """ - from litellm.constants import LITELLM_TRUNCATED_PAYLOAD_FIELD + from litellm.constants import ( + LITELLM_TRUNCATED_PAYLOAD_FIELD, + LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, + ) if visited is None: visited = set() @@ -674,7 +681,8 @@ def _sanitize_request_body_for_spend_logs_payload( # Build the truncated string: beginning + truncation marker + end truncated_value = ( f"{value[:start_chars]}" - f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars) ..." + f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars. " + f"{LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE}) ..." f"{value[-end_chars:]}" ) return truncated_value @@ -791,6 +799,11 @@ def _get_proxy_server_request_for_spend_logs_payload( _request_body = _sanitize_request_body_for_spend_logs_payload(_request_body) _request_body_json_str = json.dumps(_request_body, default=str) + if LITELLM_TRUNCATED_PAYLOAD_FIELD in _request_body_json_str: + verbose_proxy_logger.info( + "Spend Log: request body was truncated before storing in DB. %s", + LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, + ) return _request_body_json_str return "{}" @@ -866,8 +879,15 @@ def _get_response_for_spend_logs_payload( if sanitized_response is None: return "{}" if isinstance(sanitized_response, str): - return sanitized_response - return safe_dumps(sanitized_response) + result_str = sanitized_response + else: + result_str = safe_dumps(sanitized_response) + if LITELLM_TRUNCATED_PAYLOAD_FIELD in result_str: + verbose_proxy_logger.info( + "Spend Log: response was truncated before storing in DB. %s", + LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, + ) + return result_str return "{}" diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py index 24f45cc5c91..9a64e641b5e 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py @@ -16,7 +16,11 @@ sys.path.insert( from unittest.mock import AsyncMock, MagicMock, patch import litellm -from litellm.constants import LITELLM_TRUNCATED_PAYLOAD_FIELD, REDACTED_BY_LITELM_STRING +from litellm.constants import ( + LITELLM_TRUNCATED_PAYLOAD_FIELD, + LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, + REDACTED_BY_LITELM_STRING, +) from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy.spend_tracking.spend_tracking_utils import ( _get_messages_for_spend_logs_payload, @@ -60,7 +64,7 @@ def test_sanitize_request_body_for_spend_logs_payload_long_string(): end_chars = MAX_STRING_LENGTH_PROMPT_IN_DB - start_chars skipped_chars = len(long_string) - (start_chars + end_chars) - expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars) ..." + expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars. {LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE}) ..." expected_length = start_chars + len(expected_truncation_message) + end_chars assert len(sanitized["text"]) == expected_length @@ -86,7 +90,7 @@ def test_sanitize_request_body_for_spend_logs_payload_nested_dict(): end_chars = MAX_STRING_LENGTH_PROMPT_IN_DB - start_chars skipped_chars = len(long_string) - total_keep - expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars) ..." + expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars. {LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE}) ..." expected_length = start_chars + len(expected_truncation_message) + end_chars assert len(sanitized["outer"]["inner"]["text"]) == expected_length @@ -111,7 +115,7 @@ def test_sanitize_request_body_for_spend_logs_payload_nested_list(): end_chars = MAX_STRING_LENGTH_PROMPT_IN_DB - start_chars skipped_chars = len(long_string) - total_keep - expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars) ..." + expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars. {LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE}) ..." expected_length = start_chars + len(expected_truncation_message) + end_chars assert len(sanitized["items"][0]["text"]) == expected_length @@ -151,7 +155,7 @@ def test_sanitize_request_body_for_spend_logs_payload_mixed_types(): end_chars = MAX_STRING_LENGTH_PROMPT_IN_DB - start_chars skipped_chars = len(long_string) - total_keep - expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars) ..." + expected_truncation_message = f"... ({LITELLM_TRUNCATED_PAYLOAD_FIELD} skipped {skipped_chars} chars. {LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE}) ..." expected_length = start_chars + len(expected_truncation_message) + end_chars assert len(sanitized["text"]) == expected_length @@ -396,6 +400,78 @@ def test_get_response_for_spend_logs_payload_truncates_large_embedding(mock_shou assert parsed["data"][0]["other_field"] == "value" +def test_truncation_includes_db_safeguard_note(): + """ + Test that truncated content includes the DB safeguard note explaining + that full data is available in OTEL/other logging integrations. + """ + from litellm.constants import MAX_STRING_LENGTH_PROMPT_IN_DB + + large_error = "Error: " + "x" * (MAX_STRING_LENGTH_PROMPT_IN_DB + 1000) + request_body = {"error_trace": large_error} + sanitized = _sanitize_request_body_for_spend_logs_payload(request_body) + + truncated = sanitized["error_trace"] + assert LITELLM_TRUNCATED_PAYLOAD_FIELD in truncated + assert LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE in truncated + assert "DB storage safeguard" in truncated + assert "logging callbacks" in truncated.lower() or "logging integrations" in truncated.lower() or "logging callbacks" in truncated + + +@patch( + "litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs" +) +def test_response_truncation_logs_info_message(mock_should_store): + """ + Test that when response is truncated before DB storage, an info log is emitted + noting that full data is available in OTEL/other integrations. + """ + from litellm.constants import MAX_STRING_LENGTH_PROMPT_IN_DB + + mock_should_store.return_value = True + large_text = "B" * (MAX_STRING_LENGTH_PROMPT_IN_DB + 500) + payload = cast( + StandardLoggingPayload, + {"response": {"data": [{"content": large_text}]}}, + ) + + with patch( + "litellm.proxy.spend_tracking.spend_tracking_utils.verbose_proxy_logger" + ) as mock_logger: + _get_response_for_spend_logs_payload(payload) + mock_logger.info.assert_called_once() + log_msg = mock_logger.info.call_args[0][0] + assert "response was truncated" in log_msg + + +@patch( + "litellm.proxy.spend_tracking.spend_tracking_utils._should_store_prompts_and_responses_in_spend_logs" +) +def test_request_body_truncation_logs_info_message(mock_should_store): + """ + Test that when request body is truncated before DB storage, an info log is emitted. + """ + from litellm.constants import MAX_STRING_LENGTH_PROMPT_IN_DB + + mock_should_store.return_value = True + large_prompt = "C" * (MAX_STRING_LENGTH_PROMPT_IN_DB + 500) + litellm_params = { + "proxy_server_request": { + "body": {"messages": [{"role": "user", "content": large_prompt}]} + } + } + + with patch( + "litellm.proxy.spend_tracking.spend_tracking_utils.verbose_proxy_logger" + ) as mock_logger: + _get_proxy_server_request_for_spend_logs_payload( + metadata={}, litellm_params=litellm_params, kwargs={} + ) + mock_logger.info.assert_called_once() + log_msg = mock_logger.info.call_args[0][0] + assert "request body was truncated" in log_msg + + def test_safe_dumps_handles_circular_references(): """Test that safe_dumps can handle circular references without raising exceptions""" From d0e480414ce23c2c278d1c7f5885afc6d1dd1e4e Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 5 Mar 2026 17:00:51 -0800 Subject: [PATCH 146/380] Fix team usage spend showing lower than expected values The /team/daily/activity endpoint used Prisma pagination (page_size=1000) but the UI only fetched page 1. Teams with many keys/models easily exceed 1000 rows in LiteLLM_DailyTeamSpend, causing truncated totals. Switches the endpoint to use SQL GROUP BY via get_daily_activity_aggregated with include_entity_breakdown=True, returning all data in a single response while preserving per-team breakdown. Also adds timezone parameter support. Co-Authored-By: Claude Opus 4.6 --- .../common_daily_activity.py | 44 +++++-- .../management_endpoints/team_endpoints.py | 23 ++-- .../test_team_endpoints.py | 110 +++++++++++++----- 3 files changed, 126 insertions(+), 51 deletions(-) diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 02961748e7c..a4fbeb7e28f 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -474,16 +474,21 @@ def _build_aggregated_sql_query( start_date: str, end_date: str, model: Optional[str], - api_key: Optional[str], + api_key: Optional[Union[str, List[str]]], exclude_entity_ids: Optional[List[str]] = None, timezone_offset_minutes: Optional[int] = None, + include_entity_id: bool = False, ) -> Tuple[str, List[Any]]: """Build a parameterized SQL GROUP BY query for aggregated daily activity. Groups by (date, api_key, model, model_group, custom_llm_provider, mcp_namespaced_tool_name, endpoint) with SUMs on all metric columns. - The entity_id column is intentionally omitted from GROUP BY to collapse - rows across entities — this is where the biggest row reduction comes from. + + When include_entity_id is False (default), the entity_id column is omitted + from GROUP BY to collapse rows across entities. + + When include_entity_id is True, the entity_id column is included in both + SELECT and GROUP BY, preserving per-entity breakdown in the results. Returns: Tuple of (sql_query, params_list) ready for prisma_client.db.query_raw(). @@ -538,14 +543,24 @@ def _build_aggregated_sql_query( # Optional api_key filter if api_key: - sql_conditions.append(f"api_key = ${p}") - sql_params.append(api_key) - p += 1 + if isinstance(api_key, list): + placeholders = ", ".join(f"${p + i}" for i in range(len(api_key))) + sql_conditions.append(f"api_key IN ({placeholders})") + sql_params.extend(api_key) + p += len(api_key) + else: + sql_conditions.append(f"api_key = ${p}") + sql_params.append(api_key) + p += 1 where_clause = " AND ".join(sql_conditions) + entity_select = f'"{entity_id_field}",' if include_entity_id else "" + entity_group_by = f'"{entity_id_field}",' if include_entity_id else "" + sql_query = f""" SELECT + {entity_select} date, api_key, model, @@ -563,7 +578,7 @@ def _build_aggregated_sql_query( SUM(failed_requests)::bigint AS failed_requests FROM "{pg_table}" WHERE {where_clause} - GROUP BY date, api_key, model, model_group, custom_llm_provider, + GROUP BY {entity_group_by} date, api_key, model, model_group, custom_llm_provider, mcp_namespaced_tool_name, endpoint ORDER BY date DESC """ @@ -735,9 +750,10 @@ async def get_daily_activity_aggregated( start_date: Optional[str], end_date: Optional[str], model: Optional[str], - api_key: Optional[str], + api_key: Optional[Union[str, List[str]]], exclude_entity_ids: Optional[List[str]] = None, timezone_offset_minutes: Optional[int] = None, + include_entity_breakdown: bool = False, ) -> SpendAnalyticsPaginatedResponse: """Aggregated variant that returns the full result set (no pagination). @@ -745,6 +761,11 @@ async def get_daily_activity_aggregated( all individual rows into Python. This collapses rows across entities (users/teams/orgs), reducing ~150k rows to ~2-3k grouped rows. + When include_entity_breakdown is True, the entity_id column is included + in the GROUP BY so that per-entity breakdown data is preserved in the + response (e.g. per-team spend). This is needed for entity-specific views + like the team usage dashboard. + Matches the response model of the paginated endpoint so the UI does not need to transform. """ if prisma_client is None: @@ -770,6 +791,7 @@ async def get_daily_activity_aggregated( api_key=api_key, exclude_entity_ids=exclude_entity_ids, timezone_offset_minutes=timezone_offset_minutes, + include_entity_id=include_entity_breakdown, ) # Execute GROUP BY query — returns pre-aggregated dicts @@ -780,13 +802,11 @@ async def get_daily_activity_aggregated( # Convert dicts to objects for compatibility with _aggregate_spend_records records = [SimpleNamespace(**row) for row in rows] - # entity_id_field=None skips entity breakdown (entity dimension was - # collapsed by the GROUP BY, so per-entity data is not available) aggregated = await _aggregate_spend_records( prisma_client=prisma_client, records=records, - entity_id_field=None, - entity_metadata_field=None, + entity_id_field=entity_id_field if include_entity_breakdown else None, + entity_metadata_field=entity_metadata_field if include_entity_breakdown else None, ) return SpendAnalyticsPaginatedResponse( diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 80d50f31a17..5e7a0931b2a 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -77,8 +77,8 @@ from litellm.proxy.management_endpoints.common_utils import ( _upsert_budget_and_membership, _user_has_admin_view, ) -from litellm.proxy.management_endpoints.tag_management_endpoints import ( - get_daily_activity, +from litellm.proxy.management_endpoints.common_daily_activity import ( + get_daily_activity_aggregated, ) from litellm.proxy.management_helpers.object_permission_utils import ( _set_object_permission, @@ -3890,22 +3890,27 @@ async def get_team_daily_activity( page: int = 1, page_size: int = 10, exclude_team_ids: Optional[str] = None, + timezone: Optional[int] = None, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ Get daily activity for specific teams or all teams. + Uses SQL GROUP BY to aggregate all matching rows without pagination, + ensuring accurate total spend regardless of data volume. + Args: team_ids (Optional[str]): Comma-separated list of team IDs to filter by. If not provided, returns data for all teams. start_date (Optional[str]): Start date for the activity period (YYYY-MM-DD). end_date (Optional[str]): End date for the activity period (YYYY-MM-DD). model (Optional[str]): Filter by model name. api_key (Optional[str]): Filter by API key. - page (int): Page number for pagination. - page_size (int): Number of items per page. + page (int): Deprecated, kept for backward compatibility. All results are returned in a single page. + page_size (int): Deprecated, kept for backward compatibility. exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude. + timezone (Optional[int]): Timezone offset in minutes from UTC (e.g., 480 for PST). Returns: - SpendAnalyticsPaginatedResponse: Paginated response containing daily activity data. + SpendAnalyticsPaginatedResponse: Response containing daily activity data with per-team breakdown. """ from litellm.proxy.proxy_server import ( prisma_client, @@ -4009,17 +4014,17 @@ async def get_team_daily_activity( if final_api_key_filter is None and user_api_keys is not None: final_api_key_filter = user_api_keys - return await get_daily_activity( + return await get_daily_activity_aggregated( prisma_client=prisma_client, table_name="litellm_dailyteamspend", entity_id_field="team_id", entity_id=team_ids_list, entity_metadata_field=team_alias_metadata, - exclude_entity_ids=exclude_team_ids_list, start_date=start_date, end_date=end_date, model=model, api_key=final_api_key_filter, - page=page, - page_size=page_size, + exclude_entity_ids=exclude_team_ids_list, + timezone_offset_minutes=timezone, + include_entity_breakdown=True, ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index b6ac974e2cf..0a2a7e0c432 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -5379,10 +5379,10 @@ async def test_get_team_daily_activity_non_admin_filters_by_user_api_keys( # Mock get_daily_activity to capture the api_key parameter with patch( - "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity", + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated", new_callable=AsyncMock, - ) as mock_get_daily_activity: - mock_get_daily_activity.return_value = MagicMock() + ) as mock_get_daily_activity_agg: + mock_get_daily_activity_agg.return_value = MagicMock() # Call the endpoint await get_team_daily_activity( @@ -5398,8 +5398,8 @@ async def test_get_team_daily_activity_non_admin_filters_by_user_api_keys( ) # Verify get_daily_activity was called with user's API keys as filter - mock_get_daily_activity.assert_called_once() - call_kwargs = mock_get_daily_activity.call_args[1] + mock_get_daily_activity_agg.assert_called_once() + call_kwargs = mock_get_daily_activity_agg.call_args[1] assert call_kwargs["api_key"] == ["user_key_1", "user_key_2"] assert call_kwargs["entity_id"] == [team_id] @@ -5464,10 +5464,10 @@ async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client) # Mock get_daily_activity to capture the api_key parameter with patch( - "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity", + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated", new_callable=AsyncMock, - ) as mock_get_daily_activity: - mock_get_daily_activity.return_value = MagicMock() + ) as mock_get_daily_activity_agg: + mock_get_daily_activity_agg.return_value = MagicMock() # Call the endpoint await get_team_daily_activity( @@ -5483,8 +5483,8 @@ async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client) ) # Verify get_daily_activity was called WITHOUT API key filtering - mock_get_daily_activity.assert_called_once() - call_kwargs = mock_get_daily_activity.call_args[1] + mock_get_daily_activity_agg.assert_called_once() + call_kwargs = mock_get_daily_activity_agg.call_args[1] assert call_kwargs["api_key"] is None assert call_kwargs["entity_id"] == [team_id] @@ -5553,10 +5553,10 @@ async def test_get_team_daily_activity_member_with_permission_sees_all_spend( # Mock get_daily_activity to capture the api_key parameter with patch( - "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity", + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated", new_callable=AsyncMock, - ) as mock_get_daily_activity: - mock_get_daily_activity.return_value = MagicMock() + ) as mock_get_daily_activity_agg: + mock_get_daily_activity_agg.return_value = MagicMock() # Call the endpoint await get_team_daily_activity( @@ -5572,8 +5572,8 @@ async def test_get_team_daily_activity_member_with_permission_sees_all_spend( ) # Verify get_daily_activity was called WITHOUT API key filtering - mock_get_daily_activity.assert_called_once() - call_kwargs = mock_get_daily_activity.call_args[1] + mock_get_daily_activity_agg.assert_called_once() + call_kwargs = mock_get_daily_activity_agg.call_args[1] assert call_kwargs["api_key"] is None assert call_kwargs["entity_id"] == [team_id] @@ -5652,10 +5652,10 @@ async def test_get_team_daily_activity_member_without_permission_filters_by_keys # Mock get_daily_activity to capture the api_key parameter with patch( - "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity", + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated", new_callable=AsyncMock, - ) as mock_get_daily_activity: - mock_get_daily_activity.return_value = MagicMock() + ) as mock_get_daily_activity_agg: + mock_get_daily_activity_agg.return_value = MagicMock() # Call the endpoint await get_team_daily_activity( @@ -5671,8 +5671,8 @@ async def test_get_team_daily_activity_member_without_permission_filters_by_keys ) # Verify get_daily_activity was called WITH API key filtering - mock_get_daily_activity.assert_called_once() - call_kwargs = mock_get_daily_activity.call_args[1] + mock_get_daily_activity_agg.assert_called_once() + call_kwargs = mock_get_daily_activity_agg.call_args[1] assert call_kwargs["api_key"] == ["user_key_abc", "user_key_def"] assert call_kwargs["entity_id"] == [team_id] @@ -5822,10 +5822,10 @@ async def test_get_team_daily_activity_non_admin_filters_by_user_api_keys( # Mock get_daily_activity to capture the api_key parameter with patch( - "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity", + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated", new_callable=AsyncMock, - ) as mock_get_daily_activity: - mock_get_daily_activity.return_value = MagicMock() + ) as mock_get_daily_activity_agg: + mock_get_daily_activity_agg.return_value = MagicMock() # Call the endpoint await get_team_daily_activity( @@ -5841,8 +5841,8 @@ async def test_get_team_daily_activity_non_admin_filters_by_user_api_keys( ) # Verify get_daily_activity was called with user's API keys as filter - mock_get_daily_activity.assert_called_once() - call_kwargs = mock_get_daily_activity.call_args[1] + mock_get_daily_activity_agg.assert_called_once() + call_kwargs = mock_get_daily_activity_agg.call_args[1] assert call_kwargs["api_key"] == ["user_key_1", "user_key_2"] assert call_kwargs["entity_id"] == [team_id] @@ -5907,10 +5907,10 @@ async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client) # Mock get_daily_activity to capture the api_key parameter with patch( - "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity", + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated", new_callable=AsyncMock, - ) as mock_get_daily_activity: - mock_get_daily_activity.return_value = MagicMock() + ) as mock_get_daily_activity_agg: + mock_get_daily_activity_agg.return_value = MagicMock() # Call the endpoint await get_team_daily_activity( @@ -5926,8 +5926,8 @@ async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client) ) # Verify get_daily_activity was called WITHOUT API key filtering - mock_get_daily_activity.assert_called_once() - call_kwargs = mock_get_daily_activity.call_args[1] + mock_get_daily_activity_agg.assert_called_once() + call_kwargs = mock_get_daily_activity_agg.call_args[1] assert call_kwargs["api_key"] is None assert call_kwargs["entity_id"] == [team_id] @@ -5939,6 +5939,56 @@ async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client) assert False, "API keys should not be fetched for team admin users" +@pytest.mark.asyncio +async def test_get_team_daily_activity_uses_aggregated_with_entity_breakdown( + mock_db_client, +): + """ + Test that /team/daily/activity calls get_daily_activity_aggregated + with include_entity_breakdown=True, timezone, and correct parameters. + """ + from litellm.proxy.management_endpoints.team_endpoints import ( + get_team_daily_activity, + ) + + user_api_key_dict = UserAPIKeyAuth( + user_id="admin_user", user_role=LitellmUserRoles.PROXY_ADMIN + ) + + # Mock the team table query for fetching team aliases + mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[]) + + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated", + new_callable=AsyncMock, + ) as mock_get_daily_activity_agg: + mock_get_daily_activity_agg.return_value = MagicMock() + + await get_team_daily_activity( + team_ids="team_1,team_2", + start_date="2024-01-01", + end_date="2024-01-31", + model=None, + api_key=None, + page=1, + page_size=10, + exclude_team_ids="litellm-dashboard", + timezone=480, + user_api_key_dict=user_api_key_dict, + ) + + mock_get_daily_activity_agg.assert_called_once() + call_kwargs = mock_get_daily_activity_agg.call_args[1] + assert call_kwargs["table_name"] == "litellm_dailyteamspend" + assert call_kwargs["entity_id_field"] == "team_id" + assert call_kwargs["entity_id"] == ["team_1", "team_2"] + assert call_kwargs["exclude_entity_ids"] == ["litellm-dashboard"] + assert call_kwargs["start_date"] == "2024-01-01" + assert call_kwargs["end_date"] == "2024-01-31" + assert call_kwargs["timezone_offset_minutes"] == 480 + assert call_kwargs["include_entity_breakdown"] is True + + @pytest.mark.asyncio async def test_validate_and_populate_member_user_info_both_provided_match(): """ From 537be618d4234826fcbb2a654fa2682971f3dc72 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 5 Mar 2026 17:17:50 -0800 Subject: [PATCH 147/380] fix(types): add CONFIG_OVERRIDES to SupportedDBObjectType enum Without this, deployments using supported_db_objects filtering would silently skip polling for config_overrides, preventing Hashicorp Vault config from syncing across pods. --- litellm/proxy/_types.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index e408abb3c1b..55d3a61de68 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -77,6 +77,7 @@ class SupportedDBObjectType(str, enum.Enum): PASS_THROUGH_ENDPOINTS = "pass_through_endpoints" PROMPTS = "prompts" MODEL_COST_MAP = "model_cost_map" + CONFIG_OVERRIDES = "config_overrides" def __str__(self): return str(self.value) @@ -2126,7 +2127,7 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): user_header_mappings: Optional[List[UserHeaderMapping]] = None supported_db_objects: Optional[List[SupportedDBObjectType]] = Field( None, - description="Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map'. If not set, all objects are loaded (default behavior).", + description="Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map', 'config_overrides'. If not set, all objects are loaded (default behavior).", ) user_mcp_management_mode: Optional[UserMCPManagementMode] = Field( None, From c953388927d44be1877b70f6fb39a16b07e7c11e Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 5 Mar 2026 17:21:05 -0800 Subject: [PATCH 148/380] fix(vault): remove approle_role_id from sensitive fields, use async HTTP for test_connection - approle_role_id is a non-secret identifier (like a username) per Vault's AppRole model; masking it hinders admin auditing - Use async httpx client for the token lookup-self call to avoid blocking the event loop --- litellm/proxy/_types.py | 3 ++- .../management_endpoints/config_override_endpoints.py | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 55d3a61de68..61197738e72 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -77,6 +77,7 @@ class SupportedDBObjectType(str, enum.Enum): PASS_THROUGH_ENDPOINTS = "pass_through_endpoints" PROMPTS = "prompts" MODEL_COST_MAP = "model_cost_map" + TOOLS = "tools" CONFIG_OVERRIDES = "config_overrides" def __str__(self): @@ -2127,7 +2128,7 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): user_header_mappings: Optional[List[UserHeaderMapping]] = None supported_db_objects: Optional[List[SupportedDBObjectType]] = Field( None, - description="Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map', 'config_overrides'. If not set, all objects are loaded (default behavior).", + description="Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map', 'tools', 'config_overrides'. If not set, all objects are loaded (default behavior).", ) user_mcp_management_mode: Optional[UserMCPManagementMode] = Field( None, diff --git a/litellm/proxy/management_endpoints/config_override_endpoints.py b/litellm/proxy/management_endpoints/config_override_endpoints.py index 2978a523fb1..78cb91b3483 100644 --- a/litellm/proxy/management_endpoints/config_override_endpoints.py +++ b/litellm/proxy/management_endpoints/config_override_endpoints.py @@ -8,7 +8,8 @@ from pydantic import TypeAdapter import litellm from litellm._logging import verbose_proxy_logger -from litellm.llms.custom_httpx.http_handler import _get_httpx_client +from litellm.llms.custom_httpx.http_handler import get_async_httpx_client +from litellm.llms.custom_httpx.httpx_handler import httpxSpecialProvider from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker from litellm.proxy._types import CommonProxyErrors, KeyManagementSystem, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -37,7 +38,6 @@ HASHICORP_ENV_VAR_MAPPING: Dict[str, str] = { HASHICORP_SENSITIVE_FIELDS: Set[str] = { "vault_token", - "approle_role_id", "approle_secret_id", "client_key", } @@ -387,11 +387,11 @@ async def test_hashicorp_vault_connection( # Step 2: Verify the token is valid via token/lookup-self try: - sync_client = _get_httpx_client() + async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.ProxyServer) lookup_url = f"{client.vault_addr}/v1/auth/token/lookup-self" if client.vault_namespace: headers["X-Vault-Namespace"] = client.vault_namespace - response = sync_client.get(lookup_url, headers=headers) + response = await async_client.get(lookup_url, headers=headers) response.raise_for_status() except Exception as e: raise HTTPException( From 8d539db108dc55cca303e8f2c6757243e7dfaa1e Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 5 Mar 2026 17:36:46 -0800 Subject: [PATCH 149/380] Fix admin viewer unable to see all organizations The /organization/list endpoint only checked for PROXY_ADMIN role, causing PROXY_ADMIN_VIEW_ONLY users to fall into the else branch which restricts results to orgs the user is a member of. Use the existing _user_has_admin_view() helper to include both roles. --- litellm/proxy/management_endpoints/organization_endpoints.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 1c19c4ef313..103b2efcdde 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -649,8 +649,8 @@ async def list_organization( "mode": "insensitive", # Case-insensitive search } - # if proxy admin - get all orgs (with optional filters) - if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: + # if proxy admin or admin viewer - get all orgs (with optional filters) + if _user_has_admin_view(user_api_key_dict): response = await prisma_client.db.litellm_organizationtable.find_many( where=where_conditions if where_conditions else None, include={"litellm_budget_table": True, "members": True, "teams": True}, From 73a8e8cf07535cbd5ab648ed0939219a70543591 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 5 Mar 2026 17:40:51 -0800 Subject: [PATCH 150/380] fix(vault): resolve merge conflict, use async auth, include error details - Remove duplicate description kwarg in supported_db_objects Field() that caused SyntaxError preventing proxy startup - Wrap sync _get_request_headers() in asyncio.to_thread to avoid blocking the event loop during AppRole/TLS cert auth - Include exception messages in error responses for admin-only endpoints to aid debugging --- litellm/proxy/_types.py | 1 - .../management_endpoints/config_override_endpoints.py | 9 +++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 12f6cdf600d..da7e1f5a049 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2169,7 +2169,6 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): supported_db_objects: Optional[List[SupportedDBObjectType]] = Field( None, description="Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map', 'tools', 'config_overrides'. If not set, all objects are loaded (default behavior).", - description="Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map', 'tools'. If not set, all objects are loaded (default behavior).", ) user_mcp_management_mode: Optional[UserMCPManagementMode] = Field( None, diff --git a/litellm/proxy/management_endpoints/config_override_endpoints.py b/litellm/proxy/management_endpoints/config_override_endpoints.py index 78cb91b3483..f1d6cacf1e7 100644 --- a/litellm/proxy/management_endpoints/config_override_endpoints.py +++ b/litellm/proxy/management_endpoints/config_override_endpoints.py @@ -1,3 +1,4 @@ +import asyncio import json import os from typing import Any, Dict, Set @@ -216,7 +217,7 @@ async def update_hashicorp_vault_config( ) raise HTTPException( status_code=500, - detail="Failed to initialize secret manager", + detail=f"Failed to initialize secret manager: {e}", ) # Only persist to DB after successful init @@ -378,11 +379,11 @@ async def test_hashicorp_vault_connection( # Step 1: Authenticate (exercises AppRole login, TLS cert login, or direct token) try: - headers = client._get_request_headers() + headers = await asyncio.to_thread(client._get_request_headers) except Exception as e: raise HTTPException( status_code=502, - detail="Vault authentication failed", + detail=f"Vault authentication failed: {e}", ) # Step 2: Verify the token is valid via token/lookup-self @@ -396,7 +397,7 @@ async def test_hashicorp_vault_connection( except Exception as e: raise HTTPException( status_code=502, - detail="Vault token validation failed", + detail=f"Vault token validation failed: {e}", ) return { From ec600aa70a06e3c0d92467472f5e75e474b79485 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Thu, 5 Mar 2026 18:13:04 -0800 Subject: [PATCH 151/380] =?UTF-8?q?feat(ui):=20add=20Chat=20UI=20=E2=80=94?= =?UTF-8?q?=20ChatGPT-like=20interface=20with=20MCP=20tools=20and=20stream?= =?UTF-8?q?ing=20(#22937)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add chat message and conversation types * feat(ui): add useChatHistory hook for localStorage-backed conversations * feat(ui): add ConversationList sidebar component * feat(ui): add MCPConnectPicker for attaching MCP servers to chat * feat(ui): add ModelSelector dropdown for chat * feat(ui): add ChatInputBar with MCP tool attachment support * feat(ui): add MCPAppsPanel with list/detail view for MCP servers * feat(ui): add ChatMessages component; remove auto-scrollIntoView that caused scroll-lock bypass * feat(ui): add ChatPage — ChatGPT-like UI with scroll lock, MCP tools, streaming * feat(ui): add /chat route wired to ChatPage * feat(ui): remove chat from leftnav — chat accessible via navbar button * feat(ui): add Chat button to top navbar * feat(ui): add dismissible Chat UI announcement banner to Playground page * feat(proxy): add Chat UI link to Swagger description * feat(ui): add react-markdown and syntax-highlighter deps for chat UI * fix(ui): replace missing BorderOutlined import with inline stop icon div * fix(ui): apply remark-gfm plugin to ReactMarkdown for GFM support * fix(ui): remove unused isEvenRow variable in MCPAppsPanel * fix(ui): add ellipsis when truncating conversation title * fix(ui): wire search button to chats view; remove non-functional keyboard hint * fix(ui): use serverRootPath in navbar chat link for sub-path deployments * fix(ui): remove unused ChatInputBar and ModelSelector files * fix(ui): correct grid bottom-border condition for odd server count * fix(chat): move localStorage writes out of setConversations updater (React purity) * fix(chat): fix stale closure in handleEditAndResend - compute history before async state update * fix(chat): fix 4 issues in ChatMessages - array redaction, clipboard error, inline detection, remove unused ref --- litellm/proxy/proxy_server.py | 9 +- ui/litellm-dashboard/package-lock.json | 295 +++++++ ui/litellm-dashboard/package.json | 4 +- .../src/app/(dashboard)/playground/page.tsx | 64 +- ui/litellm-dashboard/src/app/chat/page.tsx | 19 + .../src/components/chat/ChatMessages.tsx | 577 ++++++++++++ .../src/components/chat/ChatPage.tsx | 823 ++++++++++++++++++ .../src/components/chat/ConversationList.tsx | 483 ++++++++++ .../src/components/chat/MCPAppsPanel.tsx | 274 ++++++ .../src/components/chat/MCPConnectPicker.tsx | 157 ++++ .../src/components/chat/types.ts | 20 + .../src/components/chat/useChatHistory.ts | 230 +++++ .../src/components/leftnav.tsx | 1 + .../src/components/navbar.tsx | 39 +- 14 files changed, 2988 insertions(+), 7 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/chat/page.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ChatMessages.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ChatPage.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/ConversationList.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx create mode 100644 ui/litellm-dashboard/src/components/chat/types.ts create mode 100644 ui/litellm-dashboard/src/components/chat/useChatHistory.ts diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9683b37dbb4..7fe0ce6d6f5 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -372,6 +372,9 @@ from litellm.proxy.management_endpoints.internal_user_endpoints import ( from litellm.proxy.management_endpoints.internal_user_endpoints import ( user_update, ) +from litellm.proxy.management_endpoints.jwt_key_mapping_endpoints import ( + router as jwt_key_mapping_router, +) from litellm.proxy.management_endpoints.key_management_endpoints import ( delete_verification_tokens, duration_in_seconds, @@ -380,9 +383,6 @@ from litellm.proxy.management_endpoints.key_management_endpoints import ( from litellm.proxy.management_endpoints.key_management_endpoints import ( router as key_management_router, ) -from litellm.proxy.management_endpoints.jwt_key_mapping_endpoints import ( - router as jwt_key_mapping_router, -) from litellm.proxy.management_endpoints.mcp_management_endpoints import ( router as mcp_management_router, ) @@ -661,6 +661,9 @@ ui_message += "\n\n💸 [```LiteLLM Model Cost Map```](https://models.litellm.ai ui_message += f"\n\n🔎 [```LiteLLM Model Hub```]({model_hub_link}). See available models on the proxy. [**Docs**](https://docs.litellm.ai/docs/proxy/ai_hub)" +chat_link = f"{server_root_path}/ui/chat" +ui_message += f"\n\n💬 [```LiteLLM Chat UI```]({chat_link}). ChatGPT-like interface for your users to chat with AI models and MCP tools." + custom_swagger_message = "[**Customize Swagger Docs**](https://docs.litellm.ai/docs/proxy/enterprise#swagger-docs---custom-routes--branding)" ### CUSTOM BRANDING [ENTERPRISE FEATURE] ### diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 200182cf551..69efbf19c38 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -19,6 +19,7 @@ "@types/papaparse": "^5.3.15", "antd": "^5.13.2", "cva": "^1.0.0-beta.3", + "dayjs": "^1.11.19", "jwt-decode": "^4.0.0", "lucide-react": "^0.513.0", "moment": "^2.30.1", @@ -31,6 +32,7 @@ "react-json-view-lite": "^2.5.0", "react-markdown": "^9.0.1", "react-syntax-highlighter": "^15.6.6", + "remark-gfm": "^4.0.1", "tailwind-merge": "^3.2.0", "uuid": "^11.1.0" }, @@ -8281,6 +8283,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8290,6 +8302,34 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", @@ -8314,6 +8354,107 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -8528,6 +8669,127 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -11006,6 +11268,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -11039,6 +11319,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 567673c0989..ea84ea6f401 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -31,6 +31,7 @@ "@types/papaparse": "^5.3.15", "antd": "^5.13.2", "cva": "^1.0.0-beta.3", + "dayjs": "^1.11.19", "jwt-decode": "^4.0.0", "lucide-react": "^0.513.0", "moment": "^2.30.1", @@ -43,6 +44,7 @@ "react-json-view-lite": "^2.5.0", "react-markdown": "^9.0.1", "react-syntax-highlighter": "^15.6.6", + "remark-gfm": "^4.0.1", "tailwind-merge": "^3.2.0", "uuid": "^11.1.0" }, @@ -107,4 +109,4 @@ "node": ">=18.17.0", "npm": ">=8.3.0" } -} \ No newline at end of file +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx index 555930a576c..6a694d9bee9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx @@ -8,6 +8,7 @@ import ComplianceUI from "@/components/playground/complianceUI/ComplianceUI"; import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { fetchProxySettings } from "@/utils/proxyUtils"; +import { MessageOutlined, CloseOutlined } from "@ant-design/icons"; interface ProxySettings { PROXY_BASE_URL?: string; @@ -17,6 +18,7 @@ interface ProxySettings { export default function PlaygroundPage() { const { accessToken, userRole, userId, disabledPersonalKeyCreation, token } = useAuthorized(); const [proxySettings, setProxySettings] = useState(undefined); + const [chatBannerDismissed, setChatBannerDismissed] = useState(false); useEffect(() => { const initializeProxySettings = async () => { @@ -35,7 +37,66 @@ export default function PlaygroundPage() { }, [accessToken]); return ( - +
+ {!chatBannerDismissed && ( +
+ + New + + + Chat UI + {" "}— a ChatGPT-like interface for your users to chat with AI models and MCP tools. Share it with your team. + + + Open Chat UI → + + +
+ )} + Chat Compare @@ -72,5 +133,6 @@ export default function PlaygroundPage() { +
); } diff --git a/ui/litellm-dashboard/src/app/chat/page.tsx b/ui/litellm-dashboard/src/app/chat/page.tsx new file mode 100644 index 00000000000..18fc02e7f73 --- /dev/null +++ b/ui/litellm-dashboard/src/app/chat/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import ChatPage from "@/components/chat/ChatPage"; + +const ChatPageRoute = () => { + const { accessToken, userRole, userId, userEmail } = useAuthorized(); + + return ( + + ); +}; + +export default ChatPageRoute; diff --git a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx new file mode 100644 index 00000000000..640a8addef0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx @@ -0,0 +1,577 @@ +"use client"; + +import { ToolOutlined, CopyOutlined, CheckOutlined, EditOutlined } from "@ant-design/icons"; +import { Collapse, Tooltip } from "antd"; +import React, { useEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; +import ReasoningContent from "../playground/chat_ui/ReasoningContent"; +import { ChatMessage } from "./types"; + +const { Panel } = Collapse; + +// Keys whose values must be redacted in tool args display +const REDACTED_KEY_PATTERNS = /token|key|secret|password|auth/i; + +function redactSensitiveValues(obj: Record): Record { + const result: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (REDACTED_KEY_PATTERNS.test(k)) { + result[k] = "[redacted]"; + } else if (Array.isArray(v)) { + result[k] = v.map((item) => + item !== null && typeof item === "object" && !Array.isArray(item) + ? redactSensitiveValues(item as Record) + : item, + ); + } else if (v !== null && typeof v === "object") { + result[k] = redactSensitiveValues(v as Record); + } else { + result[k] = v; + } + } + return result; +} + +function formatTimestamp(ts: number): string { + const d = new Date(ts); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${hh}:${mm}`; +} + +// Shared markdown code renderer matching ReasoningContent style. +// react-markdown v9 removed the `inline` prop; detect fenced blocks via language className. +function MarkdownCodeRenderer({ + node, + className, + children, + ...props +}: React.ComponentPropsWithoutRef<"code"> & { node?: unknown }) { + const match = /language-(\w+)/.exec(className || ""); + return match ? ( + } + language={match[1]} + PreTag="div" + className="rounded-md my-2" + {...(props as Record)} + > + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); +} + +// ------- Sub-components ------- + +interface UserBubbleProps { + message: ChatMessage; + onEdit?: (messageId: string, newContent: string) => void; + isStreaming?: boolean; +} + +function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) { + const [hovered, setHovered] = useState(false); + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(message.content); + const textareaRef = useRef(null); + + useEffect(() => { + if (editing && textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.selectionStart = textareaRef.current.value.length; + } + }, [editing]); + + // Auto-resize textarea + useEffect(() => { + const ta = textareaRef.current; + if (!ta) return; + ta.style.height = "auto"; + ta.style.height = `${ta.scrollHeight}px`; + }, [editValue, editing]); + + const handleSave = () => { + const trimmed = editValue.trim(); + if (trimmed && trimmed !== message.content && onEdit) { + onEdit(message.id, trimmed); + } + setEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSave(); + } + if (e.key === "Escape") { + setEditValue(message.content); + setEditing(false); + } + }; + + if (editing) { + return ( +
+
+