From 610ef7b4cfda925b3e0dc3955ed84f31b5470e39 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 2 Feb 2026 19:04:18 -0800 Subject: [PATCH 01/18] chore: antd modal deprecated props --- .../src/components/AIHub/ModelHubTable.tsx | 8 ++--- .../src/components/OldTeams.tsx | 2 +- .../src/components/SSOModals.tsx | 6 ++-- .../src/components/admins.tsx | 10 +++--- .../src/components/budgets/budget_modal.tsx | 2 +- .../components/budgets/edit_budget_modal.tsx | 2 +- .../components/bulk_create_users_button.tsx | 32 +++++++++---------- .../src/components/cloudzero_export_modal.tsx | 2 +- .../src/components/create_user_button.tsx | 2 +- .../edit_auto_router_modal.tsx | 2 +- .../edit_model/edit_model_modal.tsx | 6 ++-- .../src/components/edit_user.tsx | 2 +- .../model_add/CredentialDeleteModal.tsx | 2 +- .../model_add/reuse_credentials.tsx | 2 +- .../src/components/onboarding_link.tsx | 2 +- .../organisms/create_key_button.tsx | 4 +-- .../components/organization/add_org_admin.tsx | 2 +- .../src/components/request_model_access.tsx | 2 +- .../components/CreateTagModal.tsx | 2 +- .../VectorStoreForm.tsx | 2 +- 20 files changed, 47 insertions(+), 47 deletions(-) diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 23bfb7d219..4843713e5a 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -483,7 +483,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, = ({ accessToken, publicPage, = ({ {canCreateOrManageTeams(userRole, userID, organizations) && ( = ({ <> = ({ {/* Clear Confirmation Modal */} setIsClearConfirmModalVisible(false)} okText="Yes, Clear" @@ -536,7 +536,7 @@ const SSOModals: React.FC = ({ = ({ const isLocal = process.env.NODE_ENV === "development"; if (isLocal != true) { - console.log = function () {}; + console.log = function () { }; } const baseUrl = useBaseUrl(); @@ -565,7 +565,7 @@ const AdminPanel: React.FC = ({ setIsAllowedIPModalVisible(false)} footer={[ , ]} width={1000} - destroyOnClose + destroyOnHidden >
diff --git a/ui/litellm-dashboard/src/components/edit_model/edit_model_modal.tsx b/ui/litellm-dashboard/src/components/edit_model/edit_model_modal.tsx index 88753b7df3..95ceb65d10 100644 --- a/ui/litellm-dashboard/src/components/edit_model/edit_model_modal.tsx +++ b/ui/litellm-dashboard/src/components/edit_model/edit_model_modal.tsx @@ -53,8 +53,8 @@ export const handleEditModelSubmit = async ( model_info: model_info_model_id !== undefined ? { - id: model_info_model_id, - } + id: model_info_model_id, + } : undefined, }; @@ -119,7 +119,7 @@ const EditModelModal: React.FC = ({ visible, onCancel, mode return ( = ({ visible, possibleUIRoles, } return ( - +
= ({ footer={null} onCancel={handleCancel} closable={true} - destroyOnClose={true} + destroyOnHidden={true} maskClosable={false} >
diff --git a/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx b/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx index fb0e372457..e16c0b73ac 100644 --- a/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx +++ b/ui/litellm-dashboard/src/components/model_add/reuse_credentials.tsx @@ -32,7 +32,7 @@ const ReuseCredentialsModal: React.FC = ({ return ( { onCancel(); form.resetFields(); diff --git a/ui/litellm-dashboard/src/components/onboarding_link.tsx b/ui/litellm-dashboard/src/components/onboarding_link.tsx index f339afda05..e6461d26a4 100644 --- a/ui/litellm-dashboard/src/components/onboarding_link.tsx +++ b/ui/litellm-dashboard/src/components/onboarding_link.tsx @@ -63,7 +63,7 @@ export default function OnboardingModal({ return ( = ({ team, teams, data, addKey }) => { {isCreateUserModalVisible && ( setIsCreateUserModalVisible(false)} footer={null} width={800} @@ -1359,7 +1359,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey }) => { )} {apiKey && ( - + Save your Key diff --git a/ui/litellm-dashboard/src/components/organization/add_org_admin.tsx b/ui/litellm-dashboard/src/components/organization/add_org_admin.tsx index 6f50dc45f7..309a6fdfcd 100644 --- a/ui/litellm-dashboard/src/components/organization/add_org_admin.tsx +++ b/ui/litellm-dashboard/src/components/organization/add_org_admin.tsx @@ -45,7 +45,7 @@ const AddOrgAdmin: FC = ({ userRole, userID, selectedOrganizat = ({ userModels, accessToken, = ({ visible, onCancel, onSu }; return ( - + diff --git a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreForm.tsx b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreForm.tsx index 506543eb42..18b5bd87d1 100644 --- a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreForm.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreForm.tsx @@ -108,7 +108,7 @@ const VectorStoreForm: React.FC = ({ }; return ( - + Date: Tue, 3 Feb 2026 00:25:58 -0300 Subject: [PATCH 02/18] adding together ai models to litellm models json --- model_prices_and_context_window.json | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 485bee4f19..a7962643e4 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -27113,6 +27113,34 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "together_ai/zai-org/GLM-4.7": { + "input_cost_per_token": 45e-07, + "litellm_provider": "together_ai", + "max_input_tokens": 200000, + "max_output_tokens": 200000, + "max_tokens": 200000, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://www.together.ai/models/glm-4-7", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "together_ai/moonshotai/Kimi-K2.5": { + "input_cost_per_token": 5e-07, + "litellm_provider": "together_ai", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "output_cost_per_token": 2.8e-06, + "source": "https://www.together.ai/models/kimi-k2-5", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_reasoning": true + }, "together_ai/moonshotai/Kimi-K2-Instruct-0905": { "input_cost_per_token": 1e-06, "litellm_provider": "together_ai", From a92a0fa686dc394f0b6505d85dd29660b42a2993 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Feb 2026 12:53:07 +0530 Subject: [PATCH 03/18] Add support for delete via only file_id --- litellm/llms/gemini/files/transformation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/gemini/files/transformation.py b/litellm/llms/gemini/files/transformation.py index 37f1376c2b..35e1b677b4 100644 --- a/litellm/llms/gemini/files/transformation.py +++ b/litellm/llms/gemini/files/transformation.py @@ -299,7 +299,7 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): # Extract the file path from full URI file_name = file_id.split("/v1beta/")[-1] else: - file_name = file_id + file_name = f"files/{file_id}" # Construct the delete URL url = f"{api_base}/v1beta/{file_name}" From cad15e21cc054c5ccfa5d5dcc67671c63020962d Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Feb 2026 13:18:44 +0530 Subject: [PATCH 04/18] Add support for delete via only file_id --- litellm/llms/gemini/files/transformation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/llms/gemini/files/transformation.py b/litellm/llms/gemini/files/transformation.py index 35e1b677b4..4dc61dc5f4 100644 --- a/litellm/llms/gemini/files/transformation.py +++ b/litellm/llms/gemini/files/transformation.py @@ -299,7 +299,9 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): # Extract the file path from full URI file_name = file_id.split("/v1beta/")[-1] else: - file_name = f"files/{file_id}" + if not file_id.startswith("files/"): + file_id = f"files/{file_id}" + file_name = file_id # Construct the delete URL url = f"{api_base}/v1beta/{file_name}" From 3765d88809bbe4b0df3e0fc74141c613c50f8038 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Feb 2026 16:23:18 +0530 Subject: [PATCH 05/18] Fix: Extra inputs are not permitted, field: 'messages[2].provider_specific_fields' --- .../llms/fireworks_ai/chat/transformation.py | 4 +++ .../test_fireworks_ai_chat_transformation.py | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py index 86bcd94450..f6ea9c57f7 100644 --- a/litellm/llms/fireworks_ai/chat/transformation.py +++ b/litellm/llms/fireworks_ai/chat/transformation.py @@ -236,6 +236,10 @@ class FireworksAIConfig(OpenAIGPTConfig): disable_add_transform_inline_image_block=disable_add_transform_inline_image_block, ) filter_value_from_dict(cast(dict, message), "cache_control") + # Remove fields not permitted by FireworksAI that may cause: + # "Not permitted, field: 'messages[n].provider_specific_fields'" + if isinstance(message, dict) and "provider_specific_fields" in message: + message.pop("provider_specific_fields", None) return messages diff --git a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py index 43c1c41374..8006ffdff1 100644 --- a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py +++ b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py @@ -108,3 +108,32 @@ def test_get_supported_openai_params_reasoning_effort(): "fireworks_ai/accounts/fireworks/models/llama-v3-70b-instruct" ) assert "reasoning_effort" not in unsupported_params + + +def test_transform_messages_helper_removes_provider_specific_fields(): + """ + Test that _transform_messages_helper removes provider_specific_fields from messages. + """ + config = FireworksAIConfig() + # Simulated messages, as dicts, including provider_specific_fields + messages = [ + { + "role": "user", + "content": "Hello!", + "provider_specific_fields": {"extra": "should be removed"}, + }, + { + "role": "assistant", + "content": "Hi there!", + "provider_specific_fields": {"more": "remove this"}, + }, + { + "role": "user", + "content": "How are you?", + # no provider_specific_fields + } + ] + # Call helper + out = config._transform_messages_helper(messages, model="fireworks/test", litellm_params={}) + for msg in out: + assert "provider_specific_fields" not in msg From ea19d8dbf6a8093554f8057a9c52db6f7c9699a3 Mon Sep 17 00:00:00 2001 From: Felipe Rodrigues Gare Carnielli Date: Tue, 3 Feb 2026 09:57:00 -0300 Subject: [PATCH 06/18] fixing glm-4.7 input cost per token --- model_prices_and_context_window.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index a7962643e4..b9e48fd7e1 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -27114,7 +27114,7 @@ "supports_tool_choice": true }, "together_ai/zai-org/GLM-4.7": { - "input_cost_per_token": 45e-07, + "input_cost_per_token": 4.5e-07, "litellm_provider": "together_ai", "max_input_tokens": 200000, "max_output_tokens": 200000, From ff568de2cbcc30efe6c6c45e50923fb14f7dcaf8 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Feb 2026 18:57:39 +0530 Subject: [PATCH 07/18] Add get files API support and tests --- litellm/llms/gemini/files/transformation.py | 10 +- .../llms/gemini/files/__init__.py | 1 + .../files/test_gemini_files_transformation.py | 298 ++++++++++++++++++ 3 files changed, 304 insertions(+), 5 deletions(-) create mode 100644 tests/test_litellm/llms/gemini/files/__init__.py create mode 100644 tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py diff --git a/litellm/llms/gemini/files/transformation.py b/litellm/llms/gemini/files/transformation.py index 4dc61dc5f4..577b748692 100644 --- a/litellm/llms/gemini/files/transformation.py +++ b/litellm/llms/gemini/files/transformation.py @@ -210,7 +210,7 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): We expect file_id to be the URI (e.g. https://generativelanguage.googleapis.com/v1beta/files/...) as returned by the upload response. """ - api_key = litellm_params.get("api_key") + api_key = litellm_params.get("api_key") or self.get_api_key() if not api_key: raise ValueError("api_key is required") @@ -222,7 +222,8 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): api_base = api_base.rstrip("/") url = "{}/v1beta/{}?key={}".format(api_base, file_id, api_key) - return url, {"Content-Type": "application/json"} + # Return empty params dict - API key is already in URL, no query params needed + return url, {} def transform_retrieve_file_response( self, @@ -235,6 +236,7 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): """ try: response_json = raw_response.json() + print(f"response_json: {response_json}") # Map Gemini state to OpenAI status gemini_state = response_json.get("state", "STATE_UNSPECIFIED") @@ -299,9 +301,7 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): # Extract the file path from full URI file_name = file_id.split("/v1beta/")[-1] else: - if not file_id.startswith("files/"): - file_id = f"files/{file_id}" - file_name = file_id + file_name = file_id if file_id.startswith("files/") else f"files/{file_id}" # Construct the delete URL url = f"{api_base}/v1beta/{file_name}" diff --git a/tests/test_litellm/llms/gemini/files/__init__.py b/tests/test_litellm/llms/gemini/files/__init__.py new file mode 100644 index 0000000000..f48fe7dbe2 --- /dev/null +++ b/tests/test_litellm/llms/gemini/files/__init__.py @@ -0,0 +1 @@ +"""Tests for Gemini files functionality""" diff --git a/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py b/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py new file mode 100644 index 0000000000..a5f72fc08c --- /dev/null +++ b/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py @@ -0,0 +1,298 @@ +""" +Test Google AI Studio (Gemini) files transformation functionality +""" + +import os +import pytest +from unittest.mock import Mock, patch + +import httpx + +from litellm.llms.gemini.files.transformation import GoogleAIStudioFilesHandler +from litellm.types.llms.openai import OpenAIFileObject + + +class TestGoogleAIStudioFilesTransformation: + """Test Google AI Studio files transformation""" + + def setup_method(self): + """Setup test method""" + self.handler = GoogleAIStudioFilesHandler() + + def test_transform_retrieve_file_request_with_full_uri(self): + """ + Test that transform_retrieve_file_request returns empty params dict + to avoid 'Content-Type' query parameter error + + Regression test for: https://github.com/BerriAI/litellm/issues/XXX + When retrieving a file, the API was incorrectly trying to pass Content-Type + as a query parameter, which Gemini API rejected. + """ + file_id = "https://generativelanguage.googleapis.com/v1beta/files/test123" + litellm_params = {"api_key": "test-api-key"} + + url, params = self.handler.transform_retrieve_file_request( + file_id=file_id, + optional_params={}, + litellm_params=litellm_params, + ) + + # Verify URL is constructed correctly with API key + assert "key=test-api-key" in url + assert file_id in url + + # CRITICAL: params should be empty dict, not contain Content-Type or any other params + # These would be incorrectly interpreted as query parameters + assert params == {}, f"Expected empty params dict, got: {params}" + assert "Content-Type" not in params, "Content-Type should not be in query params" + + def test_transform_retrieve_file_request_with_file_name_only(self): + """ + Test that transform_retrieve_file_request handles file_id without full URI + """ + file_id = "files/test123" + litellm_params = {"api_key": "test-api-key"} + + url, params = self.handler.transform_retrieve_file_request( + file_id=file_id, + optional_params={}, + litellm_params=litellm_params, + ) + + # Verify URL is constructed correctly + assert "generativelanguage.googleapis.com" in url + assert file_id in url + assert "key=test-api-key" in url + + # CRITICAL: params should be empty dict + assert params == {}, f"Expected empty params dict, got: {params}" + assert "Content-Type" not in params, "Content-Type should not be in query params" + + @patch.dict('os.environ', {}, clear=True) + @patch('litellm.llms.gemini.common_utils.get_secret_str', return_value=None) + def test_transform_retrieve_file_request_missing_api_key(self, mock_get_secret): + """Test that transform_retrieve_file_request raises error when API key is missing""" + file_id = "files/test123" + litellm_params = {} + + with pytest.raises(ValueError, match="api_key is required"): + self.handler.transform_retrieve_file_request( + file_id=file_id, + optional_params={}, + litellm_params=litellm_params, + ) + + def test_transform_retrieve_file_response_success(self): + """Test successful transformation of Gemini file retrieval response""" + # Mock response data from Gemini API + mock_response_data = { + "name": "files/test123", + "displayName": "test_file.pdf", + "mimeType": "application/pdf", + "sizeBytes": "1024", + "createTime": "2024-01-15T10:30:00.123456Z", + "updateTime": "2024-01-15T10:30:00.123456Z", + "expirationTime": "2024-01-17T10:30:00.123456Z", + "sha256Hash": "abcd1234", + "uri": "https://generativelanguage.googleapis.com/v1beta/files/test123", + "state": "ACTIVE", + } + + # Create mock httpx response + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = mock_response_data + + # Create mock logging object + mock_logging_obj = Mock() + + # Transform response + result = self.handler.transform_retrieve_file_response( + raw_response=mock_response, + logging_obj=mock_logging_obj, + litellm_params={}, + ) + + # Verify transformation + assert isinstance(result, OpenAIFileObject) + assert result.id == mock_response_data["uri"] + assert result.filename == mock_response_data["displayName"] + assert result.bytes == int(mock_response_data["sizeBytes"]) + assert result.object == "file" + assert result.purpose == "user_data" + assert result.status == "processed" # ACTIVE state maps to processed + assert result.status_details is None + + def test_transform_retrieve_file_response_failed_state(self): + """Test transformation of Gemini file retrieval response with FAILED state""" + mock_response_data = { + "name": "files/test123", + "displayName": "test_file.pdf", + "mimeType": "application/pdf", + "sizeBytes": "1024", + "createTime": "2024-01-15T10:30:00.123456Z", + "uri": "https://generativelanguage.googleapis.com/v1beta/files/test123", + "state": "FAILED", + "error": {"message": "Upload failed", "code": "INTERNAL"}, + } + + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = mock_response_data + mock_logging_obj = Mock() + + result = self.handler.transform_retrieve_file_response( + raw_response=mock_response, + logging_obj=mock_logging_obj, + litellm_params={}, + ) + + # Verify error state handling + assert result.status == "error" + assert result.status_details is not None + assert "message" in result.status_details + + def test_transform_retrieve_file_response_processing_state(self): + """Test transformation of Gemini file retrieval response with PROCESSING state""" + mock_response_data = { + "name": "files/test123", + "displayName": "test_file.pdf", + "mimeType": "application/pdf", + "sizeBytes": "1024", + "createTime": "2024-01-15T10:30:00.123456Z", + "uri": "https://generativelanguage.googleapis.com/v1beta/files/test123", + "state": "PROCESSING", + } + + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = mock_response_data + mock_logging_obj = Mock() + + result = self.handler.transform_retrieve_file_response( + raw_response=mock_response, + logging_obj=mock_logging_obj, + litellm_params={}, + ) + + # PROCESSING state should map to "uploaded" status + assert result.status == "uploaded" + + def test_transform_retrieve_file_response_missing_createTime(self): + """ + Test that transform_retrieve_file_response raises proper error when createTime is missing + + This tests the error scenario that occurs when API returns an error response + without the expected file metadata fields. + """ + # Mock error response from Gemini API (missing createTime) + mock_response_data = { + "error": { + "code": 400, + "message": "Invalid request", + "status": "INVALID_ARGUMENT", + } + } + + mock_response = Mock(spec=httpx.Response) + mock_response.json.return_value = mock_response_data + mock_logging_obj = Mock() + + # Should raise ValueError with helpful message + with pytest.raises(ValueError, match="Error parsing file retrieve response"): + self.handler.transform_retrieve_file_response( + raw_response=mock_response, + logging_obj=mock_logging_obj, + litellm_params={}, + ) + + def test_validate_environment(self): + """Test that validate_environment properly adds API key to headers""" + headers = {} + api_key = "test-gemini-api-key" + + result_headers = self.handler.validate_environment( + headers=headers, + model="gemini-pro", + messages=[], + optional_params={}, + litellm_params={}, + api_key=api_key, + ) + + # Verify API key is added to headers + assert "x-goog-api-key" in result_headers + assert result_headers["x-goog-api-key"] == api_key + + @patch.dict('os.environ', {}, clear=True) + @patch('litellm.llms.gemini.common_utils.get_secret_str', return_value=None) + def test_validate_environment_missing_api_key(self, mock_get_secret): + """Test that validate_environment raises error when API key is missing""" + headers = {} + + with pytest.raises( + ValueError, match="GEMINI_API_KEY is required for Google AI Studio file operations" + ): + self.handler.validate_environment( + headers=headers, + model="gemini-pro", + messages=[], + optional_params={}, + litellm_params={}, + api_key=None, + ) + + def test_get_complete_url(self): + """Test that get_complete_url constructs proper upload URL""" + api_base = "https://generativelanguage.googleapis.com" + api_key = "test-api-key" + + url = self.handler.get_complete_url( + api_base=api_base, + api_key=api_key, + model="gemini-pro", + optional_params={}, + litellm_params={}, + ) + + # Verify URL structure + assert api_base in url + assert "upload/v1beta/files" in url + assert f"key={api_key}" in url + + def test_transform_delete_file_request_with_full_uri(self): + """Test delete file request transformation with full URI""" + file_id = "https://generativelanguage.googleapis.com/v1beta/files/test123" + litellm_params = { + "api_key": "test-api-key", + "api_base": "https://generativelanguage.googleapis.com", + } + + url, params = self.handler.transform_delete_file_request( + file_id=file_id, + optional_params={}, + litellm_params=litellm_params, + ) + + # Verify URL extraction + assert "files/test123" in url + assert "generativelanguage.googleapis.com" in url + + # Params should be empty (API key goes in header via validate_environment) + assert params == {} + + def test_transform_delete_file_request_with_file_name_only(self): + """Test delete file request transformation with file name only""" + file_id = "files/test123" + litellm_params = { + "api_key": "test-api-key", + "api_base": "https://generativelanguage.googleapis.com", + } + + url, params = self.handler.transform_delete_file_request( + file_id=file_id, + optional_params={}, + litellm_params=litellm_params, + ) + + # Verify URL construction + assert file_id in url + assert "generativelanguage.googleapis.com" in url + assert params == {} From d8761de660a08502b2d634c79acdef3c8b39108f Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 3 Feb 2026 18:59:10 +0530 Subject: [PATCH 08/18] Add get files API support and tests --- litellm/llms/gemini/files/transformation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litellm/llms/gemini/files/transformation.py b/litellm/llms/gemini/files/transformation.py index 577b748692..cc799cfd6a 100644 --- a/litellm/llms/gemini/files/transformation.py +++ b/litellm/llms/gemini/files/transformation.py @@ -236,7 +236,6 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): """ try: response_json = raw_response.json() - print(f"response_json: {response_json}") # Map Gemini state to OpenAI status gemini_state = response_json.get("state", "STATE_UNSPECIFIED") From c80fae71ef3a35f2953e7ba825d007db3d125794 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Tue, 3 Feb 2026 10:39:39 -0800 Subject: [PATCH 09/18] bump litellm enterprise PIP --- ...litellm_enterprise-0.1.29-py3-none-any.whl | Bin 0 -> 111358 bytes .../dist/litellm_enterprise-0.1.29.tar.gz | Bin 0 -> 48839 bytes enterprise/pyproject.toml | 4 +-- ...odel_prices_and_context_window_backup.json | 28 ++++++++++++++++++ poetry.lock | 20 +------------ requirements.txt | 2 +- 6 files changed, 32 insertions(+), 22 deletions(-) create mode 100644 enterprise/dist/litellm_enterprise-0.1.29-py3-none-any.whl create mode 100644 enterprise/dist/litellm_enterprise-0.1.29.tar.gz diff --git a/enterprise/dist/litellm_enterprise-0.1.29-py3-none-any.whl b/enterprise/dist/litellm_enterprise-0.1.29-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..0895ecbc4271ba78bed1da72468e19f87324b33a GIT binary patch literal 111358 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 literal 0 HcmV?d00001 diff --git a/enterprise/dist/litellm_enterprise-0.1.29.tar.gz b/enterprise/dist/litellm_enterprise-0.1.29.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..6781cf26cc9e9feab8ae6a0bacbb31fa52501dd0 GIT binary patch literal 48839 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.6.2 # for PDF text extraction in RAG ingestion ######################## # LITELLM ENTERPRISE DEPENDENCIES ######################## -litellm-enterprise==0.1.28 +litellm-enterprise==0.1.29 From 9ed11c5cdf9eeea521550b8e8805001f30c6e9e8 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 3 Feb 2026 12:52:33 -0800 Subject: [PATCH 10/18] [Feat] Allow calling A2A agents through LiteLLM /chat/completions API (#20358) * init A2AConfig * add transform files * feat: A2A * feat A2AConfig * fix get_secret_str * init: A2AConfig * init A2AConfig common utils * A2AConfig * test_a2a_completion_async_non_streaming * fix * Update litellm/main.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * add multi part conversation support * extract_text_from_a2a_message --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- litellm/__init__.py | 1 + litellm/_lazy_imports_registry.py | 2 + litellm/llms/a2a/__init__.py | 6 + litellm/llms/a2a/chat/__init__.py | 6 + litellm/llms/a2a/chat/streaming_iterator.py | 103 ++++++ litellm/llms/a2a/chat/transformation.py | 303 ++++++++++++++++++ litellm/llms/a2a/common_utils.py | 134 ++++++++ litellm/main.py | 40 ++- litellm/types/utils.py | 1 + litellm/utils.py | 8 + provider_endpoints_support.json | 17 + .../code_coverage_tests/recursive_detector.py | 1 + tests/llm_translation/test_a2a.py | 132 ++++++++ 13 files changed, 750 insertions(+), 4 deletions(-) create mode 100644 litellm/llms/a2a/__init__.py create mode 100644 litellm/llms/a2a/chat/__init__.py create mode 100644 litellm/llms/a2a/chat/streaming_iterator.py create mode 100644 litellm/llms/a2a/chat/transformation.py create mode 100644 litellm/llms/a2a/common_utils.py create mode 100644 tests/llm_translation/test_a2a.py diff --git a/litellm/__init__.py b/litellm/__init__.py index 112d58d49d..f857e10eed 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1378,6 +1378,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.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 from .llms.infinity.embedding.transformation import InfinityEmbeddingConfig as InfinityEmbeddingConfig diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index 0e52e9a59e..a01fe9c11d 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -213,6 +213,7 @@ LLM_CONFIG_NAMES = ( "TopazImageVariationConfig", "OpenAITextCompletionConfig", "GroqChatConfig", + "A2AConfig", "GenAIHubOrchestrationConfig", "VoyageEmbeddingConfig", "VoyageContextualEmbeddingConfig", @@ -850,6 +851,7 @@ _LLM_CONFIGS_IMPORT_MAP = { "OpenAITextCompletionConfig", ), "GroqChatConfig": (".llms.groq.chat.transformation", "GroqChatConfig"), + "A2AConfig": (".llms.a2a.chat.transformation", "A2AConfig"), "GenAIHubOrchestrationConfig": ( ".llms.sap.chat.transformation", "GenAIHubOrchestrationConfig", diff --git a/litellm/llms/a2a/__init__.py b/litellm/llms/a2a/__init__.py new file mode 100644 index 0000000000..043efa5e8b --- /dev/null +++ b/litellm/llms/a2a/__init__.py @@ -0,0 +1,6 @@ +""" +A2A (Agent-to-Agent) Protocol Provider for LiteLLM +""" +from .chat.transformation import A2AConfig + +__all__ = ["A2AConfig"] diff --git a/litellm/llms/a2a/chat/__init__.py b/litellm/llms/a2a/chat/__init__.py new file mode 100644 index 0000000000..76bf4dd71d --- /dev/null +++ b/litellm/llms/a2a/chat/__init__.py @@ -0,0 +1,6 @@ +""" +A2A Chat Completion Implementation +""" +from .transformation import A2AConfig + +__all__ = ["A2AConfig"] diff --git a/litellm/llms/a2a/chat/streaming_iterator.py b/litellm/llms/a2a/chat/streaming_iterator.py new file mode 100644 index 0000000000..84b6fffaa3 --- /dev/null +++ b/litellm/llms/a2a/chat/streaming_iterator.py @@ -0,0 +1,103 @@ +""" +A2A Streaming Response Iterator +""" +from typing import Optional, Union + +from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator +from litellm.types.utils import GenericStreamingChunk, ModelResponseStream + +from ..common_utils import extract_text_from_a2a_response + + +class A2AModelResponseIterator(BaseModelResponseIterator): + """ + Iterator for parsing A2A streaming responses. + + Converts A2A JSON-RPC streaming chunks to OpenAI-compatible format. + """ + + def __init__( + self, + streaming_response, + sync_stream: bool, + json_mode: Optional[bool] = False, + model: str = "a2a/agent", + ): + super().__init__( + streaming_response=streaming_response, + sync_stream=sync_stream, + json_mode=json_mode, + ) + self.model = model + + def chunk_parser(self, chunk: dict) -> Union[GenericStreamingChunk, ModelResponseStream]: + """ + Parse A2A streaming chunk to OpenAI format. + + A2A chunk format: + { + "jsonrpc": "2.0", + "id": "request-id", + "result": { + "message": { + "parts": [{"kind": "text", "text": "content"}] + } + } + } + + Or for tasks: + { + "jsonrpc": "2.0", + "result": { + "kind": "task", + "status": {"state": "running"}, + "artifacts": [{"parts": [{"kind": "text", "text": "content"}]}] + } + } + """ + try: + # Extract text from A2A response + text = extract_text_from_a2a_response(chunk) + + # Determine finish reason + finish_reason = self._get_finish_reason(chunk) + + # Return generic streaming chunk + return GenericStreamingChunk( + text=text, + is_finished=bool(finish_reason), + finish_reason=finish_reason or "", + usage=None, + index=0, + tool_use=None, + ) + except Exception: + # Return empty chunk on parse error + return GenericStreamingChunk( + text="", + is_finished=False, + finish_reason="", + usage=None, + index=0, + tool_use=None, + ) + + def _get_finish_reason(self, chunk: dict) -> Optional[str]: + """Extract finish reason from A2A chunk""" + result = chunk.get("result", {}) + + # Check for task completion + if isinstance(result, dict): + status = result.get("status", {}) + if isinstance(status, dict): + state = status.get("state") + if state == "completed": + return "stop" + elif state == "failed": + return "error" + + # Check for [DONE] marker + if chunk.get("done") is True: + return "stop" + + return None diff --git a/litellm/llms/a2a/chat/transformation.py b/litellm/llms/a2a/chat/transformation.py new file mode 100644 index 0000000000..243fba6371 --- /dev/null +++ b/litellm/llms/a2a/chat/transformation.py @@ -0,0 +1,303 @@ +""" +A2A Protocol Transformation for LiteLLM +""" +import uuid +from typing import Any, Dict, Iterator, List, Optional, Union, cast + +import httpx +from pydantic import BaseModel + +from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator +from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException +from litellm.types.llms.openai import AllMessageValues +from litellm.types.utils import Choices, Message, ModelResponse + +from ..common_utils import ( + A2AError, + convert_messages_to_prompt, + extract_text_from_a2a_response, +) +from .streaming_iterator import A2AModelResponseIterator + + +class A2AConfig(BaseConfig): + """ + Configuration for A2A (Agent-to-Agent) Protocol. + + Handles transformation between OpenAI and A2A JSON-RPC 2.0 formats. + """ + + def get_supported_openai_params(self, model: str) -> List[str]: + """Return list of supported OpenAI parameters""" + return [ + "stream", + "temperature", + "max_tokens", + "top_p", + ] + + def map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + drop_params: bool, + ) -> dict: + """ + Map OpenAI parameters to A2A parameters. + + For A2A protocol, we don't need to map most parameters since + they're handled in the transform_request method. + """ + return optional_params + + def validate_environment( + self, + headers: dict, + model: str, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> dict: + """ + Validate environment and set headers for A2A requests. + + Args: + headers: Request headers dict + model: Model name + messages: Messages list + optional_params: Optional parameters + litellm_params: LiteLLM parameters + api_key: API key (optional for A2A) + api_base: API base URL + + Returns: + Updated headers dict + """ + # Ensure Content-Type is set to application/json for JSON-RPC 2.0 + if "content-type" not in headers and "Content-Type" not in headers: + headers["Content-Type"] = "application/json" + + # Add Authorization header if API key is provided + if api_key is not None: + headers["Authorization"] = f"Bearer {api_key}" + + return headers + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + """ + Get the complete A2A agent endpoint URL. + + A2A agents use JSON-RPC 2.0 at the base URL, not specific paths. + The method (message/send or message/stream) is specified in the + JSON-RPC request body, not in the URL. + + Args: + api_base: Base URL of the A2A agent (e.g., "http://0.0.0.0:9999") + api_key: API key (not used for URL construction) + model: Model name (not used for A2A, agent determined by api_base) + optional_params: Optional parameters + litellm_params: LiteLLM parameters + stream: Whether this is a streaming request (affects JSON-RPC method) + + Returns: + Complete URL for the A2A endpoint (base URL) + """ + if api_base is None: + raise ValueError("api_base is required for A2A provider") + + # A2A uses JSON-RPC 2.0 at the base URL + # Remove trailing slash for consistency + return api_base.rstrip("/") + + def transform_request( + self, + model: str, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> dict: + """ + Transform OpenAI request to A2A JSON-RPC 2.0 format. + + Args: + model: Model name + messages: List of OpenAI messages + optional_params: Optional parameters + litellm_params: LiteLLM parameters + headers: Request headers + + Returns: + A2A JSON-RPC 2.0 request dict + """ + # Generate request ID + request_id = str(uuid.uuid4()) + + if not messages: + raise ValueError("At least one message is required for A2A completion") + + # Convert all messages to maintain conversation history + # Use helper to format conversation with role prefixes + full_context = convert_messages_to_prompt(messages) + + # Create single A2A message with full conversation context + a2a_message = { + "role": "user", + "parts": [{"kind": "text", "text": full_context}], + "messageId": str(uuid.uuid4()), + } + + # Build JSON-RPC 2.0 request + # For A2A protocol, the method is "message/send" for non-streaming + # and "message/stream" for streaming (handled by optional_params["stream"]) + method = "message/stream" if optional_params.get("stream") else "message/send" + + request_data = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": { + "message": a2a_message + } + } + + return request_data + + def transform_response( + self, + model: str, + raw_response: httpx.Response, + model_response: ModelResponse, + logging_obj: Any, + request_data: dict, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + encoding: Any, + api_key: Optional[str] = None, + json_mode: Optional[bool] = None, + ) -> ModelResponse: + """ + Transform A2A JSON-RPC 2.0 response to OpenAI format. + + Args: + model: Model name + raw_response: HTTP response from A2A agent + model_response: Model response object to populate + logging_obj: Logging object + request_data: Original request data + messages: Original messages + optional_params: Optional parameters + litellm_params: LiteLLM parameters + encoding: Encoding object + api_key: API key + json_mode: JSON mode flag + + Returns: + Populated ModelResponse object + """ + try: + response_json = raw_response.json() + except Exception as e: + raise A2AError( + status_code=raw_response.status_code, + message=f"Failed to parse A2A response: {str(e)}", + headers=dict(raw_response.headers), + ) + + # Check for JSON-RPC error + if "error" in response_json: + error = response_json["error"] + raise A2AError( + status_code=raw_response.status_code, + message=f"A2A error: {error.get('message', 'Unknown error')}", + headers=dict(raw_response.headers), + ) + + # Extract text from A2A response + text = extract_text_from_a2a_response(response_json) + + # Populate model response + model_response.choices = [ + Choices( + finish_reason="stop", + index=0, + message=Message( + content=text, + role="assistant", + ), + ) + ] + + # Set model + model_response.model = model + + # Set ID from response + model_response.id = response_json.get("id", str(uuid.uuid4())) + + return model_response + + def get_model_response_iterator( + self, + streaming_response: Union[Iterator, Any], + sync_stream: bool, + json_mode: Optional[bool] = False, + ) -> BaseModelResponseIterator: + """ + Get streaming iterator for A2A responses. + + Args: + streaming_response: Streaming response iterator + sync_stream: Whether this is a sync stream + json_mode: JSON mode flag + + Returns: + A2A streaming iterator + """ + return A2AModelResponseIterator( + streaming_response=streaming_response, + sync_stream=sync_stream, + json_mode=json_mode, + ) + + def _openai_message_to_a2a_message(self, message: Dict[str, Any]) -> Dict[str, Any]: + """ + Convert OpenAI message to A2A message format. + + Args: + message: OpenAI message dict + + Returns: + A2A message dict + """ + content = message.get("content", "") + role = message.get("role", "user") + + return { + "role": role, + "parts": [{"kind": "text", "text": str(content)}], + "messageId": str(uuid.uuid4()), + } + + def get_error_class( + self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers] + ) -> BaseLLMException: + """Return appropriate error class for A2A errors""" + # Convert headers to dict if needed + headers_dict = dict(headers) if isinstance(headers, httpx.Headers) else headers + return A2AError( + status_code=status_code, + message=error_message, + headers=headers_dict, + ) diff --git a/litellm/llms/a2a/common_utils.py b/litellm/llms/a2a/common_utils.py new file mode 100644 index 0000000000..4c7da78b42 --- /dev/null +++ b/litellm/llms/a2a/common_utils.py @@ -0,0 +1,134 @@ +""" +Common utilities for A2A (Agent-to-Agent) Protocol +""" +from typing import Any, Dict, List + +from pydantic import BaseModel + +from litellm.litellm_core_utils.prompt_templates.common_utils import ( + convert_content_list_to_str, +) +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.types.llms.openai import AllMessageValues + + +class A2AError(BaseLLMException): + """Base exception for A2A protocol errors""" + + def __init__( + self, + status_code: int, + message: str, + headers: Dict[str, Any] = {}, + ): + super().__init__( + status_code=status_code, + message=message, + headers=headers, + ) + + +def convert_messages_to_prompt(messages: List[AllMessageValues]) -> str: + """ + Convert OpenAI messages to a single prompt string for A2A agent. + + Formats each message as "{role}: {content}" and joins with newlines + to preserve conversation history. Handles both string and list content. + + Args: + messages: List of OpenAI-format messages + + Returns: + Formatted prompt string with full conversation context + """ + conversation_parts = [] + for msg in messages: + # Use LiteLLM's helper to extract text from content (handles both str and list) + content_text = convert_content_list_to_str(message=msg) + + # Get role + if isinstance(msg, BaseModel): + role = msg.model_dump().get("role", "user") + elif isinstance(msg, dict): + role = msg.get("role", "user") + else: + role = dict(msg).get("role", "user") # type: ignore + + if content_text: + conversation_parts.append(f"{role}: {content_text}") + + return "\n".join(conversation_parts) + + +def extract_text_from_a2a_message( + message: Dict[str, Any], depth: int = 0, max_depth: int = 10 +) -> str: + """ + Extract text content from A2A message parts. + + Args: + message: A2A message dict with 'parts' containing text parts + depth: Current recursion depth (internal use) + max_depth: Maximum recursion depth to prevent infinite loops + + Returns: + Concatenated text from all text parts + """ + if message is None or depth >= max_depth: + return "" + + parts = message.get("parts", []) + text_parts: List[str] = [] + + for part in parts: + if part.get("kind") == "text": + text_parts.append(part.get("text", "")) + # Handle nested parts if they exist + elif "parts" in part: + nested_text = extract_text_from_a2a_message(part, depth + 1, max_depth) + if nested_text: + text_parts.append(nested_text) + + return " ".join(text_parts) + + +def extract_text_from_a2a_response( + response_dict: Dict[str, Any], max_depth: int = 10 +) -> str: + """ + Extract text content from A2A response result. + + Args: + response_dict: A2A response dict with 'result' containing message + max_depth: Maximum recursion depth to prevent infinite loops + + Returns: + Text from response message parts + """ + result = response_dict.get("result", {}) + if not isinstance(result, dict): + return "" + + # A2A response can have different formats: + # 1. Direct message: {"result": {"kind": "message", "parts": [...]}} + # 2. Nested message: {"result": {"message": {"parts": [...]}}} + # 3. Task with artifacts: {"result": {"kind": "task", "artifacts": [{"parts": [...]}]}} + + # Check if result itself has parts (direct message) + if "parts" in result: + return extract_text_from_a2a_message(result, depth=0, max_depth=max_depth) + + # Check for nested message + message = result.get("message") + if message: + return extract_text_from_a2a_message(message, depth=0, max_depth=max_depth) + + # Handle task result with artifacts + artifacts = result.get("artifacts", []) + if artifacts and len(artifacts) > 0: + first_artifact = artifacts[0] + return extract_text_from_a2a_message( + first_artifact, depth=0, max_depth=max_depth + ) + + return "" diff --git a/litellm/main.py b/litellm/main.py index 7d591f7688..60c889ab1d 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -2199,6 +2199,38 @@ 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 == "a2a": + # A2A (Agent-to-Agent) Protocol + api_base = ( + api_base + or litellm.api_base + or get_secret_str("A2A_API_BASE") + ) + + if api_base is None: + raise Exception("api_base is required for A2A provider") + + headers = headers or litellm.headers + + 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, + provider_config=provider_config, + ) elif custom_llm_provider == "gigachat": # GigaChat - Sber AI's LLM (Russia) api_key = ( @@ -3113,8 +3145,8 @@ def completion( # type: ignore # noqa: PLR0915 api_key or litellm.api_key or litellm.openrouter_key - or get_secret("OPENROUTER_API_KEY") - or get_secret("OR_API_KEY") + or get_secret_str("OPENROUTER_API_KEY") + or get_secret_str("OR_API_KEY") ) openrouter_site_url = get_secret("OR_SITE_URL") or "https://litellm.ai" @@ -4884,8 +4916,8 @@ def embedding( # noqa: PLR0915 api_key or litellm.api_key or litellm.openrouter_key - or get_secret("OPENROUTER_API_KEY") - or get_secret("OR_API_KEY") + or get_secret_str("OPENROUTER_API_KEY") + or get_secret_str("OR_API_KEY") ) openrouter_site_url = get_secret("OR_SITE_URL") or "https://litellm.ai" diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 09c944e1fe..e1f780ffcc 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3029,6 +3029,7 @@ class LlmProviders(str, Enum): MISTRAL = "mistral" MILVUS = "milvus" GROQ = "groq" + A2A = "a2a" GIGACHAT = "gigachat" NVIDIA_NIM = "nvidia_nim" CEREBRAS = "cerebras" diff --git a/litellm/utils.py b/litellm/utils.py index f3d14b455c..7109f7aa88 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -1453,6 +1453,10 @@ def client(original_function): # noqa: PLR0915 logging_obj, kwargs = function_setup( original_function.__name__, rules_obj, start_time, *args, **kwargs ) + + # Type assertion: logging_obj is guaranteed to be non-None after function_setup + assert logging_obj is not None, "logging_obj should not be None after function_setup" + ## LOAD CREDENTIALS load_credentials_from_list(kwargs) kwargs["litellm_logging_obj"] = logging_obj @@ -1771,6 +1775,9 @@ def client(original_function): # noqa: PLR0915 logging_obj, kwargs = function_setup( original_function.__name__, rules_obj, start_time, *args, **kwargs ) + + # Type assertion: logging_obj is guaranteed to be non-None after function_setup + assert logging_obj is not None, "logging_obj should not be None after function_setup" modified_kwargs = await async_pre_call_deployment_hook(kwargs, call_type) if modified_kwargs is not None: @@ -7799,6 +7806,7 @@ class ProviderConfigManager: # Simple provider mappings (no model parameter needed) LlmProviders.DEEPSEEK: (lambda: litellm.DeepSeekChatConfig(), False), LlmProviders.GROQ: (lambda: litellm.GroqChatConfig(), False), + LlmProviders.A2A: (lambda: litellm.A2AConfig(), False), LlmProviders.BYTEZ: (lambda: litellm.BytezChatConfig(), False), LlmProviders.DATABRICKS: (lambda: litellm.DatabricksConfig(), False), LlmProviders.XAI: (lambda: litellm.XAIChatConfig(), False), diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 0738c6e4e0..93e9e7beaa 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -32,6 +32,23 @@ } }, "providers": { + "a2a": { + "display_name": "A2A (Agent-to-Agent) (`a2a`)", + "url": "https://docs.litellm.ai/docs/providers/a2a", + "endpoints": { + "chat_completions": true, + "messages": false, + "responses": false, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false + } + }, "abliteration": { "display_name": "Abliteration (`abliteration`)", "url": "https://docs.litellm.ai/docs/providers/abliteration", diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index d5640f4256..ed7595bb02 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -40,6 +40,7 @@ IGNORE_FUNCTIONS = [ "filter_exceptions_from_params", # max depth set (default 20) to prevent infinite recursion. "__getattr__", # lazy loading pattern in litellm/__init__.py with proper caching to prevent infinite recursion. "_validate_inheritance_chain", # max depth set (default 100) to prevent infinite recursion in policy inheritance validation. + "extract_text_from_a2a_message", # max depth set (default 10) to prevent infinite recursion in A2A message parsing. ] diff --git a/tests/llm_translation/test_a2a.py b/tests/llm_translation/test_a2a.py new file mode 100644 index 0000000000..2cfd3110ae --- /dev/null +++ b/tests/llm_translation/test_a2a.py @@ -0,0 +1,132 @@ +""" +Minimal E2E tests for A2A (Agent-to-Agent) Protocol provider. + +Tests validate that the endpoint is reachable and can handle both +streaming and non-streaming requests. +""" +import os +import sys + +import pytest + +sys.path.insert(0, os.path.abspath("../..")) + +import litellm + + +@pytest.mark.asyncio +async def test_a2a_completion_async_non_streaming(): + """ + Test A2A provider with async non-streaming request. + + Minimal test to validate endpoint reachability. + + Note: Requires an A2A agent running at http://0.0.0.0:9999 + Set A2A_API_BASE environment variable to use a different endpoint. + """ + api_base = os.environ.get("A2A_API_BASE", "http://0.0.0.0:9999") + + try: + response = await litellm.acompletion( + model="a2a/test-agent", + messages=[{"role": "user", "content": "Hello"}], + api_base=api_base, + stream=False, + ) + + print(f"Response: {response}") + assert response is not None, "Expected non-None response" + print(f"✅ Async non-streaming test passed") + + except litellm.exceptions.APIConnectionError as e: + pytest.skip(f"A2A agent not reachable at {api_base}: {e}") + except Exception as e: + pytest.fail(f"Error occurred: {e}") + + +@pytest.mark.asyncio +async def test_a2a_completion_async_streaming(): + """ + Test A2A provider with async streaming request. + + Minimal test to validate streaming endpoint reachability. + """ + api_base = os.environ.get("A2A_API_BASE", "http://0.0.0.0:9999") + + try: + response = await litellm.acompletion( + model="a2a/test-agent", + messages=[{"role": "user", "content": "Hello"}], + api_base=api_base, + stream=True, + ) + + chunks = [] + async for chunk in response: # type: ignore + chunks.append(chunk) + print(f"Chunk: {chunk}") + + assert len(chunks) > 0, "Expected at least one chunk in streaming response" + print(f"✅ Async streaming test passed: received {len(chunks)} chunks") + + except litellm.exceptions.APIConnectionError as e: + pytest.skip(f"A2A agent not reachable at {api_base}: {e}") + except Exception as e: + pytest.fail(f"Error occurred: {e}") + + +def test_a2a_completion_sync(): + """ + Test A2A provider with synchronous non-streaming request. + + Minimal test to validate sync endpoint reachability. + """ + api_base = os.environ.get("A2A_API_BASE", "http://0.0.0.0:9999") + + try: + response = litellm.completion( + model="a2a/test-agent", + messages=[{"role": "user", "content": "Hello"}], + api_base=api_base, + stream=False, + ) + + print(f"Response: {response}") + assert response is not None, "Expected non-None response" + print(f"✅ Sync non-streaming test passed") + + except litellm.exceptions.APIConnectionError as e: + pytest.skip(f"A2A agent not reachable at {api_base}: {e}") + except Exception as e: + pytest.fail(f"Error occurred: {e}") + + +def test_a2a_completion_sync_streaming(): + """ + Test A2A provider with synchronous streaming request. + + Minimal test to validate sync streaming endpoint reachability. + """ + api_base = os.environ.get("A2A_API_BASE", "http://0.0.0.0:9999") + + try: + response = litellm.completion( + model="a2a/test-agent", + messages=[{"role": "user", "content": "Hello"}], + api_base=api_base, + stream=True, + ) + + chunks = [] + for chunk in response: # type: ignore + chunks.append(chunk) + print(f"Chunk: {chunk}") + + assert len(chunks) > 0, "Expected at least one chunk in streaming response" + print(f"✅ Sync streaming test passed: received {len(chunks)} chunks") + + except litellm.exceptions.APIConnectionError as e: + pytest.skip(f"A2A agent not reachable at {api_base}: {e}") + except Exception as e: + pytest.fail(f"Error occurred: {e}") + From 59cab4d2aa9c436f60b619513c75b1ffb1d7aea9 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 3 Feb 2026 12:53:50 -0800 Subject: [PATCH 11/18] UI - Show team alias on Models health page (#20359) * feat(ui): Add team-alias column to Models Health Status UI - Added Team Alias column to the Models Health Status table - Updated HealthCheckComponent to accept teams prop - Updated health_check_columns to display team alias based on team_id - Falls back to team_id if team alias not found, or shows '-' if no team - Updated parent components to pass teams data to HealthCheckComponent Co-authored-by: ishaan * Update ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: Cursor Agent Co-authored-by: ishaan Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../ModelsAndEndpointsView.tsx | 1 + .../model_dashboard/HealthCheckComponent.tsx | 4 +++ .../model_dashboard/health_check_columns.tsx | 27 +++++++++++++++++++ .../components/templates/model_dashboard.tsx | 1 + 4 files changed, 33 insertions(+) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx index 6a4882a92a..8bfbaa8d3a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -400,6 +400,7 @@ const ModelsAndEndpointsView: React.FC = ({ premiumUser, te all_models_on_proxy={allModelsOnProxy} getDisplayModelName={getDisplayModelName} setSelectedModelId={setSelectedModelId} + teams={teams} /> string; setSelectedModelId?: (modelId: string) => void; + teams?: Team[] | null; } const HealthCheckComponent: React.FC = ({ @@ -32,6 +34,7 @@ const HealthCheckComponent: React.FC = ({ all_models_on_proxy, getDisplayModelName, setSelectedModelId, + teams, }) => { const [modelHealthStatuses, setModelHealthStatuses] = useState<{ [key: string]: HealthStatus }>({}); const [selectedModelsForHealth, setSelectedModelsForHealth] = useState([]); @@ -574,6 +577,7 @@ const HealthCheckComponent: React.FC = ({ showErrorModal, showSuccessModal, setSelectedModelId, + teams, )} data={modelData.data.map((model: any) => { const modelName = model.model_name; diff --git a/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx b/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx index 077b9d1004..3e8ae662ad 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx @@ -2,6 +2,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { Tooltip, Checkbox } from "antd"; import { Text } from "@tremor/react"; import { InformationCircleIcon, PlayIcon, RefreshIcon } from "@heroicons/react/outline"; +import { Team } from "@/components/key_team_helpers/key_list"; interface HealthCheckData { model_name: string; @@ -42,6 +43,7 @@ export const healthCheckColumns = ( showErrorModal?: (modelName: string, cleanedError: string, fullError: string) => void, showSuccessModal?: (modelName: string, response: any) => void, setSelectedModelId?: (modelId: string) => void, + teams?: Team[] | null, ): ColumnDef[] => [ { header: () => ( @@ -100,6 +102,31 @@ export const healthCheckColumns = ( ); }, }, + { + header: "Team Alias", + accessorKey: "model_info.team_id", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const model = row.original; + const teamId = model.model_info?.team_id; + + if (!teamId) { + return -; + } + + const team = teams?.find((t) => t.team_id === teamId); + const teamAlias = team?.team_alias || teamId; + + return ( +
+ +
{teamAlias}
+
+
+ ); + }, + }, { header: "Health Status", accessorKey: "health_status", diff --git a/ui/litellm-dashboard/src/components/templates/model_dashboard.tsx b/ui/litellm-dashboard/src/components/templates/model_dashboard.tsx index e4dc896f55..6f3ce27567 100644 --- a/ui/litellm-dashboard/src/components/templates/model_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/templates/model_dashboard.tsx @@ -1368,6 +1368,7 @@ const OldModelDashboard: React.FC = ({ all_models_on_proxy={all_models_on_proxy} getDisplayModelName={getDisplayModelName} setSelectedModelId={setSelectedModelId} + teams={teams} /> From d267c690860f083ddcd763d2ea782f375683fb08 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 3 Feb 2026 15:25:38 -0800 Subject: [PATCH 12/18] [Feat] Use A2A registered agents with /chat/completions (#20362) * test_a2a_registry_integration * fix: render agents on model dropdown on UI * init append_agents_to_model_group * route_a2a_agent_request * is_a2a_agent_model * route_a2a_agent_request * fix: error handling * docs A2A usage * docs fix * feat: working A2a streaming * fix transform --- docs/my-website/docs/a2a.md | 113 +------ docs/my-website/docs/a2a_invoking_agents.md | 280 ++++++++++++++++++ docs/my-website/sidebars.js | 1 + litellm/llms/a2a/chat/streaming_iterator.py | 2 +- litellm/llms/a2a/chat/transformation.py | 79 ++++- litellm/llms/a2a/common_utils.py | 20 +- litellm/main.py | 22 +- litellm/proxy/agent_endpoints/a2a_routing.py | 53 ++++ .../agent_endpoints/model_list_helpers.py | 96 ++++++ litellm/proxy/proxy_server.py | 19 ++ litellm/proxy/route_llm_request.py | 11 + .../test_model_list_helpers.py | 110 +++++++ .../proxy/test_route_a2a_models.py | 105 +++++++ .../test_litellm/test_a2a_registry_lookup.py | 73 +++++ 14 files changed, 860 insertions(+), 124 deletions(-) create mode 100644 docs/my-website/docs/a2a_invoking_agents.md create mode 100644 litellm/proxy/agent_endpoints/a2a_routing.py create mode 100644 litellm/proxy/agent_endpoints/model_list_helpers.py create mode 100644 tests/test_litellm/proxy/agent_endpoints/test_model_list_helpers.py create mode 100644 tests/test_litellm/proxy/test_route_a2a_models.py create mode 100644 tests/test_litellm/test_a2a_registry_lookup.py diff --git a/docs/my-website/docs/a2a.md b/docs/my-website/docs/a2a.md index a7e8b52d99..b1166a7809 100644 --- a/docs/my-website/docs/a2a.md +++ b/docs/my-website/docs/a2a.md @@ -68,116 +68,9 @@ Follow [this guide, to add your pydantic ai agent to LiteLLM Agent Gateway](./pr ## Invoking your Agents -Use the [A2A Python SDK](https://pypi.org/project/a2a-sdk) to invoke agents through LiteLLM. - -This example shows how to: -1. **List available agents** - Query `/v1/agents` to see which agents your key can access -2. **Select an agent** - Pick an agent from the list -3. **Invoke via A2A** - Use the A2A protocol to send messages to the agent - -```python showLineNumbers title="invoke_a2a_agent.py" -from uuid import uuid4 -import httpx -import asyncio -from a2a.client import A2ACardResolver, A2AClient -from a2a.types import MessageSendParams, SendMessageRequest - -# === CONFIGURE THESE === -LITELLM_BASE_URL = "http://localhost:4000" # Your LiteLLM proxy URL -LITELLM_VIRTUAL_KEY = "sk-1234" # Your LiteLLM Virtual Key -# ======================= - -async def main(): - headers = {"Authorization": f"Bearer {LITELLM_VIRTUAL_KEY}"} - - async with httpx.AsyncClient(headers=headers) as client: - # Step 1: List available agents - response = await client.get(f"{LITELLM_BASE_URL}/v1/agents") - agents = response.json() - - print("Available agents:") - for agent in agents: - print(f" - {agent['agent_name']} (ID: {agent['agent_id']})") - - if not agents: - print("No agents available for this key") - return - - # Step 2: Select an agent and invoke it - selected_agent = agents[0] - agent_id = selected_agent["agent_id"] - agent_name = selected_agent["agent_name"] - print(f"\nInvoking: {agent_name}") - - # Step 3: Use A2A protocol to invoke the agent - base_url = f"{LITELLM_BASE_URL}/a2a/{agent_id}" - resolver = A2ACardResolver(httpx_client=client, base_url=base_url) - agent_card = await resolver.get_agent_card() - a2a_client = A2AClient(httpx_client=client, agent_card=agent_card) - - request = SendMessageRequest( - id=str(uuid4()), - params=MessageSendParams( - message={ - "role": "user", - "parts": [{"kind": "text", "text": "Hello, what can you do?"}], - "messageId": uuid4().hex, - } - ), - ) - response = await a2a_client.send_message(request) - print(f"Response: {response.model_dump(mode='json', exclude_none=True, indent=4)}") - -if __name__ == "__main__": - asyncio.run(main()) -``` - -### Streaming Responses - -For streaming responses, use `send_message_streaming`: - -```python showLineNumbers title="invoke_a2a_agent_streaming.py" -from uuid import uuid4 -import httpx -import asyncio -from a2a.client import A2ACardResolver, A2AClient -from a2a.types import MessageSendParams, SendStreamingMessageRequest - -# === CONFIGURE THESE === -LITELLM_BASE_URL = "http://localhost:4000" # Your LiteLLM proxy URL -LITELLM_VIRTUAL_KEY = "sk-1234" # Your LiteLLM Virtual Key -LITELLM_AGENT_NAME = "ij-local" # Agent name registered in LiteLLM -# ======================= - -async def main(): - base_url = f"{LITELLM_BASE_URL}/a2a/{LITELLM_AGENT_NAME}" - headers = {"Authorization": f"Bearer {LITELLM_VIRTUAL_KEY}"} - - async with httpx.AsyncClient(headers=headers) as httpx_client: - # Resolve agent card and create client - resolver = A2ACardResolver(httpx_client=httpx_client, base_url=base_url) - agent_card = await resolver.get_agent_card() - client = A2AClient(httpx_client=httpx_client, agent_card=agent_card) - - # Send a streaming message - request = SendStreamingMessageRequest( - id=str(uuid4()), - params=MessageSendParams( - message={ - "role": "user", - "parts": [{"kind": "text", "text": "Hello, what can you do?"}], - "messageId": uuid4().hex, - } - ), - ) - - # Stream the response - async for chunk in client.send_message_streaming(request): - print(chunk.model_dump(mode="json", exclude_none=True)) - -if __name__ == "__main__": - asyncio.run(main()) -``` +See the [Invoking A2A Agents](./a2a_invoking_agents) guide to learn how to call your agents using: +- **A2A SDK** - Native A2A protocol with full support for tasks and artifacts +- **OpenAI SDK** - Familiar `/chat/completions` interface with `a2a/` model prefix ## Tracking Agent Logs diff --git a/docs/my-website/docs/a2a_invoking_agents.md b/docs/my-website/docs/a2a_invoking_agents.md new file mode 100644 index 0000000000..3bb248e456 --- /dev/null +++ b/docs/my-website/docs/a2a_invoking_agents.md @@ -0,0 +1,280 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Invoking A2A Agents + +Learn how to invoke A2A agents through LiteLLM using different methods. + +:::tip Deploy Your Own A2A Agent + +Want to test with your own agent? Deploy this template A2A agent powered by Google Gemini: + +[**shin-bot-litellm/a2a-gemini-agent**](https://github.com/shin-bot-litellm/a2a-gemini-agent) - Simple deployable A2A agent with streaming support + +::: + +## A2A SDK + +Use the [A2A Python SDK](https://pypi.org/project/a2a-sdk) to invoke agents through LiteLLM using the A2A protocol. + +### Non-Streaming + +This example shows how to: +1. **List available agents** - Query `/v1/agents` to see which agents your key can access +2. **Select an agent** - Pick an agent from the list +3. **Invoke via A2A** - Use the A2A protocol to send messages to the agent + +```python showLineNumbers title="invoke_a2a_agent.py" +from uuid import uuid4 +import httpx +import asyncio +from a2a.client import A2ACardResolver, A2AClient +from a2a.types import MessageSendParams, SendMessageRequest + +# === CONFIGURE THESE === +LITELLM_BASE_URL = "http://localhost:4000" # Your LiteLLM proxy URL +LITELLM_VIRTUAL_KEY = "sk-1234" # Your LiteLLM Virtual Key +# ======================= + +async def main(): + headers = {"Authorization": f"Bearer {LITELLM_VIRTUAL_KEY}"} + + async with httpx.AsyncClient(headers=headers) as client: + # Step 1: List available agents + response = await client.get(f"{LITELLM_BASE_URL}/v1/agents") + agents = response.json() + + print("Available agents:") + for agent in agents: + print(f" - {agent['agent_name']} (ID: {agent['agent_id']})") + + if not agents: + print("No agents available for this key") + return + + # Step 2: Select an agent and invoke it + selected_agent = agents[0] + agent_id = selected_agent["agent_id"] + agent_name = selected_agent["agent_name"] + print(f"\nInvoking: {agent_name}") + + # Step 3: Use A2A protocol to invoke the agent + base_url = f"{LITELLM_BASE_URL}/a2a/{agent_id}" + resolver = A2ACardResolver(httpx_client=client, base_url=base_url) + agent_card = await resolver.get_agent_card() + a2a_client = A2AClient(httpx_client=client, agent_card=agent_card) + + request = SendMessageRequest( + id=str(uuid4()), + params=MessageSendParams( + message={ + "role": "user", + "parts": [{"kind": "text", "text": "Hello, what can you do?"}], + "messageId": uuid4().hex, + } + ), + ) + response = await a2a_client.send_message(request) + print(f"Response: {response.model_dump(mode='json', exclude_none=True, indent=4)}") + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### Streaming + +For streaming responses, use `send_message_streaming`: + +```python showLineNumbers title="invoke_a2a_agent_streaming.py" +from uuid import uuid4 +import httpx +import asyncio +from a2a.client import A2ACardResolver, A2AClient +from a2a.types import MessageSendParams, SendStreamingMessageRequest + +# === CONFIGURE THESE === +LITELLM_BASE_URL = "http://localhost:4000" # Your LiteLLM proxy URL +LITELLM_VIRTUAL_KEY = "sk-1234" # Your LiteLLM Virtual Key +LITELLM_AGENT_NAME = "ij-local" # Agent name registered in LiteLLM +# ======================= + +async def main(): + base_url = f"{LITELLM_BASE_URL}/a2a/{LITELLM_AGENT_NAME}" + headers = {"Authorization": f"Bearer {LITELLM_VIRTUAL_KEY}"} + + async with httpx.AsyncClient(headers=headers) as httpx_client: + # Resolve agent card and create client + resolver = A2ACardResolver(httpx_client=httpx_client, base_url=base_url) + agent_card = await resolver.get_agent_card() + client = A2AClient(httpx_client=httpx_client, agent_card=agent_card) + + # Send a streaming message + request = SendStreamingMessageRequest( + id=str(uuid4()), + params=MessageSendParams( + message={ + "role": "user", + "parts": [{"kind": "text", "text": "Tell me a long story"}], + "messageId": uuid4().hex, + } + ), + ) + + # Stream the response + async for chunk in client.send_message_streaming(request): + print(chunk.model_dump(mode="json", exclude_none=True)) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## /chat/completions API (OpenAI SDK) + +You can also invoke A2A agents using the familiar OpenAI SDK by using the `a2a/` model prefix. + +### Non-Streaming + + + + +```python showLineNumbers title="openai_non_streaming.py" +import openai + +client = openai.OpenAI( + api_key="sk-1234", # Your LiteLLM Virtual Key + base_url="http://localhost:4000" # Your LiteLLM proxy URL +) + +response = client.chat.completions.create( + model="a2a/my-agent", # Use a2a/ prefix with your agent name + messages=[ + {"role": "user", "content": "Hello, what can you do?"} + ] +) + +print(response.choices[0].message.content) +``` + + + + +```typescript showLineNumbers title="openai_non_streaming.ts" +import OpenAI from 'openai'; + +const client = new OpenAI({ + apiKey: 'sk-1234', // Your LiteLLM Virtual Key + baseURL: 'http://localhost:4000' // Your LiteLLM proxy URL +}); + +const response = await client.chat.completions.create({ + model: 'a2a/my-agent', // Use a2a/ prefix with your agent name + messages: [ + { role: 'user', content: 'Hello, what can you do?' } + ] +}); + +console.log(response.choices[0].message.content); +``` + + + + +```bash showLineNumbers title="curl_non_streaming.sh" +curl -X POST http://localhost:4000/v1/chat/completions \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "a2a/my-agent", + "messages": [ + {"role": "user", "content": "Hello, what can you do?"} + ] + }' +``` + + + + +### Streaming + + + + +```python showLineNumbers title="openai_streaming.py" +import openai + +client = openai.OpenAI( + api_key="sk-1234", # Your LiteLLM Virtual Key + base_url="http://localhost:4000" # Your LiteLLM proxy URL +) + +stream = client.chat.completions.create( + model="a2a/my-agent", # Use a2a/ prefix with your agent name + messages=[ + {"role": "user", "content": "Tell me a long story"} + ], + stream=True +) + +for chunk in stream: + if chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="", flush=True) +``` + + + + +```typescript showLineNumbers title="openai_streaming.ts" +import OpenAI from 'openai'; + +const client = new OpenAI({ + apiKey: 'sk-1234', // Your LiteLLM Virtual Key + baseURL: 'http://localhost:4000' // Your LiteLLM proxy URL +}); + +const stream = await client.chat.completions.create({ + model: 'a2a/my-agent', // Use a2a/ prefix with your agent name + messages: [ + { role: 'user', content: 'Tell me a long story' } + ], + stream: true +}); + +for await (const chunk of stream) { + const content = chunk.choices[0]?.delta?.content; + if (content) { + process.stdout.write(content); + } +} +``` + + + + +```bash showLineNumbers title="curl_streaming.sh" +curl -X POST http://localhost:4000/v1/chat/completions \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "a2a/my-agent", + "messages": [ + {"role": "user", "content": "Tell me a long story"} + ], + "stream": true + }' +``` + + + + +## Key Differences + +| Method | Use Case | Advantages | +|--------|----------|------------| +| **A2A SDK** | Native A2A protocol integration | • Full A2A protocol support
• Access to task states and artifacts
• Context management | +| **OpenAI SDK** | Familiar OpenAI-style interface | • Drop-in replacement for OpenAI calls
• Easier migration from LLM to agent workflows
• Works with existing OpenAI tooling | + +:::tip Model Prefix + +When using the OpenAI SDK, always prefix your agent name with `a2a/` (e.g., `a2a/my-agent`) to route requests to the A2A agent instead of an LLM provider. + +::: diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index d932b6af25..98c8ee6eaa 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -469,6 +469,7 @@ const sidebars = { label: "/a2a - A2A Agent Gateway", items: [ "a2a", + "a2a_invoking_agents", "a2a_cost_tracking", "a2a_agent_permissions" ], diff --git a/litellm/llms/a2a/chat/streaming_iterator.py b/litellm/llms/a2a/chat/streaming_iterator.py index 84b6fffaa3..4b689414dd 100644 --- a/litellm/llms/a2a/chat/streaming_iterator.py +++ b/litellm/llms/a2a/chat/streaming_iterator.py @@ -94,7 +94,7 @@ class A2AModelResponseIterator(BaseModelResponseIterator): if state == "completed": return "stop" elif state == "failed": - return "error" + return "stop" # Map failed state to 'stop' (valid finish_reason) # Check for [DONE] marker if chunk.get("done") is True: diff --git a/litellm/llms/a2a/chat/transformation.py b/litellm/llms/a2a/chat/transformation.py index 243fba6371..163cd5ab22 100644 --- a/litellm/llms/a2a/chat/transformation.py +++ b/litellm/llms/a2a/chat/transformation.py @@ -2,10 +2,9 @@ A2A Protocol Transformation for LiteLLM """ import uuid -from typing import Any, Dict, Iterator, List, Optional, Union, cast +from typing import Any, Dict, Iterator, List, Optional, Union import httpx -from pydantic import BaseModel from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException @@ -27,6 +26,68 @@ class A2AConfig(BaseConfig): Handles transformation between OpenAI and A2A JSON-RPC 2.0 formats. """ + @staticmethod + def resolve_agent_config_from_registry( + model: str, + api_base: Optional[str], + api_key: Optional[str], + headers: Optional[Dict[str, Any]], + optional_params: Dict[str, Any], + ) -> tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: + """ + Resolve agent configuration from registry if model format is "a2a/". + + Extracts agent name from model string and looks up configuration in the + agent registry (if available in proxy context). + + Args: + model: Model string (e.g., "a2a/my-agent") + api_base: Explicit api_base (takes precedence over registry) + api_key: Explicit api_key (takes precedence over registry) + headers: Explicit headers (takes precedence over registry) + optional_params: Dict to merge additional litellm_params into + + Returns: + Tuple of (api_base, api_key, headers) with registry values filled in + """ + # Extract agent name from model (e.g., "a2a/my-agent" -> "my-agent") + agent_name = model.split("/", 1)[1] if "/" in model else None + + # Only lookup if agent name exists and some config is missing + if not agent_name or (api_base is not None and api_key is not None and headers is not None): + return api_base, api_key, headers + + # Try registry lookup (only available in proxy context) + try: + from litellm.proxy.agent_endpoints.agent_registry import ( + global_agent_registry, + ) + + agent = global_agent_registry.get_agent_by_name(agent_name) + if agent: + # Get api_base from agent card URL + if api_base is None and agent.agent_card_params: + api_base = agent.agent_card_params.get("url") + + # Get api_key, headers, and other params from litellm_params + if agent.litellm_params: + if api_key is None: + api_key = agent.litellm_params.get("api_key") + + if headers is None: + agent_headers = agent.litellm_params.get("headers") + if agent_headers: + headers = agent_headers + + # Merge other litellm_params (timeout, max_retries, etc.) + for key, value in agent.litellm_params.items(): + if key not in ["api_key", "api_base", "headers", "model"] and key not in optional_params: + optional_params[key] = value + except ImportError: + pass # Registry not available (not running in proxy context) + + return api_base, api_key, headers + def get_supported_openai_params(self, model: str) -> List[str]: """Return list of supported OpenAI parameters""" return [ @@ -46,9 +107,14 @@ class A2AConfig(BaseConfig): """ Map OpenAI parameters to A2A parameters. - For A2A protocol, we don't need to map most parameters since - they're handled in the transform_request method. + For A2A protocol, we need to map the stream parameter so + transform_request can determine which JSON-RPC method to use. """ + # Map stream parameter + for param, value in non_default_params.items(): + if param == "stream" and value is True: + optional_params["stream"] = value + return optional_params def validate_environment( @@ -160,8 +226,9 @@ class A2AConfig(BaseConfig): # Build JSON-RPC 2.0 request # For A2A protocol, the method is "message/send" for non-streaming - # and "message/stream" for streaming (handled by optional_params["stream"]) - method = "message/stream" if optional_params.get("stream") else "message/send" + # and "message/stream" for streaming + stream = optional_params.get("stream", False) + method = "message/stream" if stream else "message/send" request_data = { "jsonrpc": "2.0", diff --git a/litellm/llms/a2a/common_utils.py b/litellm/llms/a2a/common_utils.py index 4c7da78b42..116e120540 100644 --- a/litellm/llms/a2a/common_utils.py +++ b/litellm/llms/a2a/common_utils.py @@ -113,6 +113,8 @@ def extract_text_from_a2a_response( # 1. Direct message: {"result": {"kind": "message", "parts": [...]}} # 2. Nested message: {"result": {"message": {"parts": [...]}}} # 3. Task with artifacts: {"result": {"kind": "task", "artifacts": [{"parts": [...]}]}} + # 4. Task with status message: {"result": {"kind": "task", "status": {"message": {"parts": [...]}}}} + # 5. Streaming artifact-update: {"result": {"kind": "artifact-update", "artifact": {"parts": [...]}}} # Check if result itself has parts (direct message) if "parts" in result: @@ -123,7 +125,23 @@ def extract_text_from_a2a_response( if message: return extract_text_from_a2a_message(message, depth=0, max_depth=max_depth) - # Handle task result with artifacts + # Check for streaming artifact-update (singular artifact) + artifact = result.get("artifact") + if artifact and isinstance(artifact, dict): + return extract_text_from_a2a_message( + artifact, depth=0, max_depth=max_depth + ) + + # Check for task status message (common in Gemini A2A agents) + status = result.get("status", {}) + if isinstance(status, dict): + status_message = status.get("message") + if status_message: + return extract_text_from_a2a_message( + status_message, depth=0, max_depth=max_depth + ) + + # Handle task result with artifacts (plural, array) artifacts = result.get("artifacts", []) if artifacts and len(artifacts) > 0: first_artifact = artifacts[0] diff --git a/litellm/main.py b/litellm/main.py index 60c889ab1d..6d6bef81c2 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -2201,14 +2201,24 @@ def completion( # type: ignore # noqa: PLR0915 ) elif custom_llm_provider == "a2a": # A2A (Agent-to-Agent) Protocol - api_base = ( - api_base - or litellm.api_base - or get_secret_str("A2A_API_BASE") + # Resolve agent configuration from registry if model format is "a2a/" + api_base, api_key, headers = litellm.A2AConfig.resolve_agent_config_from_registry( + model=model, + api_base=api_base, + api_key=api_key, + headers=headers, + optional_params=optional_params, ) - + + # Fall back to environment variables and defaults + api_base = api_base or litellm.api_base or get_secret_str("A2A_API_BASE") + if api_base is None: - raise Exception("api_base is required for A2A provider") + raise Exception( + "api_base is required for A2A provider. " + "Either provide api_base parameter, set A2A_API_BASE environment variable, " + "or register the agent in the proxy with model='a2a/'." + ) headers = headers or litellm.headers diff --git a/litellm/proxy/agent_endpoints/a2a_routing.py b/litellm/proxy/agent_endpoints/a2a_routing.py new file mode 100644 index 0000000000..8e4c705df2 --- /dev/null +++ b/litellm/proxy/agent_endpoints/a2a_routing.py @@ -0,0 +1,53 @@ +""" +A2A Agent Routing + +Handles routing for A2A agents (models with "a2a/" prefix). +Looks up agents in the registry and injects their API base URL. +""" + +from typing import Any, Optional + +import litellm +from litellm._logging import verbose_proxy_logger + + +async def route_a2a_agent_request(data: dict, route_type: str) -> Optional[Any]: + """ + Route A2A agent requests directly to litellm with injected API base. + + Returns None if not an A2A request (allows normal routing to continue). + """ + # Import here to avoid circular imports + from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry + from litellm.proxy.route_llm_request import ( + ROUTE_ENDPOINT_MAPPING, + ProxyModelNotFoundError, + ) + + model_name = data.get("model", "") + + # Check if this is an A2A agent request + if not isinstance(model_name, str) or not model_name.startswith("a2a/"): + return None + + # Extract agent name (e.g., "a2a/my-agent" -> "my-agent") + agent_name = model_name[4:] + + # Look up agent in registry + agent = global_agent_registry.get_agent_by_name(agent_name) + if agent is None: + verbose_proxy_logger.error(f"[A2A] Agent '{agent_name}' not found in registry") + route_name = ROUTE_ENDPOINT_MAPPING.get(route_type, route_type) + raise ProxyModelNotFoundError(route=route_name, model_name=model_name) + + # Get API base URL from agent config + if not agent.agent_card_params or "url" not in agent.agent_card_params: + verbose_proxy_logger.error(f"[A2A] Agent '{agent_name}' has no URL configured") + route_name = ROUTE_ENDPOINT_MAPPING.get(route_type, route_type) + raise ProxyModelNotFoundError(route=route_name, model_name=model_name) + + # Inject API base and route to litellm + data["api_base"] = agent.agent_card_params["url"] + verbose_proxy_logger.debug(f"[A2A] Routing {model_name} to {data['api_base']}") + + return getattr(litellm, f"{route_type}")(**data) diff --git a/litellm/proxy/agent_endpoints/model_list_helpers.py b/litellm/proxy/agent_endpoints/model_list_helpers.py new file mode 100644 index 0000000000..c640300bb8 --- /dev/null +++ b/litellm/proxy/agent_endpoints/model_list_helpers.py @@ -0,0 +1,96 @@ +""" +Helper functions for appending A2A agents to model lists. + +Used by proxy model endpoints to make agents appear in UI alongside models. +""" +from typing import List + +from litellm._logging import verbose_proxy_logger +from litellm.proxy._types import UserAPIKeyAuth +from litellm.types.proxy.management_endpoints.model_management_endpoints import ( + ModelGroupInfoProxy, +) + + +async def append_agents_to_model_group( + model_groups: List[ModelGroupInfoProxy], + user_api_key_dict: UserAPIKeyAuth, +) -> List[ModelGroupInfoProxy]: + """ + Append A2A agents to model groups list for UI display. + + Converts agents to model format with "a2a/" naming + so they appear in playground and work with LiteLLM routing. + """ + try: + from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry + from litellm.proxy.agent_endpoints.auth.agent_permission_handler import ( + AgentRequestHandler, + ) + + allowed_agent_ids = await AgentRequestHandler.get_allowed_agents( + user_api_key_auth=user_api_key_dict + ) + + for agent_id in allowed_agent_ids: + agent = global_agent_registry.get_agent_by_id(agent_id) + if agent is not None: + model_groups.append( + ModelGroupInfoProxy( + model_group=f"a2a/{agent.agent_name}", + mode="chat", + providers=["a2a"], + ) + ) + except Exception as e: + verbose_proxy_logger.debug( + f"Error appending agents to model_group/info: {e}" + ) + + return model_groups + + +async def append_agents_to_model_info( + models: List[dict], + user_api_key_dict: UserAPIKeyAuth, +) -> List[dict]: + """ + Append A2A agents to model info list for UI display. + + Converts agents to model format with "a2a/" naming + so they appear in models page and work with LiteLLM routing. + """ + try: + from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry + from litellm.proxy.agent_endpoints.auth.agent_permission_handler import ( + AgentRequestHandler, + ) + + allowed_agent_ids = await AgentRequestHandler.get_allowed_agents( + user_api_key_auth=user_api_key_dict + ) + + for agent_id in allowed_agent_ids: + agent = global_agent_registry.get_agent_by_id(agent_id) + if agent is not None: + models.append({ + "model_name": f"a2a/{agent.agent_name}", + "litellm_params": { + "model": f"a2a/{agent.agent_name}", + "custom_llm_provider": "a2a", + }, + "model_info": { + "id": agent.agent_id, + "mode": "chat", + "db_model": True, + "created_by": agent.created_by, + "created_at": agent.created_at, + "updated_at": agent.updated_at, + }, + }) + except Exception as e: + verbose_proxy_logger.debug( + f"Error appending agents to v2/model/info: {e}" + ) + + return models diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 8f433bfa48..b06071481e 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -239,6 +239,10 @@ from litellm.proxy._types import * from litellm.proxy.agent_endpoints.a2a_endpoints import router as a2a_router from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry from litellm.proxy.agent_endpoints.endpoints import router as agent_endpoints_router +from litellm.proxy.agent_endpoints.model_list_helpers import ( + append_agents_to_model_group, + append_agents_to_model_info, +) from litellm.proxy.analytics_endpoints.analytics_endpoints import ( router as analytics_router, ) @@ -8616,6 +8620,15 @@ async def model_info_v2( ) verbose_proxy_logger.debug("all_models: %s", all_models) + + # Append A2A agents to models list + all_models = await append_agents_to_model_info( + models=all_models, + user_api_key_dict=user_api_key_dict, + ) + + # Update total count to include agents + search_total_count = len(all_models) return _paginate_models_response( all_models=all_models, @@ -9456,6 +9469,12 @@ async def model_group_info( model_groups: List[ModelGroupInfoProxy] = _get_model_group_info( llm_router=llm_router, all_models_str=all_models_str, model_group=model_group ) + + # Append A2A agents to model groups + model_groups = await append_agents_to_model_group( + model_groups=model_groups, + user_api_key_dict=user_api_key_dict, + ) return {"data": model_groups} diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index c6a93164d4..39ef5fdd1d 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -12,6 +12,11 @@ else: LitellmRouter = Any +def _is_a2a_agent_model(model_name: Any) -> bool: + """Check if the model name is for an A2A agent (a2a/ prefix).""" + return isinstance(model_name, str) and model_name.startswith("a2a/") + + ROUTE_ENDPOINT_MAPPING = { "acompletion": "/chat/completions", "atext_completion": "/completions", @@ -322,6 +327,12 @@ async def route_request( except Exception: # If router fails (e.g., model not found in router), fall back to direct call return getattr(litellm, f"{route_type}")(**data) + elif _is_a2a_agent_model(data.get("model", "")): + from litellm.proxy.agent_endpoints.a2a_routing import ( + route_a2a_agent_request, + ) + + return await route_a2a_agent_request(data, route_type) elif user_model is not None: return getattr(litellm, f"{route_type}")(**data) diff --git a/tests/test_litellm/proxy/agent_endpoints/test_model_list_helpers.py b/tests/test_litellm/proxy/agent_endpoints/test_model_list_helpers.py new file mode 100644 index 0000000000..92cd3d9ad6 --- /dev/null +++ b/tests/test_litellm/proxy/agent_endpoints/test_model_list_helpers.py @@ -0,0 +1,110 @@ +""" +Test appending A2A agents to model lists. + +Maps to: litellm/proxy/agent_endpoints/model_list_helpers.py +""" +import os +import sys + +sys.path.insert(0, os.path.abspath("../../../..")) + +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from litellm.proxy.agent_endpoints.model_list_helpers import ( + append_agents_to_model_group, + append_agents_to_model_info, +) +from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth +from litellm.types.agents import AgentResponse +from litellm.types.proxy.management_endpoints.model_management_endpoints import ( + ModelGroupInfoProxy, +) + + +@pytest.mark.asyncio +async def test_append_agents_to_model_group(): + """Test agents are converted to model group format with a2a/ prefix""" + + # Mock agent data + mock_agent = AgentResponse( + agent_id="test-agent-id", + agent_name="my-agent", + agent_card_params={"url": "http://example.com"}, + litellm_params=None, + ) + + # Mock AgentRequestHandler at its source location + mock_get_allowed_agents = AsyncMock(return_value=["test-agent-id"]) + + # Mock global_agent_registry + mock_registry = Mock() + mock_registry.get_agent_by_id = Mock(return_value=mock_agent) + + with patch( + "litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.get_allowed_agents", + mock_get_allowed_agents, + ): + with patch( + "litellm.proxy.agent_endpoints.agent_registry.global_agent_registry", + mock_registry, + ): + model_groups = [] + user_api_key_dict = Mock(spec=UserAPIKeyAuth) + + result = await append_agents_to_model_group( + model_groups=model_groups, + user_api_key_dict=user_api_key_dict, + ) + + # Verify agent was converted with a2a/ prefix + assert len(result) == 1 + assert result[0].model_group == "a2a/my-agent" + assert result[0].mode == "chat" + assert result[0].providers == ["a2a"] + + +@pytest.mark.asyncio +async def test_append_agents_to_model_info(): + """Test agents are converted to model info format with a2a/ prefix""" + + # Mock agent data + mock_agent = AgentResponse( + agent_id="agent-123", + agent_name="test-agent", + agent_card_params={"url": "http://example.com"}, + litellm_params=None, + created_by="user-123", + ) + + # Mock AgentRequestHandler at its source location + mock_get_allowed_agents = AsyncMock(return_value=["agent-123"]) + + # Mock global_agent_registry + mock_registry = Mock() + mock_registry.get_agent_by_id = Mock(return_value=mock_agent) + + with patch( + "litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.get_allowed_agents", + mock_get_allowed_agents, + ): + with patch( + "litellm.proxy.agent_endpoints.agent_registry.global_agent_registry", + mock_registry, + ): + models = [] + user_api_key_dict = Mock(spec=UserAPIKeyAuth) + + result = await append_agents_to_model_info( + models=models, + user_api_key_dict=user_api_key_dict, + ) + + # Verify agent was converted with a2a/ prefix + assert len(result) == 1 + assert result[0]["model_name"] == "a2a/test-agent" + assert result[0]["litellm_params"]["model"] == "a2a/test-agent" + assert result[0]["litellm_params"]["custom_llm_provider"] == "a2a" + assert result[0]["model_info"]["id"] == "agent-123" + assert result[0]["model_info"]["mode"] == "chat" diff --git a/tests/test_litellm/proxy/test_route_a2a_models.py b/tests/test_litellm/proxy/test_route_a2a_models.py new file mode 100644 index 0000000000..e8d89c0c7e --- /dev/null +++ b/tests/test_litellm/proxy/test_route_a2a_models.py @@ -0,0 +1,105 @@ +""" +Test A2A model routing in proxy. + +Maps to: litellm/proxy/agent_endpoints/a2a_routing.py +""" +import os +import sys + +sys.path.insert(0, os.path.abspath("../../..")) + +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from litellm.proxy.agent_endpoints.a2a_routing import route_a2a_agent_request +from litellm.proxy.route_llm_request import route_request + + +@pytest.mark.asyncio +async def test_route_a2a_model_bypasses_router(): + """Test that a2a/ prefixed models bypass router and go directly to litellm with api_base""" + + # Mock data for chat completion with a2a model + data = { + "model": "a2a/test-agent", + "messages": [{"role": "user", "content": "Hello"}], + } + + # Mock router that doesn't have the a2a model + mock_router = Mock() + mock_router.model_names = ["gpt-4", "gpt-3.5-turbo"] + mock_router.deployment_names = [] + mock_router.has_model_id = Mock(return_value=False) + mock_router.model_group_alias = None + mock_router.router_general_settings = Mock(pass_through_all_models=False) + mock_router.default_deployment = None + mock_router.pattern_router = Mock(patterns=[]) + mock_router.map_team_model = Mock(return_value=None) + + # Mock agent in registry + from litellm.types.agents import AgentResponse + + mock_agent = AgentResponse( + agent_id="test-agent-id", + agent_name="test-agent", + agent_card_params={"url": "http://agent.example.com"}, + litellm_params=None, + ) + + mock_registry = Mock() + mock_registry.get_agent_by_name = Mock(return_value=mock_agent) + + # Mock litellm.acompletion to verify it's called + mock_acompletion = AsyncMock(return_value={"id": "test-response"}) + + with patch("litellm.acompletion", mock_acompletion): + with patch( + "litellm.proxy.agent_endpoints.a2a_routing.global_agent_registry", + mock_registry, + ): + result = await route_request( + data=data, + llm_router=mock_router, + user_model=None, + route_type="acompletion", + ) + + # Verify litellm.acompletion was called with api_base injected + mock_acompletion.assert_called_once() + call_kwargs = mock_acompletion.call_args.kwargs + assert call_kwargs["model"] == "a2a/test-agent" + assert call_kwargs["api_base"] == "http://agent.example.com" + + +@pytest.mark.asyncio +async def test_route_non_a2a_model_raises_error_if_not_in_router(): + """Test that non-a2a models that aren't in router raise an error""" + + # Mock data for chat completion with model not in router + data = { + "model": "unknown-model", + "messages": [{"role": "user", "content": "Hello"}], + } + + # Mock router without the model + mock_router = Mock() + mock_router.model_names = ["gpt-4", "gpt-3.5-turbo"] + mock_router.deployment_names = [] + mock_router.has_model_id = Mock(return_value=False) + mock_router.model_group_alias = None + mock_router.router_general_settings = Mock(pass_through_all_models=False) + mock_router.default_deployment = None + mock_router.pattern_router = Mock(patterns=[]) + mock_router.map_team_model = Mock(return_value=None) + + # Should raise ProxyModelNotFoundError + from litellm.proxy.route_llm_request import ProxyModelNotFoundError + + with pytest.raises(ProxyModelNotFoundError): + await route_request( + data=data, + llm_router=mock_router, + user_model=None, + route_type="acompletion", + ) diff --git a/tests/test_litellm/test_a2a_registry_lookup.py b/tests/test_litellm/test_a2a_registry_lookup.py new file mode 100644 index 0000000000..9938f10a43 --- /dev/null +++ b/tests/test_litellm/test_a2a_registry_lookup.py @@ -0,0 +1,73 @@ +""" +Test A2A provider registry lookup functionality. + +Maps to: litellm/llms/a2a/chat/transformation.py +""" +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +import pytest + +import litellm +from litellm.llms.a2a.chat.transformation import A2AConfig + + +def test_resolve_agent_config_from_registry_static_method(): + """Test the static helper method for registry resolution""" + + # Test 1: No agent name in model + api_base, api_key, headers = A2AConfig.resolve_agent_config_from_registry( + model="a2a", + api_base="http://test.com", + api_key=None, + headers=None, + optional_params={} + ) + assert api_base == "http://test.com" + + # Test 2: All params provided - should not lookup registry + api_base, api_key, headers = A2AConfig.resolve_agent_config_from_registry( + model="a2a/test-agent", + api_base="http://explicit.com", + api_key="explicit-key", + headers={"X-Test": "value"}, + optional_params={} + ) + assert api_base == "http://explicit.com" + assert api_key == "explicit-key" + + +def test_a2a_registry_integration(): + """Test registry lookup in proxy context""" + + try: + from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry + from litellm.types.agents import AgentResponse + + # Create test agent + test_agent = AgentResponse( + agent_id="test-id", + agent_name="test-agent", + agent_card_params={"url": "http://registry-url.example.com:9999"}, + litellm_params={"api_key": "registry-key"}, + ) + + # Register and test + original_agents = global_agent_registry.agent_list.copy() + global_agent_registry.register_agent(test_agent) + + try: + litellm.completion( + model="a2a/test-agent", + messages=[{"role": "user", "content": "Hello"}] + ) + except Exception as e: + # Should use registry URL (connection error expected) + assert "registry-url.example.com" in str(e) or "APIConnectionError" in str(type(e).__name__) + finally: + global_agent_registry.agent_list = original_agents + + except ImportError: + pytest.skip("Registry not available (not in proxy context)") From a2653bcd5e6f368b652e17fbaafe5e6df5b7d356 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Feb 2026 15:26:51 -0800 Subject: [PATCH 13/18] Adding Allowed Routes to Key Info and Edit Pages --- .../templates/key_edit_view.test.tsx | 353 ++++++++++++++++-- .../components/templates/key_edit_view.tsx | 67 +++- .../templates/key_info_view.test.tsx | 174 ++++++++- .../components/templates/key_info_view.tsx | 65 ++-- 4 files changed, 575 insertions(+), 84 deletions(-) diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index 03e1085937..0123e22eb1 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -1,24 +1,61 @@ -import { fireEvent, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../../tests/test-utils"; import { KeyResponse } from "../key_team_helpers/key_list"; import { KeyEditView } from "./key_edit_view"; -// Mock window.matchMedia -Object.defineProperty(window, "matchMedia", { - writable: true, - value: vi.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })), +vi.mock("../networking", async () => { + const actual = await vi.importActual("../networking"); + return { + ...actual, + getPromptsList: vi.fn().mockResolvedValue({ + prompts: [{ prompt_id: "prompt-1" }, { prompt_id: "prompt-2" }], + }), + modelAvailableCall: vi.fn().mockResolvedValue({ + data: [{ id: "gpt-4" }, { id: "gpt-3.5-turbo" }], + }), + tagListCall: vi.fn().mockResolvedValue({ + tag1: { name: "tag1", description: "Test tag 1" }, + tag2: { name: "tag2", description: "Test tag 2" }, + }), + getGuardrailsList: vi.fn().mockResolvedValue({ + guardrails: [{ guardrail_name: "guardrail-1" }], + }), + getPoliciesList: vi.fn().mockResolvedValue({ + policies: [{ policy_name: "policy-1" }], + }), + getPassThroughEndpointsCall: vi.fn().mockResolvedValue({ + endpoints: [], + }), + vectorStoreListCall: vi.fn().mockResolvedValue({ + data: [], + }), + mcpToolsCall: vi.fn().mockResolvedValue({ + data: [], + }), + agentListCall: vi.fn().mockResolvedValue({ + data: [], + }), + fetchMCPServers: vi.fn().mockResolvedValue([]), + fetchMCPAccessGroups: vi.fn().mockResolvedValue([]), + listMCPTools: vi.fn().mockResolvedValue({ + tools: [], + error: null, + message: null, + stack_trace: null, + }), + getAgentsList: vi.fn().mockResolvedValue({ + agents: [], + }), + getAgentAccessGroups: vi.fn().mockResolvedValue([]), + }; }); +vi.mock("../organisms/create_key_button", () => ({ + fetchTeamModels: vi.fn().mockResolvedValue(["team-model-1", "team-model-2"]), +})); + describe("KeyEditView", () => { const MOCK_KEY_DATA: KeyResponse = { token: "test-token-123", @@ -93,8 +130,8 @@ describe("KeyEditView", () => { const { getByText } = renderWithProviders( {}} - onSubmit={async () => {}} + onCancel={() => { }} + onSubmit={async () => { }} accessToken={""} userID={""} userRole={""} @@ -111,8 +148,8 @@ describe("KeyEditView", () => { const { getByText } = renderWithProviders( {}} - onSubmit={async () => {}} + onCancel={() => { }} + onSubmit={async () => { }} accessToken={""} userID={""} userRole={""} @@ -129,8 +166,8 @@ describe("KeyEditView", () => { const { getByLabelText } = renderWithProviders( {}} - onSubmit={async () => {}} + onCancel={() => { }} + onSubmit={async () => { }} accessToken={""} userID={""} userRole={""} @@ -144,13 +181,17 @@ describe("KeyEditView", () => { }); }); + beforeEach(() => { + vi.clearAllMocks(); + }); + it("should call onCancel when cancel button is clicked", async () => { const onCancelMock = vi.fn(); - const { getByText } = renderWithProviders( + renderWithProviders( {}} + onSubmit={async () => { }} accessToken={""} userID={""} userRole={""} @@ -159,12 +200,272 @@ describe("KeyEditView", () => { ); await waitFor(() => { - expect(getByText("Cancel")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); }); - const cancelButton = getByText("Cancel"); - fireEvent.click(cancelButton); + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + await userEvent.click(cancelButton); expect(onCancelMock).toHaveBeenCalledTimes(1); }); + + it("should display key alias input field", async () => { + renderWithProviders( + { }} + onSubmit={async () => { }} + accessToken={""} + userID={""} + userRole={""} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByLabelText("Key Alias")).toBeInTheDocument(); + }); + }); + + it("should display models select field", async () => { + renderWithProviders( + { }} + onSubmit={async () => { }} + accessToken={""} + userID={""} + userRole={""} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("Models")).toBeInTheDocument(); + }); + }); + + it("should display max budget input field", async () => { + renderWithProviders( + { }} + onSubmit={async () => { }} + accessToken={""} + userID={""} + userRole={""} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByLabelText("Max Budget (USD)")).toBeInTheDocument(); + }); + }); + + it("should display allowed routes input field", async () => { + renderWithProviders( + { }} + onSubmit={async () => { }} + accessToken={""} + userID={""} + userRole={""} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByLabelText(/allowed routes/i)).toBeInTheDocument(); + }); + }); + + it("should call onSubmit with form values when form is submitted", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + renderWithProviders( + { }} + onSubmit={onSubmitMock} + accessToken={"test-token"} + userID={"test-user"} + userRole={"admin"} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + }); + }); + + it("should disable models field when management routes are selected", async () => { + const keyDataWithManagementRoutes = { + ...MOCK_KEY_DATA, + allowed_routes: ["management_routes"], + }; + + renderWithProviders( + { }} + onSubmit={async () => { }} + accessToken={""} + userID={""} + userRole={""} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("Models field is disabled for this key type")).toBeInTheDocument(); + }); + }); + + it("should disable models field when info routes are selected", async () => { + const keyDataWithInfoRoutes = { + ...MOCK_KEY_DATA, + allowed_routes: ["info_routes"], + }; + + renderWithProviders( + { }} + onSubmit={async () => { }} + accessToken={""} + userID={""} + userRole={""} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("Models field is disabled for this key type")).toBeInTheDocument(); + }); + }); + + it("should disable guardrails selector when user is not premium", async () => { + renderWithProviders( + { }} + onSubmit={async () => { }} + accessToken={"test-token"} + userID={""} + userRole={""} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("Guardrails")).toBeInTheDocument(); + }); + }); + + it("should parse comma-separated allowed routes on submit", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + renderWithProviders( + { }} + onSubmit={onSubmitMock} + accessToken={"test-token"} + userID={"test-user"} + userRole={"admin"} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByLabelText(/allowed routes/i)).toBeInTheDocument(); + }); + + const allowedRoutesInput = screen.getByLabelText(/allowed routes/i); + await userEvent.clear(allowedRoutesInput); + await userEvent.type(allowedRoutesInput, "route1, route2, route3"); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect(Array.isArray(callArgs.allowed_routes)).toBe(true); + expect(callArgs.allowed_routes).toEqual(["route1", "route2", "route3"]); + }); + }); + + it("should handle empty allowed routes string on submit", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + renderWithProviders( + { }} + onSubmit={onSubmitMock} + accessToken={"test-token"} + userID={"test-user"} + userRole={"admin"} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByLabelText(/allowed routes/i)).toBeInTheDocument(); + }); + + const allowedRoutesInput = screen.getByLabelText(/allowed routes/i); + await userEvent.clear(allowedRoutesInput); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + const callArgs = onSubmitMock.mock.calls[0][0]; + expect(callArgs.allowed_routes).toEqual([]); + }); + }); + + + it("should disable cancel button during submission", async () => { + const onSubmitMock = vi.fn( + () => + new Promise((resolve) => { + setTimeout(resolve, 100); + }), + ); + + renderWithProviders( + { }} + onSubmit={onSubmitMock} + accessToken={"test-token"} + userID={"test-user"} + userRole={"admin"} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + }); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + expect(cancelButton).toBeDisabled(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index 2d3eaf2bb7..64676e3b94 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -35,7 +35,6 @@ interface KeyEditViewProps { // Add this helper function const getAvailableModelsForKey = (keyData: KeyResponse, teams: any[] | null): string[] => { // If no teams data is available, return empty array - console.log("getAvailableModelsForKey:", teams); if (!teams || !keyData.team_id) { return []; } @@ -172,7 +171,9 @@ export function KeyEditView({ : [], auto_rotate: keyData.auto_rotate || false, ...(keyData.rotation_interval && { rotation_interval: keyData.rotation_interval }), - allowed_routes: keyData.allowed_routes, + allowed_routes: Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0 + ? keyData.allowed_routes.join(", ") + : "", }; useEffect(() => { @@ -197,7 +198,9 @@ export function KeyEditView({ : [], auto_rotate: keyData.auto_rotate || false, ...(keyData.rotation_interval && { rotation_interval: keyData.rotation_interval }), - allowed_routes: keyData.allowed_routes, + allowed_routes: Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0 + ? keyData.allowed_routes.join(", ") + : "", }); }, [keyData, form]); @@ -226,11 +229,24 @@ export function KeyEditView({ fetchTags(); }, [accessToken]); - console.log("premiumUser:", premiumUser); - const handleSubmit = async (values: any) => { try { setIsKeySaving(true); + + // Parse allowed_routes from comma-separated string to array + if (typeof values.allowed_routes === "string") { + const trimmedInput = values.allowed_routes.trim(); + if (trimmedInput === "") { + values.allowed_routes = []; + } else { + values.allowed_routes = trimmedInput + .split(",") + .map((route: string) => route.trim()) + .filter((route: string) => route.length > 0); + } + } + // If it's already an array (shouldn't happen, but handle it), keep as is + await onSubmit(values); } finally { setIsKeySaving(false); @@ -251,7 +267,11 @@ export function KeyEditView({ } > {({ getFieldValue, setFieldValue }) => { - const allowedRoutes = getFieldValue("allowed_routes") || []; + const allowedRoutesValue = getFieldValue("allowed_routes") || ""; + // Convert string to array for checking + const allowedRoutes = typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== "" + ? allowedRoutesValue.split(",").map((r: string) => r.trim()).filter((r: string) => r.length > 0) + : []; const isDisabled = allowedRoutes.includes("management_routes") || allowedRoutes.includes("info_routes"); const models = getFieldValue("models") || []; @@ -290,7 +310,11 @@ export function KeyEditView({ shouldUpdate={(prevValues, currentValues) => prevValues.allowed_routes !== currentValues.allowed_routes} > {({ getFieldValue, setFieldValue }) => { - const allowedRoutes = getFieldValue("allowed_routes"); + const allowedRoutesValue = getFieldValue("allowed_routes") || ""; + // Convert string to array for getKeyTypeFromRoutes + const allowedRoutes = typeof allowedRoutesValue === "string" && allowedRoutesValue.trim() !== "" + ? allowedRoutesValue.split(",").map((r: string) => r.trim()).filter((r: string) => r.length > 0) + : []; const keyTypeValue = getKeyTypeFromRoutes(allowedRoutes); return ( @@ -302,13 +326,13 @@ export function KeyEditView({ onChange={(value) => { switch (value) { case "default": - setFieldValue("allowed_routes", []); + setFieldValue("allowed_routes", ""); break; case "llm_api": - setFieldValue("allowed_routes", ["llm_api_routes"]); + setFieldValue("allowed_routes", "llm_api_routes"); break; case "management": - setFieldValue("allowed_routes", ["management_routes"]); + setFieldValue("allowed_routes", "management_routes"); setFieldValue("models", []); break; } @@ -344,6 +368,22 @@ export function KeyEditView({
+ + Allowed Routes{" "} + + + + + } + name="allowed_routes" + > + + + @@ -473,7 +513,7 @@ export function KeyEditView({ !premiumUser ? "Premium feature - Upgrade to set allowed pass through routes by key" : Array.isArray(keyData.metadata?.allowed_passthrough_routes) && - keyData.metadata.allowed_passthrough_routes.length > 0 + keyData.metadata.allowed_passthrough_routes.length > 0 ? `Current: ${keyData.metadata.allowed_passthrough_routes.join(", ")}` : "Select or enter allowed pass through routes" } @@ -590,11 +630,6 @@ export function KeyEditView({ - {/* Hidden form field for allowed_routes */} - - {/* Hidden form field for disabled callbacks */}
@@ -691,10 +689,10 @@ export default function KeyInfoView({
{Array.isArray(currentKeyData.metadata?.tags) && currentKeyData.metadata.tags.length > 0 ? currentKeyData.metadata.tags.map((tag, index) => ( - - {tag} - - )) + + {tag} + + )) : "No tags specified"}
@@ -704,24 +702,39 @@ export default function KeyInfoView({ {Array.isArray(currentKeyData.metadata?.prompts) && currentKeyData.metadata.prompts.length > 0 ? currentKeyData.metadata.prompts.map((prompt, index) => ( - - {prompt} - - )) + + {prompt} + + )) : "No prompts specified"} +
+ Allowed Routes +
+ {Array.isArray(currentKeyData.allowed_routes) && currentKeyData.allowed_routes.length > 0 ? ( + currentKeyData.allowed_routes.map((route, index) => ( + + {route} + + )) + ) : ( + All routes allowed + )} +
+
+
Allowed Pass Through Routes {Array.isArray(currentKeyData.metadata?.allowed_passthrough_routes) && - currentKeyData.metadata.allowed_passthrough_routes.length > 0 + currentKeyData.metadata.allowed_passthrough_routes.length > 0 ? currentKeyData.metadata.allowed_passthrough_routes.map((route, index) => ( - - {route} - - )) + + {route} + + )) : "No pass through routes specified"}
From a50896f91e589616bac955f9c273a54408eff25c Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Wed, 4 Feb 2026 01:15:04 +0100 Subject: [PATCH 14/18] fix: revert httpx client caching that caused closed client errors (#20025) AsyncHTTPHandler.__del__ was closing httpx clients still in use by AsyncOpenAI/AsyncAzureOpenAI due to independent cache lifecycles. Restores standalone httpx client creation for OpenAI/Azure providers. --- litellm/llms/openai/common_utils.py | 74 +++++-------------- tests/test_litellm/llms/test_lifecycle_fix.py | 46 ++++++++++++ 2 files changed, 64 insertions(+), 56 deletions(-) create mode 100644 tests/test_litellm/llms/test_lifecycle_fix.py diff --git a/litellm/llms/openai/common_utils.py b/litellm/llms/openai/common_utils.py index 8bcecd3523..d8107a9ce9 100644 --- a/litellm/llms/openai/common_utils.py +++ b/litellm/llms/openai/common_utils.py @@ -22,7 +22,6 @@ from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, get_ssl_configuration, ) -from litellm.types.utils import LlmProviders class OpenAIError(BaseLLMException): @@ -205,67 +204,30 @@ class BaseOpenAILLM: if litellm.aclient_session is not None: return litellm.aclient_session - # Use the global cached client system to prevent memory leaks (issue #14540) - # This routes through get_async_httpx_client() which provides TTL-based caching - from litellm.llms.custom_httpx.http_handler import get_async_httpx_client + # Get unified SSL configuration + ssl_config = get_ssl_configuration() - try: - # Get SSL config and include in params for proper cache key - ssl_config = get_ssl_configuration() - params = {"ssl_verify": ssl_config} if ssl_config is not None else {} - params["disable_aiohttp_transport"] = litellm.disable_aiohttp_transport - - # Get a cached AsyncHTTPHandler which manages the httpx.AsyncClient - cached_handler = get_async_httpx_client( - llm_provider=LlmProviders.OPENAI, # Cache key includes provider - params=params, # Include SSL config in cache key + return httpx.AsyncClient( + verify=ssl_config, + transport=AsyncHTTPHandler._create_async_transport( + ssl_context=ssl_config + if isinstance(ssl_config, ssl.SSLContext) + else None, + ssl_verify=ssl_config if isinstance(ssl_config, bool) else None, shared_session=shared_session, - ) - # Return the underlying httpx client from the handler - return cached_handler.client - except (ImportError, AttributeError, KeyError) as e: - # Fallback to creating a client directly if caching system unavailable - # This preserves backwards compatibility - verbose_logger.debug( - f"Client caching unavailable ({type(e).__name__}), using direct client creation" - ) - ssl_config = get_ssl_configuration() - return httpx.AsyncClient( - verify=ssl_config, - transport=AsyncHTTPHandler._create_async_transport( - ssl_context=ssl_config - if isinstance(ssl_config, ssl.SSLContext) - else None, - ssl_verify=ssl_config if isinstance(ssl_config, bool) else None, - shared_session=shared_session, - ), - follow_redirects=True, - ) + ), + follow_redirects=True, + ) @staticmethod def _get_sync_http_client() -> Optional[httpx.Client]: if litellm.client_session is not None: return litellm.client_session - # Use the global cached client system to prevent memory leaks (issue #14540) - from litellm.llms.custom_httpx.http_handler import _get_httpx_client + # Get unified SSL configuration + ssl_config = get_ssl_configuration() - try: - # Get SSL config and include in params for proper cache key - ssl_config = get_ssl_configuration() - params = {"ssl_verify": ssl_config} if ssl_config is not None else None - - # Get a cached HTTPHandler which manages the httpx.Client - cached_handler = _get_httpx_client(params=params) - # Return the underlying httpx client from the handler - return cached_handler.client - except (ImportError, AttributeError, KeyError) as e: - # Fallback to creating a client directly if caching system unavailable - verbose_logger.debug( - f"Client caching unavailable ({type(e).__name__}), using direct client creation" - ) - ssl_config = get_ssl_configuration() - return httpx.Client( - verify=ssl_config, - follow_redirects=True, - ) + return httpx.Client( + verify=ssl_config, + follow_redirects=True, + ) diff --git a/tests/test_litellm/llms/test_lifecycle_fix.py b/tests/test_litellm/llms/test_lifecycle_fix.py new file mode 100644 index 0000000000..7b1876a333 --- /dev/null +++ b/tests/test_litellm/llms/test_lifecycle_fix.py @@ -0,0 +1,46 @@ +""" +Verifies that the httpx client used by AsyncOpenAI is NOT closed +when AsyncHTTPHandler instances are garbage collected. +""" +import asyncio +import gc +import httpx +from litellm.llms.openai.common_utils import BaseOpenAILLM +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler + + +async def test_httpx_client_not_closed_by_handler_gc(): + """ + Before the fix: _get_async_http_client() returned handler.client, + so when handler was GC'd its __del__ closed the client. + After the fix: returns a standalone httpx.AsyncClient, no handler involved. + """ + # Get the client the same way AsyncOpenAI would + client = BaseOpenAILLM._get_async_http_client() + assert isinstance(client, httpx.AsyncClient) + + # Simulate what the old code did: create an AsyncHTTPHandler and GC it + handler = AsyncHTTPHandler() + handler_client = handler.client + del handler + gc.collect() + + # The client from _get_async_http_client should still be open + # because it's NOT tied to any AsyncHTTPHandler + assert not client.is_closed, "Client was closed prematurely!" + + # Verify it can actually send (build a request without sending) + try: + req = client.build_request("GET", "https://example.com") + print("PASS: Client is still usable after handler GC") + except RuntimeError as e: + if "closed" in str(e): + print(f"FAIL: {e}") + raise + raise + + await client.aclose() + print("All checks passed!") + + +asyncio.run(test_httpx_client_not_closed_by_handler_gc()) From cf256c742f568de2dfd82ef40356bc61debc0722 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Feb 2026 16:32:21 -0800 Subject: [PATCH 15/18] allow max_budget reset --- .../internal_user_endpoints.py | 5 ++++- .../test_internal_user_endpoints.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 38a867d031..636ed87d79 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -813,9 +813,12 @@ def _update_internal_user_params( data_json: dict, data: Union[UpdateUserRequest, UpdateUserRequestNoUserIDorEmail] ) -> dict: non_default_values = {} + fields_set = data.fields_set() if hasattr(data, 'fields_set') else set() + for k, v in data_json.items(): if k == "max_budget": - non_default_values[k] = v + if "max_budget" in fields_set: + non_default_values[k] = v elif ( v is not None and v 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 dc436bac08..919af96f76 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 @@ -1133,6 +1133,24 @@ def test_update_internal_user_params_ignores_other_nones(): assert non_default_values["max_budget"] == 100.0 +def test_update_internal_user_params_keeps_original_max_budget_when_not_provided(): + """ + Test that _update_internal_user_params does not include max_budget + when it's not provided in the request (should keep original value). + """ + # Create test data without max_budget + data_json = {"user_id": "test_user", "user_alias": "test_alias"} + data = UpdateUserRequest(user_id="test_user", user_alias="test_alias") + + # Call the function + non_default_values = _update_internal_user_params(data_json=data_json, data=data) + + # Assertions: max_budget should NOT be in non_default_values + assert "max_budget" not in non_default_values + assert "user_id" in non_default_values + assert "user_alias" in non_default_values + + def test_generate_request_base_validator(): """ Test that GenerateRequestBase validator converts empty string to None for max_budget From 831f89b9645b1dda632bfe1902f59accf9193a72 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Feb 2026 18:19:33 -0800 Subject: [PATCH 16/18] unlimited budget ui changes --- .../src/components/user_edit_view.test.tsx | 504 ++++++++++++++++++ .../src/components/user_edit_view.tsx | 64 ++- 2 files changed, 558 insertions(+), 10 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/user_edit_view.test.tsx diff --git a/ui/litellm-dashboard/src/components/user_edit_view.test.tsx b/ui/litellm-dashboard/src/components/user_edit_view.test.tsx new file mode 100644 index 0000000000..4bac32321a --- /dev/null +++ b/ui/litellm-dashboard/src/components/user_edit_view.test.tsx @@ -0,0 +1,504 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "../../tests/test-utils"; +import { UserEditView } from "./user_edit_view"; + +vi.mock("./key_team_helpers/fetch_available_models_team_key", () => ({ + getModelDisplayName: vi.fn((model: string) => model), +})); + +vi.mock("../utils/roles", () => ({ + all_admin_roles: ["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer", "org_admin"], +})); + +vi.mock("antd", async (importOriginal) => { + const actual = await importOriginal(); + const React = await import("react"); + const SelectComponent = ({ + value, + onChange, + mode, + children, + placeholder, + disabled, + style, + allowClear, + ...props + }: any) => { + const isMultiple = mode === "multiple"; + const selectValue = isMultiple ? (Array.isArray(value) ? value : []) : value || ""; + return React.createElement( + "select", + { + multiple: isMultiple, + value: selectValue, + onChange: (e: React.ChangeEvent) => { + const selectedValues = Array.from(e.target.selectedOptions, (option) => option.value); + onChange(isMultiple ? selectedValues : selectedValues[0] || undefined); + }, + disabled, + placeholder, + style, + "aria-label": placeholder || "Select", + role: "combobox", + ...props, + }, + children, + ); + }; + SelectComponent.Option = ({ value: optionValue, children: optionChildren }: any) => + React.createElement("option", { value: optionValue }, optionChildren); + return { + ...actual, + Select: SelectComponent, + Tooltip: ({ children }: { children?: React.ReactNode }) => React.createElement(React.Fragment, null, children), + Checkbox: ({ checked, onChange, children, ...props }: any) => + React.createElement( + "label", + { style: { display: "flex", alignItems: "center", gap: "8px" } }, + React.createElement("input", { + type: "checkbox", + checked: checked, + onChange: (e: React.ChangeEvent) => onChange({ target: { checked: e.target.checked } }), + ...props, + }), + children, + ), + }; +}); + +vi.mock("@tremor/react", async (importOriginal) => { + const actual = await importOriginal(); + const React = await import("react"); + return { + ...actual, + SelectItem: ({ value, children, title }: any) => { + const childText = React.Children.toArray(children) + .map((child: any) => (typeof child === "string" ? child : child?.props?.children || "")) + .join(" "); + return React.createElement("option", { value, title }, childText || title || value); + }, + }; +}); + +describe("UserEditView", () => { + const MOCK_USER_DATA = { + user_id: "user-123", + user_info: { + user_email: "test@example.com", + user_alias: "Test User", + user_role: "proxy_admin", + models: ["gpt-4", "gpt-3.5-turbo"], + max_budget: 100.5, + budget_duration: "30d", + metadata: { + key1: "value1", + key2: "value2", + }, + }, + }; + + const MOCK_POSSIBLE_UI_ROLES = { + proxy_admin: { + ui_label: "Proxy Admin", + description: "Full access to proxy", + }, + proxy_admin_viewer: { + ui_label: "Proxy Admin Viewer", + description: "Read-only access", + }, + user: { + ui_label: "User", + description: "Standard user", + }, + }; + + const defaultProps = { + userData: MOCK_USER_DATA, + onCancel: vi.fn(), + onSubmit: vi.fn(), + teams: null, + accessToken: "test-token", + userID: "current-user-1", + userRole: "Admin", + userModels: ["gpt-4", "gpt-3.5-turbo", "claude-3"], + possibleUIRoles: MOCK_POSSIBLE_UI_ROLES, + isBulkEdit: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + }); + + it("should display user ID field when not in bulk edit mode", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByLabelText("User ID")).toBeInTheDocument(); + }); + + const userIdInput = screen.getByLabelText("User ID"); + expect(userIdInput).toBeDisabled(); + expect(userIdInput).toHaveValue("user-123"); + }); + + it("should not display user ID field when in bulk edit mode", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + + expect(screen.queryByLabelText("User ID")).not.toBeInTheDocument(); + }); + + it("should display email field when not in bulk edit mode", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByLabelText("Email")).toBeInTheDocument(); + }); + + const emailInput = screen.getByLabelText("Email"); + expect(emailInput).toHaveValue("test@example.com"); + }); + + it("should not display email field when in bulk edit mode", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + + expect(screen.queryByLabelText("Email")).not.toBeInTheDocument(); + }); + + it("should display user alias field with initial value", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByLabelText("User Alias")).toBeInTheDocument(); + }); + + const aliasInput = screen.getByLabelText("User Alias"); + expect(aliasInput).toHaveValue("Test User"); + }); + + it("should display personal models select with available models", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Personal Models")).toBeInTheDocument(); + }); + + const modelsSelect = screen.getByRole("combobox", { name: /select models/i }); + expect(modelsSelect).toBeInTheDocument(); + }); + + it("should disable models select when user role is not admin", async () => { + renderWithProviders(); + + await waitFor(() => { + const modelsSelect = screen.getByRole("combobox", { name: /select models/i }); + expect(modelsSelect).toBeDisabled(); + }); + }); + + it("should enable models select when user role is admin", async () => { + renderWithProviders(); + + await waitFor(() => { + const modelsSelect = screen.getByRole("combobox", { name: /select models/i }); + expect(modelsSelect).not.toBeDisabled(); + }); + }); + + it("should display max budget input field", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Max Budget (USD)")).toBeInTheDocument(); + }); + }); + + it("should display unlimited budget checkbox", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByLabelText("Unlimited Budget")).toBeInTheDocument(); + }); + }); + + it("should set unlimited budget checkbox when max_budget is null", async () => { + const userDataWithNullBudget = { + ...MOCK_USER_DATA, + user_info: { + ...MOCK_USER_DATA.user_info, + max_budget: null, + }, + }; + + renderWithProviders(); + + await waitFor(() => { + const checkbox = screen.getByLabelText("Unlimited Budget"); + expect(checkbox).toBeChecked(); + }); + }); + + it("should disable budget input when unlimited budget is checked", async () => { + const userDataWithNullBudget = { + ...MOCK_USER_DATA, + user_info: { + ...MOCK_USER_DATA.user_info, + max_budget: null, + }, + }; + + renderWithProviders(); + + await waitFor(() => { + const budgetInput = screen.getByRole("spinbutton", { name: /max budget/i }); + expect(budgetInput).toBeDisabled(); + }); + }); + + it("should enable budget input when unlimited budget is unchecked", async () => { + renderWithProviders(); + + await waitFor(() => { + const budgetInput = screen.getByRole("spinbutton", { name: /max budget/i }); + expect(budgetInput).not.toBeDisabled(); + }); + }); + + it("should clear budget value when unlimited budget is checked", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByLabelText("Unlimited Budget")).toBeInTheDocument(); + }); + + const checkbox = screen.getByLabelText("Unlimited Budget"); + await userEvent.click(checkbox); + + await waitFor(() => { + const budgetInput = screen.getByRole("spinbutton", { name: /max budget/i }); + expect(budgetInput).toHaveValue(null); + }); + }); + + it("should display metadata textarea with formatted JSON", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByLabelText("Metadata")).toBeInTheDocument(); + }); + + const metadataTextarea = screen.getByLabelText("Metadata"); + const expectedJson = JSON.stringify(MOCK_USER_DATA.user_info.metadata, null, 2); + expect(metadataTextarea).toHaveValue(expectedJson); + }); + + it("should display empty metadata textarea when metadata is undefined", async () => { + const userDataWithoutMetadata = { + ...MOCK_USER_DATA, + user_info: { + ...MOCK_USER_DATA.user_info, + metadata: undefined, + }, + }; + + renderWithProviders(); + + await waitFor(() => { + const metadataTextarea = screen.getByLabelText("Metadata"); + expect(metadataTextarea).toHaveValue(""); + }); + }); + + it("should call onCancel when cancel button is clicked", async () => { + const onCancelMock = vi.fn(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + }); + + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + await userEvent.click(cancelButton); + + expect(onCancelMock).toHaveBeenCalledTimes(1); + }); + + it("should call onSubmit with form values when form is submitted", async () => { + const onSubmitMock = vi.fn(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + }); + + const callArgs = onSubmitMock.mock.calls[0][0]; + expect(callArgs.user_id).toBe("user-123"); + expect(callArgs.user_email).toBe("test@example.com"); + expect(callArgs.user_alias).toBe("Test User"); + expect(callArgs.user_role).toBe("proxy_admin"); + expect(callArgs.models).toEqual(["gpt-4", "gpt-3.5-turbo"]); + expect(callArgs.max_budget).toBe(100.5); + expect(callArgs.budget_duration).toBe("30d"); + expect(callArgs.metadata).toEqual(MOCK_USER_DATA.user_info.metadata); + }); + + it("should set max_budget to null when unlimited budget is checked on submit", async () => { + const onSubmitMock = vi.fn(); + const userDataWithNullBudget = { + ...MOCK_USER_DATA, + user_info: { + ...MOCK_USER_DATA.user_info, + max_budget: null, + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + }); + + const callArgs = onSubmitMock.mock.calls[0][0]; + expect(callArgs.max_budget).toBeNull(); + }); + + it("should require budget when unlimited budget is not checked", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Max Budget (USD)")).toBeInTheDocument(); + }); + + const budgetInput = screen.getByRole("spinbutton", { name: /max budget/i }); + await userEvent.clear(budgetInput); + + const checkbox = screen.getByLabelText("Unlimited Budget"); + expect(checkbox).not.toBeChecked(); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText("Please enter a budget or select Unlimited Budget")).toBeInTheDocument(); + }); + }); + + it("should allow submission when unlimited budget is checked even if budget is empty", async () => { + const onSubmitMock = vi.fn(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByLabelText("Unlimited Budget")).toBeInTheDocument(); + }); + + const checkbox = screen.getByLabelText("Unlimited Budget"); + await userEvent.click(checkbox); + + await waitFor(() => { + expect(checkbox).toBeChecked(); + }); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + }); + }); + + it("should update form values when userData changes", async () => { + const { rerender } = renderWithProviders(); + + await waitFor(() => { + expect(screen.getByLabelText("User Alias")).toHaveValue("Test User"); + }); + + const updatedUserData = { + ...MOCK_USER_DATA, + user_info: { + ...MOCK_USER_DATA.user_info, + user_alias: "Updated Alias", + }, + }; + + rerender(); + + await waitFor(() => { + expect(screen.getByLabelText("User Alias")).toHaveValue("Updated Alias"); + }); + }); + + it("should handle user data with empty models array", async () => { + const userDataWithEmptyModels = { + ...MOCK_USER_DATA, + user_info: { + ...MOCK_USER_DATA.user_info, + models: [], + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + + const submitButton = screen.getByRole("button", { name: /save changes/i }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(defaultProps.onSubmit).toHaveBeenCalled(); + }); + + const callArgs = defaultProps.onSubmit.mock.calls[0][0]; + expect(callArgs.models).toEqual([]); + }); + + it("should handle user data with undefined max_budget", async () => { + const userDataWithUndefinedBudget = { + ...MOCK_USER_DATA, + user_info: { + ...MOCK_USER_DATA.user_info, + max_budget: undefined, + }, + }; + + renderWithProviders(); + + await waitFor(() => { + const checkbox = screen.getByLabelText("Unlimited Budget"); + expect(checkbox).toBeChecked(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/user_edit_view.tsx b/ui/litellm-dashboard/src/components/user_edit_view.tsx index 5a15cba91f..e123b6aadd 100644 --- a/ui/litellm-dashboard/src/components/user_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/user_edit_view.tsx @@ -1,12 +1,11 @@ -import React from "react"; -import { Form, Select, Tooltip } from "antd"; -import NumericalInput from "./shared/numerical_input"; -import { TextInput, Textarea, SelectItem } from "@tremor/react"; -import { Button } from "@tremor/react"; -import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; -import { all_admin_roles } from "../utils/roles"; import { InfoCircleOutlined } from "@ant-design/icons"; +import { Button, SelectItem, TextInput, Textarea } from "@tremor/react"; +import { Checkbox, Form, Select, Tooltip } from "antd"; +import React, { useState } from "react"; +import { all_admin_roles } from "../utils/roles"; import BudgetDurationDropdown from "./common_components/budget_duration_dropdown"; +import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; +import NumericalInput from "./shared/numerical_input"; interface UserEditViewProps { userData: any; @@ -34,21 +33,34 @@ export function UserEditView({ isBulkEdit = false, }: UserEditViewProps) { const [form] = Form.useForm(); + const [unlimitedBudget, setUnlimitedBudget] = useState(false); // Set initial form values React.useEffect(() => { + const maxBudget = userData.user_info?.max_budget; + const isUnlimited = maxBudget === null || maxBudget === undefined; + setUnlimitedBudget(isUnlimited); + form.setFieldsValue({ user_id: userData.user_id, user_email: userData.user_info?.user_email, user_alias: userData.user_info?.user_alias, user_role: userData.user_info?.user_role, models: userData.user_info?.models || [], - max_budget: userData.user_info?.max_budget, + max_budget: isUnlimited ? "" : maxBudget, budget_duration: userData.user_info?.budget_duration, metadata: userData.user_info?.metadata ? JSON.stringify(userData.user_info.metadata, null, 2) : undefined, }); }, [userData, form]); + const handleUnlimitedBudgetChange = (e: any) => { + const checked = e.target.checked; + setUnlimitedBudget(checked); + if (checked) { + form.setFieldsValue({ max_budget: "" }); + } + }; + const handleSubmit = (values: any) => { // Convert metadata back to an object if it exists and is a string if (values.metadata && typeof values.metadata === "string") { @@ -60,6 +72,10 @@ export function UserEditView({ } } + if (unlimitedBudget || values.max_budget === "" || values.max_budget === undefined) { + values.max_budget = null; + } + onSubmit(values); }; @@ -138,8 +154,36 @@ export function UserEditView({ - - + + Max Budget (USD) + + Unlimited Budget + + + } + name="max_budget" + rules={[ + { + validator: (_, value) => { + if (!unlimitedBudget && (value === "" || value === null || value === undefined)) { + return Promise.reject(new Error("Please enter a budget or select Unlimited Budget")); + } + return Promise.resolve(); + }, + }, + ]} + > + From 66eadfabe466f1f59b62c8afe2da9529ec248508 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 3 Feb 2026 19:13:13 -0800 Subject: [PATCH 17/18] [Bug] Ensure MCP permissions are enforced when using JWT Auth (#20383) * fix: enforce team MCP permissions when using JWT authentication Root cause: When JWT auth was used with teams in groups (via team_ids_jwt_field), the team's MCP permissions were not being enforced because: 1. The default team_allowed_routes did not include mcp_routes 2. allowed_routes_check() failed for MCP endpoints like /mcp/tools/list 3. find_team_with_model_access() skipped the team due to failed route check 4. team_id was None in UserAPIKeyAuth 5. MCPRequestHandler._get_allowed_mcp_servers_for_team() returned empty list Fix: Add 'mcp_routes' to the default team_allowed_routes in LiteLLM_JWTAuth. This ensures that teams can access MCP endpoints by default, allowing the team's MCP server permissions to be properly enforced. Added tests: - test_reproduce_jwt_mcp_enforcement_issue: Reproduces the exact bug scenario - test_verify_mcp_routes_in_default_team_allowed_routes: Verifies fix - test_mcp_route_check_passes_for_team: Verifies route check works Co-authored-by: ishaan * test: add comprehensive E2E tests for JWT + team MCP permission enforcement Added tests: - test_e2e_jwt_team_mcp_permissions_enforced: Full E2E test verifying JWT auth with teams in groups properly sets team_id and MCPRequestHandler returns the team's MCP servers - test_e2e_jwt_without_team_no_mcp_servers: Verifies no MCP servers returned when JWT has no teams - test_e2e_jwt_team_mcp_key_intersection: Verifies intersection logic when both key and team have MCP permissions (result = intersection) These tests verify the complete flow: 1. JWT token with team in groups field 2. JWT auth properly sets team_id on UserAPIKeyAuth 3. MCPRequestHandler.get_allowed_mcp_servers() returns team's MCP servers 4. Key/team permission intersection works correctly Co-authored-by: ishaan * test: add simple tests for JWT + MCP permission enforcement Simple, focused tests that validate: 1. test_simple_jwt_mcp_permissions_enforced: JWT user with team gets team's MCP servers 2. test_simple_jwt_no_team_no_mcp_servers: JWT user without team gets no MCP servers 3. test_simple_jwt_team_id_required_for_mcp_permissions: Verifies team_id is required 4. test_jwt_auth_sets_team_id_for_mcp_route: JWT auth sets team_id for MCP routes These tests directly verify the core MCP permission enforcement logic works when using JWT authentication with teams. Co-authored-by: ishaan * Add test: MCP route without model still returns team_id Co-authored-by: ishaan * Add 2 debug logs for JWT+MCP troubleshooting - handle_jwt.py: Log team route check result (team_id, route, is_allowed) - user_api_key_auth_mcp.py: Log team_id when looking up MCP permissions Co-authored-by: ishaan --------- Co-authored-by: Cursor Agent Co-authored-by: ishaan --- .../mcp_server/auth/user_api_key_auth_mcp.py | 3 + litellm/proxy/_types.py | 2 +- litellm/proxy/auth/handle_jwt.py | 3 + .../mcp_server/test_jwt_mcp_enforcement.py | 480 ++++++++++++++++++ .../mcp_server/test_jwt_mcp_simple.py | 277 ++++++++++ 5 files changed, 764 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/proxy/_experimental/mcp_server/test_jwt_mcp_enforcement.py create mode 100644 tests/test_litellm/proxy/_experimental/mcp_server/test_jwt_mcp_simple.py diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 49d6ac7d89..7e70b5baae 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -387,6 +387,9 @@ class MCPRequestHandler: user_api_key_cache, ) + verbose_logger.debug( + f"MCP team permission lookup: team_id={user_api_key_auth.team_id if user_api_key_auth else None}" + ) if not user_api_key_auth or not user_api_key_auth.team_id or not prisma_client: return None diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 9ae95085f5..131a6caab0 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3673,7 +3673,7 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase): team_id_upsert: bool = False team_ids_jwt_field: Optional[str] = None upsert_sso_user_to_team: bool = False - team_allowed_routes: List[str] = ["openai_routes", "info_routes"] + team_allowed_routes: List[str] = ["openai_routes", "info_routes", "mcp_routes"] team_id_default: Optional[str] = Field( default=None, description="If no team_id given, default permissions/spend-tracking to this team.s", diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 33667b5d8d..584be0a949 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -976,6 +976,9 @@ class JWTAuthManager: user_route=route, litellm_proxy_roles=jwt_handler.litellm_jwtauth, ) + verbose_proxy_logger.debug( + f"JWT team route check: team_id={team_id}, route={route}, is_allowed={is_allowed}" + ) if is_allowed: return team_id, team_object except Exception: diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_jwt_mcp_enforcement.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_jwt_mcp_enforcement.py new file mode 100644 index 0000000000..b4a5a8ca19 --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_jwt_mcp_enforcement.py @@ -0,0 +1,480 @@ +""" +Test to verify Team MCP permissions are enforced when using JWT authentication. + +Scenario: +1. Team "ABC" exists with models configured and MCPs assigned +2. User JWT has team "ABC" in groups (via team_ids_jwt_field) +3. Call MCP list endpoint +4. EXPECTED: Team MCP permissions should be enforced +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from litellm.proxy._types import ( + LiteLLM_JWTAuth, + LiteLLM_TeamTable, + LiteLLM_ObjectPermissionTable, + UserAPIKeyAuth, +) +from litellm.proxy.auth.handle_jwt import JWTAuthManager, JWTHandler +from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + MCPRequestHandler, +) +from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager + + +@pytest.mark.asyncio +async def test_reproduce_jwt_mcp_enforcement_issue(monkeypatch): + """ + Reproduce the bug where Team MCP permissions are NOT enforced when using JWT. + + Setup: + - Team "ABC" has models ["gpt-4"] and MCPs ["mcp-server-1"] assigned + - JWT has team "ABC" in groups field + - User calls MCP list endpoint (no model requested) + + Expected: team_id should be set to "ABC" so MCP permissions are enforced + Actual (BUG): team_id is None because route check fails for MCP routes + """ + from litellm.caching import DualCache + from litellm.proxy.utils import ProxyLogging + from litellm.router import Router + + # Setup mock router + router = Router(model_list=[{"model_name": "gpt-4", "litellm_params": {"model": "gpt-4"}}]) + import sys + import types + proxy_server_module = types.ModuleType("proxy_server") + proxy_server_module.llm_router = router + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", proxy_server_module) + + # Team "ABC" has models configured AND MCPs assigned + team_with_mcp = LiteLLM_TeamTable( + team_id="ABC", + models=["gpt-4"], # Team HAS models + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="perm-123", + mcp_servers=["mcp-server-1"], # Team has MCPs assigned + ), + ) + + async def mock_get_team_object(*args, **kwargs): + team_id = kwargs.get("team_id") or args[0] + if team_id == "ABC": + return team_with_mcp + return None + + monkeypatch.setattr( + "litellm.proxy.auth.handle_jwt.get_team_object", mock_get_team_object + ) + + # Setup JWT handler with team_ids_jwt_field (groups) + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth( + team_ids_jwt_field="groups", # Use groups field for teams + # NOTE: team_allowed_routes defaults to ["openai_routes", "info_routes"] + # which does NOT include "mcp_routes" + ) + + user_api_key_cache = DualCache() + proxy_logging_obj = ProxyLogging(user_api_key_cache=user_api_key_cache) + + # Simulate JWT payload with team in groups + jwt_token = { + "sub": "user-123", + "groups": ["ABC"], # Team "ABC" is in groups + "scope": "", + } + + # Mock auth_jwt to return our token + with patch.object(jwt_handler, "auth_jwt", new_callable=AsyncMock) as mock_auth_jwt: + mock_auth_jwt.return_value = jwt_token + + # Call auth_builder for MCP route (like /mcp/tools/list) + result = await JWTAuthManager.auth_builder( + api_key="test-jwt-token", + jwt_handler=jwt_handler, + request_data={}, # No model in request (MCP endpoint) + general_settings={}, + route="/mcp/tools/list", # MCP route + prisma_client=None, + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=proxy_logging_obj, + ) + + # THIS IS THE BUG: team_id should be "ABC" but it's None! + print(f"Result team_id: {result['team_id']}") + print(f"Result team_object: {result['team_object']}") + + # The test should FAIL if the bug exists (team_id is None) + # If the fix is applied, team_id should be "ABC" + assert result["team_id"] == "ABC", ( + f"BUG: team_id should be 'ABC' but got '{result['team_id']}'. " + f"This happens because default team_allowed_routes does not include 'mcp_routes', " + f"so allowed_routes_check() fails and the team is skipped in find_team_with_model_access()." + ) + + +@pytest.mark.asyncio +async def test_verify_mcp_routes_in_default_team_allowed_routes(): + """ + Verify that mcp_routes IS in the default team_allowed_routes. + This is required for team MCP permissions to work with JWT auth. + """ + default_jwt_auth = LiteLLM_JWTAuth() + + print(f"Default team_allowed_routes: {default_jwt_auth.team_allowed_routes}") + + # mcp_routes must be in defaults for team MCP permissions to work + assert "mcp_routes" in default_jwt_auth.team_allowed_routes, ( + "mcp_routes must be in default team_allowed_routes for JWT MCP enforcement to work" + ) + + +@pytest.mark.asyncio +async def test_mcp_route_check_passes_for_team(): + """ + Verify that allowed_routes_check returns True for MCP routes with default settings. + This is required for teams to access MCP endpoints with JWT auth. + """ + from litellm.proxy._types import LitellmUserRoles + from litellm.proxy.auth.auth_checks import allowed_routes_check + + jwt_auth = LiteLLM_JWTAuth() # Use defaults + + # Check if MCP route is allowed for TEAM role + is_allowed = allowed_routes_check( + user_role=LitellmUserRoles.TEAM, + user_route="/mcp/tools/list", + litellm_proxy_roles=jwt_auth, + ) + + print(f"Is /mcp/tools/list allowed for TEAM with defaults? {is_allowed}") + + # MCP routes should be allowed by default for teams + assert is_allowed is True, ( + "MCP routes must be allowed by default for teams for JWT MCP enforcement to work" + ) + + +@pytest.mark.asyncio +async def test_e2e_jwt_team_mcp_permissions_enforced(monkeypatch): + """ + End-to-end test verifying that team MCP permissions are properly enforced + when using JWT authentication with teams in groups. + + This test verifies the complete flow: + 1. JWT token contains team "ABC" in groups field + 2. Team "ABC" exists with MCP servers ["mcp-server-1", "mcp-server-2"] assigned + 3. JWT auth properly sets team_id on UserAPIKeyAuth + 4. MCPRequestHandler.get_allowed_mcp_servers() returns team's MCP servers + """ + from litellm.caching import DualCache + from litellm.proxy.utils import ProxyLogging + from litellm.router import Router + + # Setup mock router + router = Router(model_list=[{"model_name": "gpt-4", "litellm_params": {"model": "gpt-4"}}]) + import sys + import types + proxy_server_module = types.ModuleType("proxy_server") + proxy_server_module.llm_router = router + proxy_server_module.prisma_client = MagicMock() # Mock prisma client + proxy_server_module.user_api_key_cache = DualCache() + proxy_server_module.proxy_logging_obj = MagicMock() + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", proxy_server_module) + + # Team "ABC" has MCP servers assigned via object_permission + team_mcp_servers = ["mcp-server-1", "mcp-server-2"] + team_object_permission = LiteLLM_ObjectPermissionTable( + object_permission_id="perm-abc-123", + mcp_servers=team_mcp_servers, + mcp_access_groups=[], + vector_stores=[], + ) + + team_with_mcp = LiteLLM_TeamTable( + team_id="ABC", + models=["gpt-4"], + object_permission=team_object_permission, + object_permission_id="perm-abc-123", + ) + + async def mock_get_team_object(*args, **kwargs): + team_id = kwargs.get("team_id") or (args[0] if args else None) + if team_id == "ABC": + return team_with_mcp + return None + + monkeypatch.setattr( + "litellm.proxy.auth.handle_jwt.get_team_object", mock_get_team_object + ) + monkeypatch.setattr( + "litellm.proxy.auth.auth_checks.get_team_object", mock_get_team_object + ) + + # Setup JWT handler with team_ids_jwt_field (groups) + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth( + team_ids_jwt_field="groups", + ) + + user_api_key_cache = DualCache() + proxy_logging_obj = ProxyLogging(user_api_key_cache=user_api_key_cache) + + # Simulate JWT payload with team in groups + jwt_token = { + "sub": "user-123", + "groups": ["ABC"], + "scope": "", + } + + # Step 1: Verify JWT auth returns correct team_id + with patch.object(jwt_handler, "auth_jwt", new_callable=AsyncMock) as mock_auth_jwt: + mock_auth_jwt.return_value = jwt_token + + result = await JWTAuthManager.auth_builder( + api_key="test-jwt-token", + jwt_handler=jwt_handler, + request_data={}, + general_settings={}, + route="/mcp/tools/list", + prisma_client=None, + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=proxy_logging_obj, + ) + + # Verify team_id is set correctly + assert result["team_id"] == "ABC", f"Expected team_id='ABC', got '{result['team_id']}'" + assert result["team_object"] is not None, "team_object should not be None" + + # Step 2: Create UserAPIKeyAuth with the team_id from JWT auth + user_api_key_auth = UserAPIKeyAuth( + api_key=None, + team_id=result["team_id"], + user_id=result["user_id"], + ) + + # Step 3: Verify MCPRequestHandler returns team's MCP servers + # Mock _get_team_object_permission to return our team's object_permission + with patch.object( + MCPRequestHandler, "_get_team_object_permission" + ) as mock_get_team_perm: + mock_get_team_perm.return_value = team_object_permission + + # Mock _get_allowed_mcp_servers_for_key to return empty (no key-level permissions) + with patch.object( + MCPRequestHandler, "_get_allowed_mcp_servers_for_key" + ) as mock_key_servers: + mock_key_servers.return_value = [] + + # Mock _get_mcp_servers_from_access_groups to return empty + with patch.object( + MCPRequestHandler, "_get_mcp_servers_from_access_groups" + ) as mock_access_groups: + mock_access_groups.return_value = [] + + allowed_servers = await MCPRequestHandler.get_allowed_mcp_servers( + user_api_key_auth + ) + + print(f"Allowed MCP servers: {allowed_servers}") + + # Verify team's MCP servers are returned + assert set(allowed_servers) == set(team_mcp_servers), ( + f"Expected team MCP servers {team_mcp_servers}, got {allowed_servers}" + ) + + +@pytest.mark.asyncio +async def test_e2e_jwt_without_team_no_mcp_servers(monkeypatch): + """ + End-to-end test verifying that when JWT has no teams, no MCP servers are returned. + + This ensures: + 1. JWT token with no groups returns no team_id + 2. MCPRequestHandler.get_allowed_mcp_servers() returns empty list + """ + from litellm.caching import DualCache + from litellm.proxy.utils import ProxyLogging + from litellm.router import Router + + # Setup mock router + router = Router(model_list=[]) + import sys + import types + proxy_server_module = types.ModuleType("proxy_server") + proxy_server_module.llm_router = router + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", proxy_server_module) + + async def mock_get_team_object(*args, **kwargs): + return None + + monkeypatch.setattr( + "litellm.proxy.auth.handle_jwt.get_team_object", mock_get_team_object + ) + + # Setup JWT handler + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth( + team_ids_jwt_field="groups", + ) + + user_api_key_cache = DualCache() + proxy_logging_obj = ProxyLogging(user_api_key_cache=user_api_key_cache) + + # JWT payload with empty groups + jwt_token = { + "sub": "user-123", + "groups": [], # No teams + "scope": "", + } + + with patch.object(jwt_handler, "auth_jwt", new_callable=AsyncMock) as mock_auth_jwt: + mock_auth_jwt.return_value = jwt_token + + result = await JWTAuthManager.auth_builder( + api_key="test-jwt-token", + jwt_handler=jwt_handler, + request_data={}, + general_settings={}, + route="/mcp/tools/list", + prisma_client=None, + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=proxy_logging_obj, + ) + + # Verify no team_id is set + assert result["team_id"] is None, f"Expected team_id=None, got '{result['team_id']}'" + + # Create UserAPIKeyAuth without team_id + user_api_key_auth = UserAPIKeyAuth( + api_key=None, + team_id=None, + user_id=result["user_id"], + ) + + # Verify no MCP servers are returned when there's no team + allowed_servers = await MCPRequestHandler._get_allowed_mcp_servers_for_team( + user_api_key_auth + ) + + assert allowed_servers == [], f"Expected empty list, got {allowed_servers}" + + +@pytest.mark.asyncio +async def test_e2e_jwt_team_mcp_key_intersection(monkeypatch): + """ + End-to-end test verifying MCP permission intersection between key and team. + + Scenario: + - Team has MCP servers: ["server-1", "server-2", "server-3"] + - Key has MCP servers: ["server-2", "server-4"] + - Result should be intersection: ["server-2"] + """ + from litellm.caching import DualCache + from litellm.proxy.utils import ProxyLogging + from litellm.router import Router + + # Setup mock router + router = Router(model_list=[{"model_name": "gpt-4", "litellm_params": {"model": "gpt-4"}}]) + import sys + import types + proxy_server_module = types.ModuleType("proxy_server") + proxy_server_module.llm_router = router + proxy_server_module.prisma_client = MagicMock() + proxy_server_module.user_api_key_cache = DualCache() + proxy_server_module.proxy_logging_obj = MagicMock() + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", proxy_server_module) + + # Team MCP servers + team_mcp_servers = ["server-1", "server-2", "server-3"] + team_object_permission = LiteLLM_ObjectPermissionTable( + object_permission_id="team-perm", + mcp_servers=team_mcp_servers, + ) + + team_with_mcp = LiteLLM_TeamTable( + team_id="TEAM-X", + models=["gpt-4"], + object_permission=team_object_permission, + ) + + # Key MCP servers + key_mcp_servers = ["server-2", "server-4"] + key_object_permission = LiteLLM_ObjectPermissionTable( + object_permission_id="key-perm", + mcp_servers=key_mcp_servers, + ) + + async def mock_get_team_object(*args, **kwargs): + team_id = kwargs.get("team_id") or (args[0] if args else None) + if team_id == "TEAM-X": + return team_with_mcp + return None + + monkeypatch.setattr( + "litellm.proxy.auth.handle_jwt.get_team_object", mock_get_team_object + ) + + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth(team_ids_jwt_field="groups") + + user_api_key_cache = DualCache() + proxy_logging_obj = ProxyLogging(user_api_key_cache=user_api_key_cache) + + jwt_token = {"sub": "user-123", "groups": ["TEAM-X"], "scope": ""} + + with patch.object(jwt_handler, "auth_jwt", new_callable=AsyncMock) as mock_auth_jwt: + mock_auth_jwt.return_value = jwt_token + + result = await JWTAuthManager.auth_builder( + api_key="test-jwt-token", + jwt_handler=jwt_handler, + request_data={}, + general_settings={}, + route="/mcp/tools/list", + prisma_client=None, + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=proxy_logging_obj, + ) + + assert result["team_id"] == "TEAM-X" + + user_api_key_auth = UserAPIKeyAuth( + api_key=None, + team_id=result["team_id"], + user_id=result["user_id"], + object_permission=key_object_permission, # Key has its own permissions + ) + + # Mock the helper methods to return our test data + with patch.object( + MCPRequestHandler, "_get_team_object_permission" + ) as mock_team_perm: + mock_team_perm.return_value = team_object_permission + + with patch.object( + MCPRequestHandler, "_get_key_object_permission" + ) as mock_key_perm: + mock_key_perm.return_value = key_object_permission + + with patch.object( + MCPRequestHandler, "_get_mcp_servers_from_access_groups" + ) as mock_access_groups: + mock_access_groups.return_value = [] + + allowed_servers = await MCPRequestHandler.get_allowed_mcp_servers( + user_api_key_auth + ) + + # Should be intersection: only server-2 is in both + expected = ["server-2"] + assert sorted(allowed_servers) == sorted(expected), ( + f"Expected intersection {expected}, got {allowed_servers}" + ) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_jwt_mcp_simple.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_jwt_mcp_simple.py new file mode 100644 index 0000000000..9ad7736d01 --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_jwt_mcp_simple.py @@ -0,0 +1,277 @@ +""" +Simple test to validate MCP permissions are enforced when calling MCP routes with JWT. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from litellm.proxy._types import ( + LiteLLM_JWTAuth, + LiteLLM_TeamTable, + LiteLLM_ObjectPermissionTable, + UserAPIKeyAuth, +) + + +@pytest.mark.asyncio +async def test_simple_jwt_mcp_permissions_enforced(): + """ + Simple test: Call MCP route with JWT, verify team's MCP servers are returned. + + Setup: + - Team "my-team" has MCP servers: ["github-mcp", "slack-mcp"] + - JWT user belongs to "my-team" + + Expected: Only ["github-mcp", "slack-mcp"] should be allowed + """ + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + MCPRequestHandler, + ) + + # 1. Create a user authenticated via JWT with team_id set + user_auth = UserAPIKeyAuth( + api_key=None, # JWT auth doesn't have api_key + user_id="jwt-user-123", + team_id="my-team", # This is set by JWT auth when team is in groups + ) + + # 2. Team's MCP permissions + team_mcp_servers = ["github-mcp", "slack-mcp"] + team_object_permission = LiteLLM_ObjectPermissionTable( + object_permission_id="perm-123", + mcp_servers=team_mcp_servers, + ) + + # 3. Mock the team permission lookup + with patch.object( + MCPRequestHandler, "_get_team_object_permission", new_callable=AsyncMock + ) as mock_team_perm: + mock_team_perm.return_value = team_object_permission + + # Mock key permissions (empty - user has no key-level MCP permissions) + with patch.object( + MCPRequestHandler, "_get_key_object_permission", new_callable=AsyncMock + ) as mock_key_perm: + mock_key_perm.return_value = None + + # Mock access groups (empty) + with patch.object( + MCPRequestHandler, "_get_mcp_servers_from_access_groups", new_callable=AsyncMock + ) as mock_access_groups: + mock_access_groups.return_value = [] + + # 4. Call get_allowed_mcp_servers - this is what MCP routes use + allowed = await MCPRequestHandler.get_allowed_mcp_servers(user_auth) + + # 5. Verify only team's MCP servers are returned + assert sorted(allowed) == sorted(team_mcp_servers), ( + f"Expected {team_mcp_servers}, got {allowed}" + ) + + # Verify team permission was looked up + mock_team_perm.assert_called_once_with(user_auth) + + +@pytest.mark.asyncio +async def test_simple_jwt_no_team_no_mcp_servers(): + """ + Simple test: JWT user with no team should get no MCP servers. + """ + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + MCPRequestHandler, + ) + + # User with no team_id (JWT didn't have teams in groups) + user_auth = UserAPIKeyAuth( + api_key=None, + user_id="jwt-user-no-team", + team_id=None, # No team + ) + + # _get_allowed_mcp_servers_for_team returns [] when team_id is None + allowed = await MCPRequestHandler._get_allowed_mcp_servers_for_team(user_auth) + + assert allowed == [], f"Expected [], got {allowed}" + + +@pytest.mark.asyncio +async def test_simple_jwt_team_id_required_for_mcp_permissions(): + """ + Simple test: Verify that team_id must be set for team MCP permissions to work. + + This is the key insight - if JWT auth doesn't set team_id, + team MCP permissions won't be enforced. + """ + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + MCPRequestHandler, + ) + + # Case 1: team_id is set -> team permissions should be checked + user_with_team = UserAPIKeyAuth( + api_key=None, + user_id="user-1", + team_id="team-abc", + ) + + team_mcp_servers = ["server-1", "server-2"] + team_perm = LiteLLM_ObjectPermissionTable( + object_permission_id="perm-1", + mcp_servers=team_mcp_servers, + ) + + with patch.object( + MCPRequestHandler, "_get_team_object_permission", new_callable=AsyncMock + ) as mock_perm: + mock_perm.return_value = team_perm + + with patch.object( + MCPRequestHandler, "_get_mcp_servers_from_access_groups", new_callable=AsyncMock + ) as mock_groups: + mock_groups.return_value = [] + + result = await MCPRequestHandler._get_allowed_mcp_servers_for_team(user_with_team) + + assert sorted(result) == sorted(team_mcp_servers) + mock_perm.assert_called_once() # Permission WAS checked + + # Case 2: team_id is None -> team permissions NOT checked + user_without_team = UserAPIKeyAuth( + api_key=None, + user_id="user-2", + team_id=None, + ) + + result = await MCPRequestHandler._get_allowed_mcp_servers_for_team(user_without_team) + assert result == [] # No permissions returned + + +@pytest.mark.asyncio +async def test_jwt_auth_sets_team_id_for_mcp_route(): + """ + Test that JWT auth properly sets team_id when accessing MCP routes. + + This is the critical test - when user calls /mcp/tools/list with JWT, + the team_id from JWT groups must be set on UserAPIKeyAuth. + """ + from litellm.proxy.auth.handle_jwt import JWTAuthManager, JWTHandler + from litellm.caching import DualCache + from litellm.proxy.utils import ProxyLogging + + # Setup + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth( + team_ids_jwt_field="groups", # Teams come from "groups" field in JWT + ) + + # Team exists with models + team = LiteLLM_TeamTable( + team_id="team-from-jwt", + models=["gpt-4"], + ) + + user_api_key_cache = DualCache() + proxy_logging_obj = ProxyLogging(user_api_key_cache=user_api_key_cache) + + # Mock JWT token with team in groups + jwt_payload = { + "sub": "user-123", + "groups": ["team-from-jwt"], + "scope": "", + } + + with patch.object(jwt_handler, "auth_jwt", new_callable=AsyncMock) as mock_auth: + mock_auth.return_value = jwt_payload + + with patch( + "litellm.proxy.auth.handle_jwt.get_team_object", new_callable=AsyncMock + ) as mock_get_team: + mock_get_team.return_value = team + + # Simulate calling MCP route + result = await JWTAuthManager.auth_builder( + api_key="jwt-token", + jwt_handler=jwt_handler, + request_data={}, + general_settings={}, + route="/mcp/tools/list", # MCP route + prisma_client=None, + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=proxy_logging_obj, + ) + + # THE KEY ASSERTION: team_id must be set + assert result["team_id"] == "team-from-jwt", ( + f"team_id should be 'team-from-jwt' but got '{result['team_id']}'. " + "This means JWT auth is not properly setting team_id for MCP routes!" + ) + + +@pytest.mark.asyncio +async def test_mcp_route_without_model_still_returns_team_id(): + """ + Test that MCP routes (which don't specify a model) still get team_id assigned. + + Key insight: MCP routes don't require a model in the request, but the JWT auth + flow must still assign a team_id so that team MCP permissions are enforced. + + The flow is: + 1. JWT token contains team in "groups" field + 2. find_team_with_model_access() is called with requested_model=None + 3. Since `not requested_model` is True, model check passes + 4. Route check passes because "mcp_routes" is in team_allowed_routes + 5. team_id is returned and set on UserAPIKeyAuth + """ + from litellm.proxy.auth.handle_jwt import JWTAuthManager, JWTHandler + from litellm.caching import DualCache + from litellm.proxy.utils import ProxyLogging + + # Setup + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth( + team_ids_jwt_field="groups", + ) + + # Team exists - note: models is a list (can be empty or have values) + # The key is that when no model is requested, model check is skipped + team = LiteLLM_TeamTable( + team_id="my-team", + models=["gpt-4", "gpt-3.5-turbo"], # Team has models, but MCP request won't specify one + ) + + user_api_key_cache = DualCache() + proxy_logging_obj = ProxyLogging(user_api_key_cache=user_api_key_cache) + + # JWT with team in groups + jwt_payload = { + "sub": "user-abc", + "groups": ["my-team"], + "scope": "", + } + + with patch.object(jwt_handler, "auth_jwt", new_callable=AsyncMock) as mock_auth: + mock_auth.return_value = jwt_payload + + with patch( + "litellm.proxy.auth.handle_jwt.get_team_object", new_callable=AsyncMock + ) as mock_get_team: + mock_get_team.return_value = team + + # Call MCP route with NO MODEL in request_data + result = await JWTAuthManager.auth_builder( + api_key="jwt-token", + jwt_handler=jwt_handler, + request_data={}, # <-- NO MODEL SPECIFIED + general_settings={}, + route="/mcp/tools/list", # MCP route + prisma_client=None, + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=proxy_logging_obj, + ) + + # Team ID must still be set even though no model was requested + assert result["team_id"] == "my-team", ( + f"Expected team_id='my-team' but got '{result['team_id']}'. " + "MCP routes without model should still get team_id from JWT!" + ) From 0cb6b5876802884ca51038cc23c452dd342dee6a Mon Sep 17 00:00:00 2001 From: naaa760 Date: Wed, 4 Feb 2026 08:56:50 +0530 Subject: [PATCH 18/18] fix(proxy): forward extra_headers in chat --- .../transformation.py | 3 +++ ...responses_transformation_transformation.py | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 57bd05124a..753a94295b 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -329,6 +329,9 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): else: request_data[key] = value + if headers: + request_data["extra_headers"] = headers + return request_data @staticmethod diff --git a/tests/test_litellm/completion_extras/test_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/test_litellm_responses_transformation_transformation.py index adbaf21907..57352eafaf 100644 --- a/tests/test_litellm/completion_extras/test_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/test_litellm_responses_transformation_transformation.py @@ -134,3 +134,25 @@ def test_transform_request_with_response_format(): assert result["text"]["format"]["type"] == "json_schema" assert result["text"]["format"]["name"] == "person_schema" assert "schema" in result["text"]["format"] + + +def test_transform_request_includes_extra_headers(): + """Test that transform_request forwards headers as extra_headers for upstream call.""" + handler = LiteLLMResponsesTransformationHandler() + messages = [{"role": "user", "content": "Hello"}] + optional_params = {} + litellm_params = {} + + class MockLoggingObj: + pass + + headers = {"cf-aig-authorization": "secret-token"} + result = handler.transform_request( + model="gpt-5-pro", + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + headers=headers, + litellm_logging_obj=MockLoggingObj(), + ) + assert result.get("extra_headers") == headers