From 52372dcbe97e718ac9a0ed20c09383f2c0342d80 Mon Sep 17 00:00:00 2001 From: Luis Gallego Ledesma Date: Wed, 28 Jan 2026 15:35:35 +0100 Subject: [PATCH 001/140] fix(langfuse_otel): prevent empty proxy request spans from being sent to Langfuse When using langfuse_otel callback, empty traces were being sent to Langfuse for requests that didn't result in actual LLM calls (e.g., auth operations, health checks, failed requests). These traces contained only internal proxy operations (auth, postgres, proxy_pre_call) with no useful LLM data. Root cause: LangfuseOtelLogger extends OpenTelemetry, which sets itself as the proxy's open_telemetry_logger. This caused create_litellm_proxy_request_started_span to be called for every request, creating a parent span that was sent to Langfuse even when no LLM call occurred. Fix: Override create_litellm_proxy_request_started_span in LangfuseOtelLogger to return None, preventing the creation of empty parent spans. This is consistent with the existing overrides for async_service_success_hook and async_service_failure_hook which already prevent service-level logs from being sent to Langfuse. Fixes: Empty traces in Langfuse v3 when using langfuse_otel callback --- .../integrations/langfuse/langfuse_otel.py | 17 +++++++++++ tests/test_service_logger_otel.py | 28 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/litellm/integrations/langfuse/langfuse_otel.py b/litellm/integrations/langfuse/langfuse_otel.py index 08493a0e8ec..20206f73b0f 100644 --- a/litellm/integrations/langfuse/langfuse_otel.py +++ b/litellm/integrations/langfuse/langfuse_otel.py @@ -1,6 +1,7 @@ import base64 import json # <--- NEW import os +from datetime import datetime from typing import TYPE_CHECKING, Any, Optional, Union from litellm._logging import verbose_logger @@ -353,6 +354,22 @@ class LangfuseOtelLogger(OpenTelemetry): return dynamic_headers + def create_litellm_proxy_request_started_span( + self, + start_time: datetime, + headers: dict, + ) -> Optional[Span]: + """ + Override to prevent creating empty proxy request spans. + + Langfuse should only receive spans for actual LLM calls, not for + internal proxy operations (auth, postgres, proxy_pre_call, etc.). + + By returning None, we prevent the parent span from being created, + which in turn prevents empty traces from being sent to Langfuse. + """ + return None + async def async_service_success_hook(self, *args, **kwargs): """ Langfuse should not receive service success logs. diff --git a/tests/test_service_logger_otel.py b/tests/test_service_logger_otel.py index 5cb21dadeae..35070d55546 100644 --- a/tests/test_service_logger_otel.py +++ b/tests/test_service_logger_otel.py @@ -1,6 +1,7 @@ import os import sys import unittest +from datetime import datetime from unittest.mock import patch, AsyncMock, MagicMock # Add the project root to sys.path @@ -41,6 +42,33 @@ class TestServiceLoggerOTEL(unittest.IsolatedAsyncioTestCase): "LangfuseOtelLogger.async_service_failure_hook", ) + @patch("litellm.integrations.opentelemetry.OpenTelemetry._init_tracing") + @patch("litellm.integrations.opentelemetry.OpenTelemetry._init_metrics") + @patch("litellm.integrations.opentelemetry.OpenTelemetry._init_logs") + async def test_langfuse_otel_does_not_create_proxy_request_span( + self, mock_logs, mock_metrics, mock_tracing + ): + """ + Test that LangfuseOtelLogger returns None for create_litellm_proxy_request_started_span. + + This prevents empty proxy request spans from being sent to Langfuse when + requests don't result in actual LLM calls (e.g., auth failures, health checks). + """ + logger = LangfuseOtelLogger() + + # Verify the method is overridden + self.assertEqual( + logger.create_litellm_proxy_request_started_span.__qualname__, + "LangfuseOtelLogger.create_litellm_proxy_request_started_span", + ) + + # Verify it returns None + result = logger.create_litellm_proxy_request_started_span( + start_time=datetime.now(), + headers={"Authorization": "Bearer test"}, + ) + self.assertIsNone(result) + @patch("litellm.integrations.opentelemetry.OpenTelemetry._init_tracing") @patch("litellm.integrations.opentelemetry.OpenTelemetry._init_metrics") @patch("litellm.integrations.opentelemetry.OpenTelemetry._init_logs") From fef5c5fd3ef3c6dc5376749fac226df59de55a87 Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 7 Feb 2026 17:08:01 -0800 Subject: [PATCH 002/140] fix: preserve key alias and team_id after key regeneration, deletion --- .../common_daily_activity.py | 35 ++- .../key_management_endpoints.py | 16 +- .../test_common_daily_activity.py | 254 ++++++++++++++++++ 3 files changed, 301 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 99a732f9efb..79d80abf272 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -327,14 +327,43 @@ async def get_api_key_metadata( prisma_client: PrismaClient, api_keys: Set[str], ) -> Dict[str, Dict[str, Any]]: - """Update api key metadata for a single record.""" + """Get api key metadata, falling back to deleted keys table for keys not found in active table. + + This ensures that key_alias and team_id are preserved in historical activity logs + even after a key is deleted or regenerated. + """ key_records = await prisma_client.db.litellm_verificationtoken.find_many( where={"token": {"in": list(api_keys)}} ) - return { - k.token: {"key_alias": k.key_alias, "team_id": k.team_id} for k in key_records + result = { + k.token: {"key_alias": k.key_alias, "team_id": k.team_id} + for k in key_records } + # For any keys not found in the active table, check the deleted keys table + missing_keys = api_keys - set(result.keys()) + if missing_keys: + try: + deleted_key_records = ( + await prisma_client.db.litellm_deletedverificationtoken.find_many( + where={"token": {"in": list(missing_keys)}}, + order={"deleted_at": "desc"}, + ) + ) + # Use the most recent deleted record for each token (ordered by deleted_at desc) + for k in deleted_key_records: + if k.token not in result: + result[k.token] = { + "key_alias": k.key_alias, + "team_id": k.team_id, + } + except Exception: + verbose_proxy_logger.debug( + "Failed to fetch deleted key metadata for missing keys" + ) + + return result + def _adjust_dates_for_timezone( start_date: str, diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 2eb6cf65281..264dff5aa54 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -3313,6 +3313,20 @@ async def regenerate_key_fn( verbose_proxy_logger.debug("key_in_db: %s", _key_in_db) + # Save the old key record to deleted table before regeneration + # This preserves key_alias and team_id metadata for historical spend records + try: + await _persist_deleted_verification_tokens( + keys=[_key_in_db], + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=litellm_changed_by, + ) + except Exception: + verbose_proxy_logger.debug( + "Failed to persist old key record to deleted table during regeneration" + ) + new_token = get_new_token(data=data) new_token_hash = hash_token(new_token) @@ -3749,7 +3763,7 @@ async def list_keys( else: admin_team_ids = None - if not user_id and user_api_key_dict.user_role not in [ + if user_id is None and user_api_key_dict.user_role not in [ LitellmUserRoles.PROXY_ADMIN.value, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, ]: diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py index 93457631d2d..48869803b20 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py @@ -11,6 +11,7 @@ sys.path.insert( from litellm.proxy.management_endpoints.common_daily_activity import ( _is_user_agent_tag, compute_tag_metadata_totals, + get_api_key_metadata, get_daily_activity, get_daily_activity_aggregated, ) @@ -208,3 +209,256 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown(): assert chat_endpoint.api_key_breakdown["key-1"].metrics.spend == 15.0 assert "key-2" in embeddings_endpoint.api_key_breakdown assert embeddings_endpoint.api_key_breakdown["key-2"].metrics.spend == 3.0 + + +@pytest.mark.asyncio +async def test_get_api_key_metadata_returns_active_key_metadata(): + """Test that get_api_key_metadata should return metadata for active keys.""" + mock_prisma = MagicMock() + + # Mock active key record + mock_active_key = MagicMock() + mock_active_key.token = "active-key-hash-123" + mock_active_key.key_alias = "my-active-key" + mock_active_key.team_id = "team-abc" + + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock( + return_value=[mock_active_key] + ) + + result = await get_api_key_metadata( + prisma_client=mock_prisma, + api_keys={"active-key-hash-123"}, + ) + + assert "active-key-hash-123" in result + assert result["active-key-hash-123"]["key_alias"] == "my-active-key" + assert result["active-key-hash-123"]["team_id"] == "team-abc" + + +@pytest.mark.asyncio +async def test_get_api_key_metadata_falls_back_to_deleted_keys(): + """Test that get_api_key_metadata should fall back to deleted keys table for missing keys.""" + mock_prisma = MagicMock() + + # No active keys found + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + + # Deleted key record exists + mock_deleted_key = MagicMock() + mock_deleted_key.token = "deleted-key-hash-456" + mock_deleted_key.key_alias = "toto-test-2" + mock_deleted_key.team_id = "team-xyz" + + mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock( + return_value=[mock_deleted_key] + ) + + result = await get_api_key_metadata( + prisma_client=mock_prisma, + api_keys={"deleted-key-hash-456"}, + ) + + assert "deleted-key-hash-456" in result + assert result["deleted-key-hash-456"]["key_alias"] == "toto-test-2" + assert result["deleted-key-hash-456"]["team_id"] == "team-xyz" + + # Verify deleted table was queried with the missing key + mock_prisma.db.litellm_deletedverificationtoken.find_many.assert_called_once_with( + where={"token": {"in": ["deleted-key-hash-456"]}}, + order={"deleted_at": "desc"}, + ) + + +@pytest.mark.asyncio +async def test_get_api_key_metadata_mixed_active_and_deleted_keys(): + """Test that get_api_key_metadata should return metadata for both active and deleted keys.""" + mock_prisma = MagicMock() + + # One active key found + mock_active_key = MagicMock() + mock_active_key.token = "active-key-hash" + mock_active_key.key_alias = "active-alias" + mock_active_key.team_id = "team-active" + + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock( + return_value=[mock_active_key] + ) + + # One deleted key found + mock_deleted_key = MagicMock() + mock_deleted_key.token = "deleted-key-hash" + mock_deleted_key.key_alias = "deleted-alias" + mock_deleted_key.team_id = "team-deleted" + + mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock( + return_value=[mock_deleted_key] + ) + + result = await get_api_key_metadata( + prisma_client=mock_prisma, + api_keys={"active-key-hash", "deleted-key-hash"}, + ) + + # Both keys should have metadata + assert len(result) == 2 + assert result["active-key-hash"]["key_alias"] == "active-alias" + assert result["active-key-hash"]["team_id"] == "team-active" + assert result["deleted-key-hash"]["key_alias"] == "deleted-alias" + assert result["deleted-key-hash"]["team_id"] == "team-deleted" + + +@pytest.mark.asyncio +async def test_get_api_key_metadata_deleted_table_not_queried_when_all_keys_found(): + """Test that get_api_key_metadata should not query deleted table when all keys are active.""" + mock_prisma = MagicMock() + + mock_active_key = MagicMock() + mock_active_key.token = "key-hash-1" + mock_active_key.key_alias = "alias-1" + mock_active_key.team_id = "team-1" + + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock( + return_value=[mock_active_key] + ) + mock_prisma.db.litellm_deletedverificationtoken = MagicMock() + mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock( + return_value=[] + ) + + result = await get_api_key_metadata( + prisma_client=mock_prisma, + api_keys={"key-hash-1"}, + ) + + assert len(result) == 1 + assert result["key-hash-1"]["key_alias"] == "alias-1" + # Deleted table should NOT have been queried + mock_prisma.db.litellm_deletedverificationtoken.find_many.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_api_key_metadata_deleted_table_error_handled_gracefully(): + """Test that get_api_key_metadata should handle errors from deleted table gracefully.""" + mock_prisma = MagicMock() + + # No active keys found + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + + # Deleted table raises an error (e.g., table doesn't exist in older schema) + mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock( + side_effect=Exception("Table not found") + ) + + result = await get_api_key_metadata( + prisma_client=mock_prisma, + api_keys={"missing-key-hash"}, + ) + + # Should return empty dict without raising + assert result == {} + + +@pytest.mark.asyncio +async def test_get_api_key_metadata_regenerated_key_uses_most_recent_deleted_record(): + """Test that get_api_key_metadata should use the most recent deleted record for regenerated keys.""" + mock_prisma = MagicMock() + + # No active keys found (old hash no longer in active table after regeneration) + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + + # Multiple deleted records for same token (e.g., regenerated multiple times) + mock_deleted_1 = MagicMock() + mock_deleted_1.token = "old-key-hash" + mock_deleted_1.key_alias = "latest-alias" + mock_deleted_1.team_id = "latest-team" + + mock_deleted_2 = MagicMock() + mock_deleted_2.token = "old-key-hash" + mock_deleted_2.key_alias = "older-alias" + mock_deleted_2.team_id = "older-team" + + # Ordered by deleted_at desc, so first record is the most recent + mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock( + return_value=[mock_deleted_1, mock_deleted_2] + ) + + result = await get_api_key_metadata( + prisma_client=mock_prisma, + api_keys={"old-key-hash"}, + ) + + # Should use the first (most recent) record + assert result["old-key-hash"]["key_alias"] == "latest-alias" + assert result["old-key-hash"]["team_id"] == "latest-team" + + +@pytest.mark.asyncio +async def test_aggregated_activity_preserves_metadata_for_deleted_keys(): + """Test that the full aggregation pipeline should preserve metadata for deleted keys.""" + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + + class MockRecord: + def __init__(self, date, endpoint, api_key, model, spend, prompt_tokens, completion_tokens): + self.date = date + self.endpoint = endpoint + self.api_key = api_key + self.model = model + self.model_group = None + self.custom_llm_provider = "openai" + self.mcp_namespaced_tool_name = None + self.spend = spend + self.prompt_tokens = prompt_tokens + self.completion_tokens = completion_tokens + self.total_tokens = prompt_tokens + completion_tokens + self.cache_read_input_tokens = 0 + self.cache_creation_input_tokens = 0 + self.api_requests = 1 + self.successful_requests = 1 + self.failed_requests = 0 + + # Records reference a deleted key + mock_records = [ + MockRecord("2024-01-01", "/v1/chat/completions", "deleted-key-hash", "gpt-4", 10.0, 100, 50), + ] + + mock_table = MagicMock() + mock_table.find_many = AsyncMock(return_value=mock_records) + mock_prisma.db.litellm_dailyuserspend = mock_table + + # Active table returns nothing for this key + mock_prisma.db.litellm_verificationtoken = MagicMock() + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + + # Deleted table returns the metadata + mock_deleted_key = MagicMock() + mock_deleted_key.token = "deleted-key-hash" + mock_deleted_key.key_alias = "toto-test-2" + mock_deleted_key.team_id = "69cd4b77-b095-4489-8c46-4f2f31d840a2" + + mock_prisma.db.litellm_deletedverificationtoken = MagicMock() + mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock( + return_value=[mock_deleted_key] + ) + + result = await get_daily_activity_aggregated( + prisma_client=mock_prisma, + table_name="litellm_dailyuserspend", + entity_id_field="user_id", + entity_id=None, + entity_metadata_field=None, + start_date="2024-01-01", + end_date="2024-01-01", + model=None, + api_key=None, + ) + + # Verify the deleted key's metadata is preserved + daily_data = result.results[0] + chat_endpoint = daily_data.breakdown.endpoints["/v1/chat/completions"] + assert "deleted-key-hash" in chat_endpoint.api_key_breakdown + key_data = chat_endpoint.api_key_breakdown["deleted-key-hash"] + assert key_data.metadata.key_alias == "toto-test-2" + assert key_data.metadata.team_id == "69cd4b77-b095-4489-8c46-4f2f31d840a2" + assert key_data.metrics.spend == 10.0 From fa9585371890f485c8c83f672fed8179b945b103 Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 7 Feb 2026 17:17:23 -0800 Subject: [PATCH 003/140] resolved greptile issue related to regeneration persistence and exception handling --- .../common_daily_activity.py | 8 ++++--- .../key_management_endpoints.py | 23 ++++++++----------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 79d80abf272..e5df2f82f69 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -357,9 +357,11 @@ async def get_api_key_metadata( "key_alias": k.key_alias, "team_id": k.team_id, } - except Exception: - verbose_proxy_logger.debug( - "Failed to fetch deleted key metadata for missing keys" + except Exception as e: + verbose_proxy_logger.warning( + "Failed to fetch deleted key metadata for %d missing keys: %s", + len(missing_keys), + e, ) return result diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 264dff5aa54..48e0c32880b 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -3313,19 +3313,16 @@ async def regenerate_key_fn( verbose_proxy_logger.debug("key_in_db: %s", _key_in_db) - # Save the old key record to deleted table before regeneration - # This preserves key_alias and team_id metadata for historical spend records - try: - await _persist_deleted_verification_tokens( - keys=[_key_in_db], - prisma_client=prisma_client, - user_api_key_dict=user_api_key_dict, - litellm_changed_by=litellm_changed_by, - ) - except Exception: - verbose_proxy_logger.debug( - "Failed to persist old key record to deleted table during regeneration" - ) + # Save the old key record to deleted table before regeneration. + # This preserves key_alias and team_id metadata for historical spend records. + # If this fails, abort the regeneration to avoid permanently losing the + # old hash→metadata mapping. + await _persist_deleted_verification_tokens( + keys=[_key_in_db], + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + litellm_changed_by=litellm_changed_by, + ) new_token = get_new_token(data=data) From ef25ecb68eaf1509b45e4a20e922498e9540df19 Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 7 Feb 2026 18:21:33 -0800 Subject: [PATCH 004/140] fixed user id --- litellm/proxy/management_endpoints/key_management_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 48e0c32880b..099c4543362 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -3760,7 +3760,7 @@ async def list_keys( else: admin_team_ids = None - if user_id is None and user_api_key_dict.user_role not in [ + if not user_id and user_api_key_dict.user_role not in [ LitellmUserRoles.PROXY_ADMIN.value, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, ]: From b880320ec6ad9a4294c7b58fb03d5c899806f16c Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Fri, 13 Feb 2026 05:57:44 -0300 Subject: [PATCH 005/140] chore: add .claude directory to gitignore Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ddf5f6279b3..c43df98a9e5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .venv .venv_policy_test .env +.claude .newenv newenv/* litellm/proxy/myenv/* From 64424d00c1ad48d4d356b130f110c30ca9470896 Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Fri, 13 Feb 2026 05:48:16 -0300 Subject: [PATCH 006/140] fix: use None instead of Reasoning() for reasoning parameter The ResponsesAPIResponse.reasoning field expects Optional[Dict[str, Any]], not a Reasoning object. Passing an empty Reasoning() object causes a mypy type error. Fix: Pass None instead of Reasoning() since the field is optional and we're not providing any reasoning data. Fixes mypy error: Argument "reasoning" to "ResponsesAPIResponse" has incompatible type "Reasoning"; expected "dict[str, Any] | None" --- .../litellm_completion_transformation/transformation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 08e31c59662..fe9c83b688b 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -1500,7 +1500,7 @@ class LiteLLMCompletionResponsesConfig: previous_response_id=getattr( chat_completion_response, "previous_response_id", None ), - reasoning=Reasoning(), + reasoning=None, status=LiteLLMCompletionResponsesConfig._map_chat_completion_finish_reason_to_responses_status( finish_reason ), From e6385a6c836f045e295854af21e868d467a587d1 Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Thu, 12 Feb 2026 20:19:06 -0300 Subject: [PATCH 007/140] fix: make policy_resolve_endpoints importable without FastAPI Adds try/except around FastAPI imports with fallback mock classes. This allows the module to be imported in test environments where proxy dependencies (FastAPI) may not be installed. Fixes NameError when MCP tests try to import from proxy_server which imports from this module: - NameError: name 'APIRouter' is not defined - NameError: name 'Depends' is not defined - NameError: name 'HTTPException' is not defined - NameError: name 'Query' is not defined --- .../policy_engine/policy_resolve_endpoints.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/policy_engine/policy_resolve_endpoints.py b/litellm/proxy/policy_engine/policy_resolve_endpoints.py index 318e990ff12..428ab2b63b2 100644 --- a/litellm/proxy/policy_engine/policy_resolve_endpoints.py +++ b/litellm/proxy/policy_engine/policy_resolve_endpoints.py @@ -6,8 +6,26 @@ Policy resolve and attachment impact estimation endpoints. """ import json +from typing import TYPE_CHECKING -from fastapi import APIRouter, Depends, HTTPException, Query +# FastAPI imports - may not be available in all environments (e.g. during testing) +try: + from fastapi import APIRouter, Depends, HTTPException, Query +except ImportError: + # Provide stubs for type checking only + if TYPE_CHECKING: + from fastapi import APIRouter, Depends, HTTPException, Query + else: + # Create mock classes that won't be used + class APIRouter: # type: ignore[no-redef] + def post(self, *args, **kwargs): + def decorator(func): + return func + return decorator + + def Depends(func): return func # type: ignore[misc] + HTTPException = Exception # type: ignore[misc,assignment] + def Query(*args, **kwargs): return None # type: ignore[misc] from litellm._logging import verbose_proxy_logger from litellm.constants import MAX_POLICY_ESTIMATE_IMPACT_ROWS From ca9cdea8a7bed1a4ee682a6567415c5477da78d4 Mon Sep 17 00:00:00 2001 From: jquinter Date: Fri, 13 Feb 2026 00:40:08 -0300 Subject: [PATCH 008/140] Update litellm/proxy/policy_engine/policy_resolve_endpoints.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- litellm/proxy/policy_engine/policy_resolve_endpoints.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/proxy/policy_engine/policy_resolve_endpoints.py b/litellm/proxy/policy_engine/policy_resolve_endpoints.py index 428ab2b63b2..27ee896c719 100644 --- a/litellm/proxy/policy_engine/policy_resolve_endpoints.py +++ b/litellm/proxy/policy_engine/policy_resolve_endpoints.py @@ -12,9 +12,7 @@ from typing import TYPE_CHECKING try: from fastapi import APIRouter, Depends, HTTPException, Query except ImportError: - # Provide stubs for type checking only - if TYPE_CHECKING: - from fastapi import APIRouter, Depends, HTTPException, Query + # Provide stubs for environments without FastAPI else: # Create mock classes that won't be used class APIRouter: # type: ignore[no-redef] From 1fffeb953c60b5cbe6eb0f3d9e6d7b95815da4ce Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Fri, 13 Feb 2026 00:47:35 -0300 Subject: [PATCH 009/140] fix: restore if TYPE_CHECKING block for proper FastAPI import handling Greptile's previous suggestion accidentally removed the if TYPE_CHECKING block, leaving an orphan else statement that caused a syntax error. This commit restores the proper structure. --- .../policy_engine/policy_resolve_endpoints.py | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/policy_engine/policy_resolve_endpoints.py b/litellm/proxy/policy_engine/policy_resolve_endpoints.py index 27ee896c719..8f613694506 100644 --- a/litellm/proxy/policy_engine/policy_resolve_endpoints.py +++ b/litellm/proxy/policy_engine/policy_resolve_endpoints.py @@ -12,18 +12,26 @@ from typing import TYPE_CHECKING try: from fastapi import APIRouter, Depends, HTTPException, Query except ImportError: - # Provide stubs for environments without FastAPI + # Provide stubs for type checking only + if TYPE_CHECKING: + from fastapi import APIRouter, Depends, HTTPException, Query else: # Create mock classes that won't be used class APIRouter: # type: ignore[no-redef] def post(self, *args, **kwargs): def decorator(func): return func + return decorator - def Depends(func): return func # type: ignore[misc] + def Depends(func): + return func # type: ignore[misc] + HTTPException = Exception # type: ignore[misc,assignment] - def Query(*args, **kwargs): return None # type: ignore[misc] + + def Query(*args, **kwargs): + return None # type: ignore[misc] + from litellm._logging import verbose_proxy_logger from litellm.constants import MAX_POLICY_ESTIMATE_IMPACT_ROWS @@ -93,7 +101,9 @@ def _get_tags_from_metadata(metadata: object, json_metadata: object = None) -> l async def _fetch_all_teams(prisma_client: object) -> list: """Fetch teams from DB once. Reuse the result across tag and alias lookups.""" return await prisma_client.db.litellm_teamtable.find_many( # type: ignore - where={}, order={"created_at": "desc"}, take=MAX_POLICY_ESTIMATE_IMPACT_ROWS, + where={}, + order={"created_at": "desc"}, + take=MAX_POLICY_ESTIMATE_IMPACT_ROWS, ) @@ -175,7 +185,8 @@ async def _find_affected_by_team_patterns( if matched_team_ids: keys = await prisma_client.db.litellm_verificationtoken.find_many( # type: ignore where={"team_id": {"in": matched_team_ids}}, - order={"created_at": "desc"}, take=MAX_POLICY_ESTIMATE_IMPACT_ROWS, + order={"created_at": "desc"}, + take=MAX_POLICY_ESTIMATE_IMPACT_ROWS, ) for key in keys: key_alias = key.key_alias or "" @@ -197,7 +208,8 @@ async def _find_affected_keys_by_alias( keys = await prisma_client.db.litellm_verificationtoken.find_many( # type: ignore where=_build_alias_where("key_alias", key_patterns), - order={"created_at": "desc"}, take=MAX_POLICY_ESTIMATE_IMPACT_ROWS, + order={"created_at": "desc"}, + take=MAX_POLICY_ESTIMATE_IMPACT_ROWS, ) for key in keys: key_alias = key.key_alias or "" @@ -380,19 +392,24 @@ async def estimate_attachment_impact( # Tag-based impact if tag_patterns: keys = await prisma_client.db.litellm_verificationtoken.find_many( # type: ignore - where={}, order={"created_at": "desc"}, + where={}, + order={"created_at": "desc"}, take=MAX_POLICY_ESTIMATE_IMPACT_ROWS, ) affected_keys, unnamed_keys = _filter_keys_by_tags(keys, tag_patterns) affected_teams, unnamed_teams = _filter_teams_by_tags( - all_teams, tag_patterns, + all_teams, + tag_patterns, ) # Team-based impact (alias matching + keys belonging to those teams) if team_patterns: new_teams, new_keys, new_unnamed = await _find_affected_by_team_patterns( - prisma_client, all_teams, team_patterns, - affected_teams, affected_keys, + prisma_client, + all_teams, + team_patterns, + affected_teams, + affected_keys, ) affected_teams.extend(new_teams) affected_keys.extend(new_keys) @@ -402,7 +419,9 @@ async def estimate_attachment_impact( key_patterns = request.keys or [] if key_patterns: new_keys = await _find_affected_keys_by_alias( - prisma_client, key_patterns, affected_keys, + prisma_client, + key_patterns, + affected_keys, ) affected_keys.extend(new_keys) From 5bcd3b53c94b44ca4eeabb9206140e2b9bcfbb98 Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Fri, 13 Feb 2026 05:25:33 -0300 Subject: [PATCH 010/140] fix: import LiteLLM_ObjectPermissionTable from _types instead of proxy_server Per maintainer feedback, FastAPI should always be available in proxy code. The issue was that MCP tests were importing from proxy_server unnecessarily, pulling in all proxy dependencies including policy_resolve_endpoints. Fix: - Revert policy_resolve_endpoints.py to use direct FastAPI imports - Update MCP tests to import LiteLLM_ObjectPermissionTable from litellm.proxy._types instead of litellm.proxy.proxy_server This avoids importing the entire proxy_server module with all its dependencies when tests only need specific types. Addresses: https://github.com/BerriAI/litellm/pull/21075/changes#r2802201174 --- .../policy_engine/policy_resolve_endpoints.py | 53 ++++--------------- tests/mcp_tests/test_mcp_logging.py | 3 +- tests/mcp_tests/test_mcp_server.py | 2 +- 3 files changed, 11 insertions(+), 47 deletions(-) diff --git a/litellm/proxy/policy_engine/policy_resolve_endpoints.py b/litellm/proxy/policy_engine/policy_resolve_endpoints.py index 8f613694506..318e990ff12 100644 --- a/litellm/proxy/policy_engine/policy_resolve_endpoints.py +++ b/litellm/proxy/policy_engine/policy_resolve_endpoints.py @@ -6,32 +6,8 @@ Policy resolve and attachment impact estimation endpoints. """ import json -from typing import TYPE_CHECKING - -# FastAPI imports - may not be available in all environments (e.g. during testing) -try: - from fastapi import APIRouter, Depends, HTTPException, Query -except ImportError: - # Provide stubs for type checking only - if TYPE_CHECKING: - from fastapi import APIRouter, Depends, HTTPException, Query - else: - # Create mock classes that won't be used - class APIRouter: # type: ignore[no-redef] - def post(self, *args, **kwargs): - def decorator(func): - return func - - return decorator - - def Depends(func): - return func # type: ignore[misc] - - HTTPException = Exception # type: ignore[misc,assignment] - - def Query(*args, **kwargs): - return None # type: ignore[misc] +from fastapi import APIRouter, Depends, HTTPException, Query from litellm._logging import verbose_proxy_logger from litellm.constants import MAX_POLICY_ESTIMATE_IMPACT_ROWS @@ -101,9 +77,7 @@ def _get_tags_from_metadata(metadata: object, json_metadata: object = None) -> l async def _fetch_all_teams(prisma_client: object) -> list: """Fetch teams from DB once. Reuse the result across tag and alias lookups.""" return await prisma_client.db.litellm_teamtable.find_many( # type: ignore - where={}, - order={"created_at": "desc"}, - take=MAX_POLICY_ESTIMATE_IMPACT_ROWS, + where={}, order={"created_at": "desc"}, take=MAX_POLICY_ESTIMATE_IMPACT_ROWS, ) @@ -185,8 +159,7 @@ async def _find_affected_by_team_patterns( if matched_team_ids: keys = await prisma_client.db.litellm_verificationtoken.find_many( # type: ignore where={"team_id": {"in": matched_team_ids}}, - order={"created_at": "desc"}, - take=MAX_POLICY_ESTIMATE_IMPACT_ROWS, + order={"created_at": "desc"}, take=MAX_POLICY_ESTIMATE_IMPACT_ROWS, ) for key in keys: key_alias = key.key_alias or "" @@ -208,8 +181,7 @@ async def _find_affected_keys_by_alias( keys = await prisma_client.db.litellm_verificationtoken.find_many( # type: ignore where=_build_alias_where("key_alias", key_patterns), - order={"created_at": "desc"}, - take=MAX_POLICY_ESTIMATE_IMPACT_ROWS, + order={"created_at": "desc"}, take=MAX_POLICY_ESTIMATE_IMPACT_ROWS, ) for key in keys: key_alias = key.key_alias or "" @@ -392,24 +364,19 @@ async def estimate_attachment_impact( # Tag-based impact if tag_patterns: keys = await prisma_client.db.litellm_verificationtoken.find_many( # type: ignore - where={}, - order={"created_at": "desc"}, + where={}, order={"created_at": "desc"}, take=MAX_POLICY_ESTIMATE_IMPACT_ROWS, ) affected_keys, unnamed_keys = _filter_keys_by_tags(keys, tag_patterns) affected_teams, unnamed_teams = _filter_teams_by_tags( - all_teams, - tag_patterns, + all_teams, tag_patterns, ) # Team-based impact (alias matching + keys belonging to those teams) if team_patterns: new_teams, new_keys, new_unnamed = await _find_affected_by_team_patterns( - prisma_client, - all_teams, - team_patterns, - affected_teams, - affected_keys, + prisma_client, all_teams, team_patterns, + affected_teams, affected_keys, ) affected_teams.extend(new_teams) affected_keys.extend(new_keys) @@ -419,9 +386,7 @@ async def estimate_attachment_impact( key_patterns = request.keys or [] if key_patterns: new_keys = await _find_affected_keys_by_alias( - prisma_client, - key_patterns, - affected_keys, + prisma_client, key_patterns, affected_keys, ) affected_keys.extend(new_keys) diff --git a/tests/mcp_tests/test_mcp_logging.py b/tests/mcp_tests/test_mcp_logging.py index aeebb2e913e..d9ecb594b7b 100644 --- a/tests/mcp_tests/test_mcp_logging.py +++ b/tests/mcp_tests/test_mcp_logging.py @@ -19,8 +19,7 @@ from litellm.proxy._experimental.mcp_server.server import ( from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( MCPServerManager, ) -from litellm.proxy.proxy_server import LiteLLM_ObjectPermissionTable -from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth from litellm.types.mcp import MCPPostCallResponseObject from litellm.types.utils import HiddenParams from mcp.types import Tool as MCPTool, CallToolResult, TextContent diff --git a/tests/mcp_tests/test_mcp_server.py b/tests/mcp_tests/test_mcp_server.py index 9718d714cfe..98cf3c40c81 100644 --- a/tests/mcp_tests/test_mcp_server.py +++ b/tests/mcp_tests/test_mcp_server.py @@ -1,7 +1,6 @@ # Create server parameters for stdio connection import os import sys -from litellm.proxy.proxy_server import LiteLLM_ObjectPermissionTable import pytest from unittest.mock import AsyncMock, MagicMock, patch from contextlib import asynccontextmanager @@ -15,6 +14,7 @@ from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( MCPServer, MCPTransport, ) +from litellm.proxy._types import LiteLLM_ObjectPermissionTable from mcp.types import Tool as MCPTool, CallToolResult, ListToolsResult from mcp.types import TextContent From 5dbcca8d43aed83e89f3f9e3a60ae0c83852a910 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 13 Feb 2026 16:20:31 -0800 Subject: [PATCH 011/140] Access groups UI --- .../accessGroups/useAccessGroupDetails.ts | 63 +++ .../hooks/accessGroups/useAccessGroups.ts | 70 +++ .../accessGroups/useCreateAccessGroup.ts | 68 +++ .../accessGroups/useDeleteAccessGroup.ts | 55 +++ .../hooks/accessGroups/useEditAccessGroup.ts | 77 ++++ ui/litellm-dashboard/src/app/page.tsx | 3 + .../AccessGroups/AccessGroupsDetailsPage.tsx | 345 +++++++++++++++ .../AccessGroupsModal/AccessGroupBaseForm.tsx | 159 +++++++ .../AccessGroupCreateModal.tsx | 67 +++ .../AccessGroupEditModal.tsx | 85 ++++ .../AccessGroups/AccessGroupsPage.tsx | 401 ++++++++++++++++++ .../src/components/AccessGroups/types.ts | 46 ++ .../DefaultProxyAdminTag.tsx | 24 ++ .../src/components/leftnav.tsx | 7 + 14 files changed, 1470 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupBaseForm.tsx create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupCreateModal.tsx create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupEditModal.tsx create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsPage.tsx create mode 100644 ui/litellm-dashboard/src/components/AccessGroups/types.ts create mode 100644 ui/litellm-dashboard/src/components/common_components/DefaultProxyAdminTag.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails.ts new file mode 100644 index 00000000000..c0379b25321 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails.ts @@ -0,0 +1,63 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + getProxyBaseUrl, + getGlobalLitellmHeaderName, + deriveErrorMessage, + handleError, +} from "@/components/networking"; +import { all_admin_roles } from "@/utils/roles"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups"; + +// ── Fetch function ─────────────────────────────────────────────────────────── + +const fetchAccessGroupDetails = async ( + accessToken: string, + accessGroupId: string, +): Promise => { + const baseUrl = getProxyBaseUrl(); + const url = `${baseUrl}/v1/access_group/${encodeURIComponent(accessGroupId)}`; + + const response = await fetch(url, { + method: "GET", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return response.json(); +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +export const useAccessGroupDetails = (accessGroupId?: string) => { + const { accessToken, userRole } = useAuthorized(); + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: accessGroupKeys.detail(accessGroupId!), + queryFn: async () => fetchAccessGroupDetails(accessToken!, accessGroupId!), + enabled: + Boolean(accessToken && accessGroupId) && + all_admin_roles.includes(userRole || ""), + + // Seed from the list cache when available + initialData: () => { + if (!accessGroupId) return undefined; + + const groups = queryClient.getQueryData( + accessGroupKeys.list({}), + ); + + return groups?.find((g) => g.access_group_id === accessGroupId); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts new file mode 100644 index 00000000000..e5d8829278d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts @@ -0,0 +1,70 @@ +import { useQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { + getProxyBaseUrl, + getGlobalLitellmHeaderName, + deriveErrorMessage, + handleError, +} from "@/components/networking"; +import { all_admin_roles } from "@/utils/roles"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface AccessGroupResponse { + access_group_id: string; + access_group_name: string; + description: string | null; + access_model_ids: string[]; + access_mcp_server_ids: string[]; + access_agent_ids: string[]; + assigned_team_ids: string[]; + assigned_key_ids: string[]; + created_at: string; + created_by: string | null; + updated_at: string; + updated_by: string | null; +} + +// ── Query keys (shared across access-group hooks) ──────────────────────────── + +export const accessGroupKeys = createQueryKeys("accessGroups"); + +// ── Fetch function ─────────────────────────────────────────────────────────── + +const fetchAccessGroups = async ( + accessToken: string, +): Promise => { + const baseUrl = getProxyBaseUrl(); + const url = `${baseUrl}/v1/access_group`; + + const response = await fetch(url, { + method: "GET", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return response.json(); +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +export const useAccessGroups = () => { + const { accessToken, userRole } = useAuthorized(); + + return useQuery({ + queryKey: accessGroupKeys.list({}), + queryFn: async () => fetchAccessGroups(accessToken!), + enabled: + Boolean(accessToken) && all_admin_roles.includes(userRole || ""), + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts new file mode 100644 index 00000000000..4d71be94455 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup.ts @@ -0,0 +1,68 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + getProxyBaseUrl, + getGlobalLitellmHeaderName, + deriveErrorMessage, + handleError, +} from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface AccessGroupCreateParams { + access_group_name: string; + description?: string | null; + access_model_ids?: string[]; + access_mcp_server_ids?: string[]; + access_agent_ids?: string[]; + assigned_team_ids?: string[]; + assigned_key_ids?: string[]; +} + +// ── Fetch function ─────────────────────────────────────────────────────────── + +const createAccessGroup = async ( + accessToken: string, + params: AccessGroupCreateParams, +): Promise => { + const baseUrl = getProxyBaseUrl(); + const url = `${baseUrl}/v1/access_group`; + + const response = await fetch(url, { + method: "POST", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return response.json(); +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +export const useCreateAccessGroup = () => { + const { accessToken } = useAuthorized(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return createAccessGroup(accessToken, params); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: accessGroupKeys.all }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup.ts new file mode 100644 index 00000000000..5df5960ce0a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup.ts @@ -0,0 +1,55 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + getProxyBaseUrl, + getGlobalLitellmHeaderName, + deriveErrorMessage, + handleError, +} from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { accessGroupKeys } from "./useAccessGroups"; + +// ── Fetch function ─────────────────────────────────────────────────────────── + +const deleteAccessGroup = async ( + accessToken: string, + accessGroupId: string, +): Promise => { + const baseUrl = getProxyBaseUrl(); + const url = `${baseUrl}/v1/access_group/${encodeURIComponent(accessGroupId)}`; + + const response = await fetch(url, { + method: "DELETE", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + // 204 No Content — nothing to parse +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +export const useDeleteAccessGroup = () => { + const { accessToken } = useAuthorized(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (accessGroupId) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return deleteAccessGroup(accessToken, accessGroupId); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: accessGroupKeys.all }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts new file mode 100644 index 00000000000..1646458c63d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts @@ -0,0 +1,77 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + getProxyBaseUrl, + getGlobalLitellmHeaderName, + deriveErrorMessage, + handleError, +} from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { AccessGroupResponse, accessGroupKeys } from "./useAccessGroups"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface AccessGroupUpdateParams { + access_group_name?: string; + description?: string | null; + access_model_ids?: string[]; + access_mcp_server_ids?: string[]; + access_agent_ids?: string[]; + assigned_team_ids?: string[]; + assigned_key_ids?: string[]; +} + +export interface EditAccessGroupVariables { + accessGroupId: string; + params: AccessGroupUpdateParams; +} + +// ── Fetch function ─────────────────────────────────────────────────────────── + +const updateAccessGroup = async ( + accessToken: string, + accessGroupId: string, + params: AccessGroupUpdateParams, +): Promise => { + const baseUrl = getProxyBaseUrl(); + const url = `${baseUrl}/v1/access_group/${encodeURIComponent(accessGroupId)}`; + + const response = await fetch(url, { + method: "PUT", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return response.json(); +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +export const useEditAccessGroup = () => { + const { accessToken } = useAuthorized(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ accessGroupId, params }) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return updateAccessGroup(accessToken, accessGroupId, params); + }, + onSuccess: (_data, { accessGroupId }) => { + queryClient.invalidateQueries({ queryKey: accessGroupKeys.all }); + queryClient.invalidateQueries({ + queryKey: accessGroupKeys.detail(accessGroupId), + }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 28b8d81cc56..ae3bd76e3cf 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -35,6 +35,7 @@ import TransformRequestPanel from "@/components/transform_request"; import UIThemeSettings from "@/components/ui_theme_settings"; import Usage from "@/components/usage"; import UserDashboard from "@/components/user_dashboard"; +import { AccessGroupsPage } from "@/components/AccessGroups/AccessGroupsPage"; import VectorStoreManagement from "@/components/vector_store_management"; import SpendLogsTable from "@/components/view_logs"; import ViewUserDashboard from "@/components/view_users"; @@ -542,6 +543,8 @@ function CreateKeyPageContent() { ) : page == "claude-code-plugins" ? ( + ) : page == "access-groups" ? ( + ) : page == "vector-stores" ? ( ) : page == "new_usage" ? ( diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx new file mode 100644 index 00000000000..56f52d540c3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsDetailsPage.tsx @@ -0,0 +1,345 @@ +import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails"; +import { + Button, + Card, + Col, + Descriptions, + Empty, + Flex, + Layout, + List, + Row, + Spin, + Tabs, + Tag, + theme, + Typography +} from "antd"; +import { + ArrowLeftIcon, + BotIcon, + EditIcon, + KeyIcon, + LayersIcon, + ServerIcon, + UsersIcon, +} from "lucide-react"; +import { useState } from "react"; +import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; +import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal"; + +const { Title, Text } = Typography; +const { Content } = Layout; + +interface AccessGroupDetailProps { + accessGroupId: string; + onBack: () => void; +} + +export function AccessGroupDetail({ + accessGroupId, + onBack, +}: AccessGroupDetailProps) { + const { data: accessGroup, isLoading } = + useAccessGroupDetails(accessGroupId); + const { token } = theme.useToken(); + const [isEditModalVisible, setIsEditModalVisible] = useState(false); + const [showAllKeys, setShowAllKeys] = useState(false); + const [showAllTeams, setShowAllTeams] = useState(false); + + const MAX_PREVIEW = 5; + + if (isLoading) { + return ( + + + + + + ); + } + + if (!accessGroup) { + return ( + + + + + {/* Group Details */} + + + + + {accessGroup.description || "—"} + + + {new Date(accessGroup.created_at).toLocaleString()} + {accessGroup.created_by && ( + +  {"by"}  + + + )} + + + {new Date(accessGroup.updated_at).toLocaleString()} + {accessGroup.updated_by && ( + +  {"by"}  + + + )} + + + + + + {/* Attached Keys & Teams */} + + + + + Attached Keys + {keyIds.length} + + } + extra={ + keyIds.length > MAX_PREVIEW ? ( + + ) : null + } + > + {keyIds.length > 0 ? ( + + {displayedKeys.map((id) => ( + + + {id.length > 20 + ? `${id.slice(0, 10)}...${id.slice(-6)}` + : id} + + + ))} + + ) : ( + + )} + + + + + + Attached Teams + {teamIds.length} + + } + extra={ + teamIds.length > MAX_PREVIEW ? ( + + ) : null + } + > + {teamIds.length > 0 ? ( + + {displayedTeams.map((id) => ( + + + {id} + + + ))} + + ) : ( + + )} + + + + + {/* Resources Tabs */} + + + + + {/* Edit Modal */} + setIsEditModalVisible(false)} + /> + + ); +} diff --git a/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupBaseForm.tsx b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupBaseForm.tsx new file mode 100644 index 00000000000..df60457571e --- /dev/null +++ b/ui/litellm-dashboard/src/components/AccessGroups/AccessGroupsModal/AccessGroupBaseForm.tsx @@ -0,0 +1,159 @@ +import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; +import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; +import { ModelSelect } from "@/components/ModelSelect/ModelSelect"; +import type { FormInstance } from "antd"; +import { Form, Input, Select, Space, Tabs } from "antd"; +import { BotIcon, InfoIcon, LayersIcon, ServerIcon } from "lucide-react"; + +const { TextArea } = Input; + +export interface AccessGroupFormValues { + name: string; + description: string; + modelIds: string[]; + mcpServerIds: string[]; + agentIds: string[]; +} + +interface AccessGroupBaseFormProps { + form: FormInstance; + isNameDisabled?: boolean; +} + +export function AccessGroupBaseForm({ + form, + isNameDisabled = false, +}: AccessGroupBaseFormProps) { + const { data: agentsData } = useAgents(); + const { data: mcpServersData } = useMCPServers(); + + const agents = agentsData?.agents ?? []; + const mcpServers = mcpServersData ?? []; + const items = [ + { + key: "1", + label: ( + + + General Info + + ), + children: ( +
+ + + + +