From 498c4254ead47876814b8a4f9b2fce485b943a37 Mon Sep 17 00:00:00 2001 From: Eric84626 Date: Sun, 7 Dec 2025 20:53:51 +0800 Subject: [PATCH 001/530] fix: Return 403 exception when calling GET responses api --- litellm/proxy/auth/auth_checks.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index fc79a4d3591..309bd577606 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -402,13 +402,14 @@ def _allowed_routes_check(user_route: str, allowed_routes: list) -> bool: - user_route: str - the route the user is trying to call - allowed_routes: List[str|LiteLLMRoutes] - the list of allowed routes for the user. """ + from starlette.routing import compile_path for allowed_route in allowed_routes: - if ( - allowed_route in LiteLLMRoutes.__members__ - and user_route in LiteLLMRoutes[allowed_route].value - ): - return True + if allowed_route in LiteLLMRoutes.__members__: + for template in LiteLLMRoutes[allowed_route].value: + regex, _, _ = compile_path(template) + if regex.match(user_route): + return True elif allowed_route == user_route: return True return False From 4ab58619ad56d93eb4add67bfd6064c50f449fa1 Mon Sep 17 00:00:00 2001 From: Eric84626 Date: Sun, 14 Dec 2025 17:55:13 +0800 Subject: [PATCH 002/530] fix: added new step into rotate master key function for processing credentials table --- .../proxy/credential_endpoints/endpoints.py | 9 ++--- .../key_management_endpoints.py | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/credential_endpoints/endpoints.py b/litellm/proxy/credential_endpoints/endpoints.py index 647abb73648..9f228bb1184 100644 --- a/litellm/proxy/credential_endpoints/endpoints.py +++ b/litellm/proxy/credential_endpoints/endpoints.py @@ -21,11 +21,11 @@ router = APIRouter() class CredentialHelperUtils: @staticmethod - def encrypt_credential_values(credential: CredentialItem) -> CredentialItem: + def encrypt_credential_values(credential: CredentialItem, new_encryption_key: Optional[str] = None) -> CredentialItem: """Encrypt values in credential.credential_values and add to DB""" encrypted_credential_values = {} for key, value in (credential.credential_values or {}).items(): - encrypted_credential_values[key] = encrypt_value_helper(value) + encrypted_credential_values[key] = encrypt_value_helper(value, new_encryption_key) # Return a new object to avoid mutating the caller's credential, which # is kept in memory and should remain unencrypted. @@ -246,7 +246,7 @@ async def delete_credential( def update_db_credential( - db_credential: CredentialItem, updated_patch: CredentialItem + db_credential: CredentialItem, updated_patch: CredentialItem, new_encryption_key: Optional[str] = None ) -> CredentialItem: """ Update a credential in the DB. @@ -258,7 +258,8 @@ def update_db_credential( ) encrypted_credential = CredentialHelperUtils.encrypt_credential_values( - updated_patch + updated_patch, + new_encryption_key, ) # update model name if encrypted_credential.credential_name: diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index da44bda791d..8ea3122ce01 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -2539,6 +2539,40 @@ async def _rotate_master_key( new_master_key=new_master_key, ) + # 5. process credentials table + try: + credentials = await prisma_client.db.litellm_credentialstable.find_many() + except Exception: + credentials = None + if credentials: + from litellm.proxy.credential_endpoints.endpoints import update_db_credential + + for cred in credentials: + try: + decrypted_cred = proxy_config.decrypt_credentials(cred) + encrypted_cred = update_db_credential( + db_credential=cred, + updated_patch=decrypted_cred, + new_encryption_key=new_master_key, + ) + credential_object_jsonified = jsonify_object(encrypted_cred.model_dump()) + await prisma_client.db.litellm_credentialstable.update( + where={"credential_name": cred.credential_name}, + data={ + **credential_object_jsonified, + "updated_by": user_api_key_dict.user_id, + }, + ) + except Exception as e: + verbose_proxy_logger.error( + f"Failed to re-encrypt credential {cred.credential_name}: {str(e)}" + ) + # Continue with next credential instead of failing entire rotation + continue + verbose_proxy_logger.debug( + f"Successfully re-encrypted {len(credentials)} credentials with new master key" + ) + def get_new_token(data: Optional[RegenerateKeyRequest]) -> str: if data and data.new_key is not None: From f4f5ea85dfe7eff7130724b1d7331354468f5ce8 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 18 Dec 2025 14:42:41 +0530 Subject: [PATCH 003/530] Add redisvl in requirements.txt --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c36f94f0752..2fb6c52cfd3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,8 @@ starlette==0.49.1 # starlette fastapi dep backoff==2.2.1 # server dep pyyaml==6.0.2 # server dep uvicorn==0.31.1 # server dep -gunicorn==23.0.0 # server dep +gunicorn==23.0.0 # server depredisvl +redisvl==0.4.1 # redis semantic cache fastuuid==0.13.5 # for uuid4 uvloop==0.21.0 # uvicorn dep, gives us much better performance under load boto3==1.36.0 # aws bedrock/sagemaker calls From 5705aaebbc3247cb1666dec0288ab7b5507b2c79 Mon Sep 17 00:00:00 2001 From: Rens Date: Thu, 18 Dec 2025 15:11:58 +0200 Subject: [PATCH 004/530] Fix Gemini 3 imgs in tool response --- .../prompt_templates/factory.py | 7 +-- ...llm_core_utils_prompt_templates_factory.py | 51 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 652692c7b8d..9afea83ef7b 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1496,9 +1496,10 @@ def convert_to_gemini_tool_call_result( content_type = content.get("type", "") if content_type == "text": content_str += content.get("text", "") - elif content_type == "input_image": - # Extract image for inline_data (for Computer Use screenshots) - image_url = content.get("image_url", "") + elif content_type in ("input_image", "image_url"): + # Extract image for inline_data (for Computer Use screenshots and tool results) + image_url_data = content.get("image_url", "") + image_url = image_url_data.get("url", "") if isinstance(image_url_data, dict) else image_url_data if image_url: # Convert image to base64 blob format for Gemini diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 41ac893b4d7..c8fe6efeaa1 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -497,6 +497,57 @@ def test_convert_gemini_messages(): ) +def test_convert_gemini_tool_call_result_with_image_url(): + """ + Test that image_url content type in tool results is handled correctly for Gemini. + Fixes: https://github.com/BerriAI/litellm/issues/18187 + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_gemini_tool_call_result, + ) + from litellm.types.llms.openai import ChatCompletionToolMessage + + # Test with string image_url format + message_str_format = ChatCompletionToolMessage( + role="tool", + tool_call_id="call_123", + content=[{"type": "image_url", "image_url": "data:image/jpeg;base64,/9j/4AAQ"}], + ) + last_message_with_tool_calls = { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "index": 0, + "function": {"name": "get_image", "arguments": "{}"}, + } + ], + } + + result = convert_to_gemini_tool_call_result( + message=message_str_format, + last_message_with_tool_calls=last_message_with_tool_calls, + ) + # Should have inline_data for the image + assert isinstance(result, list) and any("inline_data" in p for p in result) + + # Test with dict image_url format (OpenAI standard) + message_dict_format = ChatCompletionToolMessage( + role="tool", + tool_call_id="call_456", + content=[{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQ"}}], + ) + last_message_with_tool_calls["tool_calls"][0]["id"] = "call_456" + + result2 = convert_to_gemini_tool_call_result( + message=message_dict_format, + last_message_with_tool_calls=last_message_with_tool_calls, + ) + assert isinstance(result2, list) and any("inline_data" in p for p in result2) + + def test_bedrock_tools_unpack_defs(): """ Test that the unpack_defs method handles nested $ref inside anyOf items correctly From e08b4767e925c5c55c62a7881440da4bd6641a7c Mon Sep 17 00:00:00 2001 From: Chesars Date: Thu, 18 Dec 2025 17:25:43 -0300 Subject: [PATCH 005/530] fix: case-insensitive model cost map lookup Users were getting "does not support parameters: ['tools']" errors when using lowercase model names (e.g., "qwen/qwen3-next-80b-a3b-thinking") because the model cost map has mixed-case keys and the lookup was case-sensitive. Added _get_model_cost_key() helper that tries exact match first (O(1)), then falls back to case-insensitive search if not found. --- litellm/utils.py | 96 ++++++++++++++-------- tests/local_testing/test_get_model_info.py | 83 +++++++++++++++++++ 2 files changed, 144 insertions(+), 35 deletions(-) diff --git a/litellm/utils.py b/litellm/utils.py index dfc0df5c9a2..8eb71817111 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -4697,6 +4697,25 @@ def _strip_model_name(model: str, custom_llm_provider: Optional[str]) -> str: return model +def _get_model_cost_key(potential_key: str) -> Optional[str]: + """ + Get the actual key from model_cost, with case-insensitive fallback. + + Returns the key if found (exact match preferred, then case-insensitive), or None if not found. + """ + # Try exact match first (most common case, O(1)) + if potential_key in litellm.model_cost: + return potential_key + + # Fallback to case-insensitive match + potential_key_lower = potential_key.lower() + for key in litellm.model_cost: + if key.lower() == potential_key_lower: + return key + + return None + + def _get_model_info_from_model_cost(key: str) -> dict: return litellm.model_cost[key] @@ -4845,10 +4864,10 @@ def _is_potential_model_name_in_model_cost( potential_model_names: PotentialModelNamesAndCustomLLMProvider, ) -> bool: """ - Check if the potential model name is in the model cost. + Check if the potential model name is in the model cost (case-insensitive). """ return any( - potential_model_name in litellm.model_cost + _get_model_cost_key(potential_model_name) is not None for potential_model_name in potential_model_names.values() ) @@ -4926,44 +4945,51 @@ def _get_model_info_helper( # noqa: PLR0915 _model_info: Optional[Dict[str, Any]] = None key: Optional[str] = None - if combined_model_name in litellm.model_cost: - key = combined_model_name - _model_info = _get_model_info_from_model_cost(key=cast(str, key)) - if not _check_provider_match( - model_info=_model_info, custom_llm_provider=custom_llm_provider - ): - _model_info = None - if _model_info is None and model in litellm.model_cost: - key = model - _model_info = _get_model_info_from_model_cost(key=cast(str, key)) - if not _check_provider_match( - model_info=_model_info, custom_llm_provider=custom_llm_provider - ): - _model_info = None - if ( - _model_info is None - and combined_stripped_model_name in litellm.model_cost - ): - key = combined_stripped_model_name - _model_info = _get_model_info_from_model_cost(key=cast(str, key)) - if not _check_provider_match( - model_info=_model_info, custom_llm_provider=custom_llm_provider - ): - _model_info = None - if _model_info is None and stripped_model_name in litellm.model_cost: - key = stripped_model_name - _model_info = _get_model_info_from_model_cost(key=cast(str, key)) - if not _check_provider_match( - model_info=_model_info, custom_llm_provider=custom_llm_provider - ): - _model_info = None - if _model_info is None and split_model in litellm.model_cost: - key = split_model + # Use case-insensitive lookup for all model name checks + _matched_key = _get_model_cost_key(combined_model_name) + if _matched_key is not None: + key = _matched_key _model_info = _get_model_info_from_model_cost(key=cast(str, key)) if not _check_provider_match( model_info=_model_info, custom_llm_provider=custom_llm_provider ): _model_info = None + if _model_info is None: + _matched_key = _get_model_cost_key(model) + if _matched_key is not None: + key = _matched_key + _model_info = _get_model_info_from_model_cost(key=cast(str, key)) + if not _check_provider_match( + model_info=_model_info, custom_llm_provider=custom_llm_provider + ): + _model_info = None + if _model_info is None: + _matched_key = _get_model_cost_key(combined_stripped_model_name) + if _matched_key is not None: + key = _matched_key + _model_info = _get_model_info_from_model_cost(key=cast(str, key)) + if not _check_provider_match( + model_info=_model_info, custom_llm_provider=custom_llm_provider + ): + _model_info = None + if _model_info is None: + _matched_key = _get_model_cost_key(stripped_model_name) + if _matched_key is not None: + key = _matched_key + _model_info = _get_model_info_from_model_cost(key=cast(str, key)) + if not _check_provider_match( + model_info=_model_info, custom_llm_provider=custom_llm_provider + ): + _model_info = None + if _model_info is None: + _matched_key = _get_model_cost_key(split_model) + if _matched_key is not None: + key = _matched_key + _model_info = _get_model_info_from_model_cost(key=cast(str, key)) + if not _check_provider_match( + model_info=_model_info, custom_llm_provider=custom_llm_provider + ): + _model_info = None if _model_info is None or key is None: raise ValueError( diff --git a/tests/local_testing/test_get_model_info.py b/tests/local_testing/test_get_model_info.py index 823f03185d5..b84fc22af01 100644 --- a/tests/local_testing/test_get_model_info.py +++ b/tests/local_testing/test_get_model_info.py @@ -372,3 +372,86 @@ def test_get_model_info_cost_calculator_bedrock_region_cris_stripped(model, prov print("info", info) assert info["key"] == "us.anthropic.claude-3-haiku-20240307-v1:0" assert info["litellm_provider"] == "bedrock" + + +def test_get_model_info_case_insensitive_lookup(monkeypatch): + """ + Test that model info lookup is case-insensitive. + + This ensures that users can use lowercase model names even when the model cost + map has mixed-case keys (e.g., "Qwen/Qwen3-Next-80B-A3B-Thinking"). + + Related Slack discussion: Users were getting "does not support parameters: ['tools']" + errors when using lowercase model names like "qwen/qwen3-next-80b-a3b-thinking" + because the lookup was case-sensitive. + """ + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + litellm.model_cost = litellm.get_model_cost_map(url="") + + # Register a test model with mixed-case name + litellm.register_model( + { + "together_ai/Qwen/Qwen3-Next-80B-A3B-Thinking": { + "input_cost_per_token": 0.0001, + "output_cost_per_token": 0.0002, + "litellm_provider": "together_ai", + "supports_function_calling": True, + } + } + ) + + # Test 1: Exact case should work + info = litellm.get_model_info( + model="Qwen/Qwen3-Next-80B-A3B-Thinking", custom_llm_provider="together_ai" + ) + assert info is not None + assert info["supports_function_calling"] is True + + # Test 2: Lowercase should also work (case-insensitive lookup) + info_lower = litellm.get_model_info( + model="qwen/qwen3-next-80b-a3b-thinking", custom_llm_provider="together_ai" + ) + assert info_lower is not None + assert info_lower["supports_function_calling"] is True + + # Test 3: Mixed case should also work + info_mixed = litellm.get_model_info( + model="QWEN/qwen3-NEXT-80b-a3b-thinking", custom_llm_provider="together_ai" + ) + assert info_mixed is not None + assert info_mixed["supports_function_calling"] is True + + +def test_get_model_info_case_insensitive_supports_function_calling(monkeypatch): + """ + Test that supports_function_calling check works with case-insensitive model lookup. + """ + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + litellm.model_cost = litellm.get_model_cost_map(url="") + + # Register a model with mixed-case name that supports function calling + litellm.register_model( + { + "test_provider/TestModel-ABC": { + "input_cost_per_token": 0.0001, + "output_cost_per_token": 0.0002, + "litellm_provider": "test_provider", + "supports_function_calling": True, + } + } + ) + + # Test that supports_function_calling works with lowercase model name + from litellm.utils import supports_function_calling + + # Exact case + assert ( + supports_function_calling("TestModel-ABC", custom_llm_provider="test_provider") + is True + ) + + # Lowercase (should now work with case-insensitive lookup) + assert ( + supports_function_calling("testmodel-abc", custom_llm_provider="test_provider") + is True + ) From 7ddab06bedba0d6151309c308a38cbfa13588dff Mon Sep 17 00:00:00 2001 From: Eric84626 Date: Sat, 20 Dec 2025 11:35:20 +0800 Subject: [PATCH 006/530] fix: fixed the issue of handling root paths when processing Discovery protected resource metadata and authorization server metadata URLs. --- .../mcp_server/discoverable_endpoints.py | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index ffa17a5b7c4..4b6020f582b 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -15,6 +15,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import ( ) from litellm.proxy.common_utils.http_parsing_utils import _read_request_body from litellm.types.mcp_server.mcp_server_manager import MCPServer +from litellm.proxy.utils import get_server_root_path router = APIRouter( tags=["mcp"], @@ -381,7 +382,18 @@ async def callback(code: str, state: str): # ------------------------------ # Optional .well-known endpoints for MCP + OAuth discovery # ------------------------------ -@router.get("/.well-known/oauth-protected-resource/{mcp_server_name}/mcp") +""" + Per SEP-985, the client MUST: + 1. Try resource_metadata from WWW-Authenticate header (if present) + 2. Fall back to path-based well-known URI: /.well-known/oauth-protected-resource/{path} + ( + If the resource identifier value contains a path or query component, any terminating slash (/) + following the host component MUST be removed before inserting /.well-known/ and the well-known + URI path suffix between the host component and the path(include root path) and/or query components. + https://datatracker.ietf.org/doc/html/rfc9728#section-3.1) + 3. Fall back to root-based well-known URI: /.well-known/oauth-protected-resource +""" +@router.get(f"/.well-known/oauth-protected-resource{'' if get_server_root_path() == '/' else get_server_root_path()}/{{mcp_server_name}}/mcp") @router.get("/.well-known/oauth-protected-resource") async def oauth_protected_resource_mcp( request: Request, mcp_server_name: Optional[str] = None @@ -403,8 +415,15 @@ async def oauth_protected_resource_mcp( ), # this is what Claude will call } - -@router.get("/.well-known/oauth-authorization-server/{mcp_server_name}") +""" + https://datatracker.ietf.org/doc/html/rfc8414#section-3.1 + RFC 8414: Path-aware OAuth discovery + If the issuer identifier value contains a path component, any + terminating "/" MUST be removed before inserting "/.well-known/" and + the well-known URI suffix between the host component and the path(include root path) + component. +""" +@router.get(f"/.well-known/oauth-authorization-server{'' if get_server_root_path() == '/' else get_server_root_path()}/{{mcp_server_name}}") @router.get("/.well-known/oauth-authorization-server") async def oauth_authorization_server_mcp( request: Request, mcp_server_name: Optional[str] = None From 3a2ab6b0d12be8863d2a7604a1f3e8ab5721b521 Mon Sep 17 00:00:00 2001 From: Eric84626 Date: Sat, 20 Dec 2025 11:55:12 +0800 Subject: [PATCH 007/530] fix: added additional grant type into oauth_authorization_server response for fixing mcp auth register bad request issue --- .../proxy/_experimental/mcp_server/discoverable_endpoints.py | 2 +- .../_experimental/mcp_server/test_discoverable_endpoints.py | 3 ++- ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index ffa17a5b7c4..5433196dfe3 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -428,7 +428,7 @@ async def oauth_authorization_server_mcp( "authorization_endpoint": authorization_endpoint, "token_endpoint": token_endpoint, "response_types_supported": ["code"], - "grant_types_supported": ["authorization_code"], + "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["client_secret_post"], # Claude expects a registration endpoint, even if we just fake it diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 6df9abd3fee..30f3d55f028 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -354,7 +354,7 @@ async def test_register_client_remote_registration_success(): request_payload = { "client_name": "Litellm Proxy", - "grant_types": ["authorization_code"], + "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "client_secret_post", } @@ -603,6 +603,7 @@ async def test_oauth_authorization_server_respects_x_forwarded_proto(): assert response["authorization_endpoint"].startswith("https://litellm.example.com/") assert response["token_endpoint"].startswith("https://litellm.example.com/") assert response["registration_endpoint"].startswith("https://litellm.example.com/") + assert response["grant_types_supported"] == ["authorization_code", "refresh_token"] @pytest.mark.asyncio diff --git a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx index 9600c962564..a62d8baa75b 100644 --- a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx +++ b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx @@ -136,7 +136,7 @@ export const useMcpOAuthFlow = ({ if (!hasPreconfiguredCredentials) { const registration = await registerMcpOAuthClient(accessToken, serverId, { client_name: temporaryPayload.alias || temporaryPayload.server_name || serverId, - grant_types: ["authorization_code"], + grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: temporaryPayload.credentials && temporaryPayload.credentials.client_secret ? "client_secret_post" : "none", From 684fba42eaaf6a4d47795e56fd668b8d46b01525 Mon Sep 17 00:00:00 2001 From: Eric84626 Date: Sat, 20 Dec 2025 13:22:29 +0800 Subject: [PATCH 008/530] fix: added RFC RECOMMENDED property(scopes_supported) to protected resource and authorization server metadata --- .../mcp_server/discoverable_endpoints.py | 17 +++++- .../mcp_server/test_discoverable_endpoints.py | 54 ++++++++++++++++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index d6fe3f2b9cf..ded591a8f53 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -398,8 +398,14 @@ async def callback(code: str, state: str): async def oauth_protected_resource_mcp( request: Request, mcp_server_name: Optional[str] = None ): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) # Get the correct base URL considering X-Forwarded-* headers request_base_url = get_request_base_url(request) + mcp_server: Optional[MCPServer] = None + if mcp_server_name: + mcp_server = global_mcp_server_manager.get_mcp_server_by_name(mcp_server_name) return { "authorization_servers": [ ( @@ -413,6 +419,7 @@ async def oauth_protected_resource_mcp( if mcp_server_name else f"{request_base_url}/mcp" ), # this is what Claude will call + "scopes_supported": mcp_server.scopes if mcp_server else [], } """ @@ -428,6 +435,9 @@ async def oauth_protected_resource_mcp( async def oauth_authorization_server_mcp( request: Request, mcp_server_name: Optional[str] = None ): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) # Get the correct base URL considering X-Forwarded-* headers request_base_url = get_request_base_url(request) @@ -442,16 +452,21 @@ async def oauth_authorization_server_mcp( else f"{request_base_url}/token" ) + mcp_server: Optional[MCPServer] = None + if mcp_server_name: + mcp_server = global_mcp_server_manager.get_mcp_server_by_name(mcp_server_name) + return { "issuer": request_base_url, # point to your proxy "authorization_endpoint": authorization_endpoint, "token_endpoint": token_endpoint, "response_types_supported": ["code"], + "scopes_supported": mcp_server.scopes if mcp_server else [], "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["client_secret_post"], # Claude expects a registration endpoint, even if we just fake it - "registration_endpoint": f"{request_base_url}/{mcp_server_name}/register", + "registration_endpoint": f"{request_base_url}/{mcp_server_name}/register" if mcp_server_name else f"{request_base_url}/register", } diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 30f3d55f028..4c5723b8284 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -556,9 +556,33 @@ async def test_oauth_protected_resource_respects_x_forwarded_proto(): from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( oauth_protected_resource_mcp, ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + from litellm.proxy._types import MCPTransport from fastapi import Request except ImportError: pytest.skip("MCP discoverable endpoints not available") + # Clear registry + global_mcp_server_manager.registry.clear() + + # Create mock OAuth2 server + oauth2_server = MCPServer( + server_id="test_oauth_server", + name="test_oauth", + server_name="test_oauth", + alias="test_oauth", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id="test_client_id", + client_secret="test_client_secret", + authorization_url="https://provider.com/oauth/authorize", + token_url="https://provider.com/oauth/token", + scopes=["read", "write"], + ) + global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server # Mock request with http base_url but X-Forwarded-Proto: https mock_request = MagicMock(spec=Request) @@ -568,13 +592,14 @@ async def test_oauth_protected_resource_respects_x_forwarded_proto(): # Call the endpoint response = await oauth_protected_resource_mcp( request=mock_request, - mcp_server_name="test_server", + mcp_server_name="test_oauth", ) # Verify response uses HTTPS URLs assert response["authorization_servers"][0].startswith( "https://litellm.example.com/" ) + assert response["scopes_supported"] == oauth2_server.scopes @pytest.mark.asyncio @@ -584,9 +609,33 @@ async def test_oauth_authorization_server_respects_x_forwarded_proto(): from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( oauth_authorization_server_mcp, ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + from litellm.proxy._types import MCPTransport from fastapi import Request except ImportError: pytest.skip("MCP discoverable endpoints not available") + # Clear registry + global_mcp_server_manager.registry.clear() + + # Create mock OAuth2 server + oauth2_server = MCPServer( + server_id="test_oauth_server", + name="test_oauth", + server_name="test_oauth", + alias="test_oauth", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id="test_client_id", + client_secret="test_client_secret", + authorization_url="https://provider.com/oauth/authorize", + token_url="https://provider.com/oauth/token", + scopes=["read", "write"], + ) + global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server # Mock request with http base_url but X-Forwarded-Proto: https mock_request = MagicMock(spec=Request) @@ -596,7 +645,7 @@ async def test_oauth_authorization_server_respects_x_forwarded_proto(): # Call the endpoint response = await oauth_authorization_server_mcp( request=mock_request, - mcp_server_name="test_server", + mcp_server_name="test_oauth", ) # Verify response uses HTTPS URLs @@ -604,6 +653,7 @@ async def test_oauth_authorization_server_respects_x_forwarded_proto(): assert response["token_endpoint"].startswith("https://litellm.example.com/") assert response["registration_endpoint"].startswith("https://litellm.example.com/") assert response["grant_types_supported"] == ["authorization_code", "refresh_token"] + assert response["scopes_supported"] == oauth2_server.scopes @pytest.mark.asyncio From 0306f02e74d7fff462fb727639a60e5ae11d64e4 Mon Sep 17 00:00:00 2001 From: Eric84626 Date: Sat, 20 Dec 2025 14:13:18 +0800 Subject: [PATCH 009/530] fix: removed initialize the tool name to MCP server name mapping(oauth2) on startup for avoiding 401 error --- .../mcp_server/mcp_server_manager.py | 3 +++ .../mcp_server/test_mcp_server_manager.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 8c9d8630457..c2215efe9d0 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -1913,6 +1913,9 @@ class MCPServerManager: Note: This now handles prefixed tool names """ for server in self.get_registry().values(): + if server.auth_type == MCPAuth.oauth2: + # Skip OAuth2 servers for now as they may require user-specific tokens + continue tools = await self._get_tools_from_server(server) for tool in tools: # The tool.name here is already prefixed from _get_tools_from_server diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 7a6e5ad17f6..c0ded9c728c 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -536,7 +536,26 @@ class TestMCPServerManager: assert ( server.registration_url == "https://discovered.example.com/register" ) + @pytest.mark.asyncio + async def test_config_oauth_initialize_tool_name_to_mcp_server_name_mapping(self): + manager = MCPServerManager() + config = { + "example": { + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + "auth_type": MCPAuth.oauth2, + "scopes": ["config"], + "authorization_url": "https://config.example.com/auth", + } + } + + await manager.load_servers_from_config(config) + + # Initialize the tool mapping + await manager._initialize_tool_name_to_mcp_server_name_mapping() + assert manager.tool_name_to_mcp_server_name_mapping == {} + @pytest.mark.asyncio async def test_list_tools_handles_missing_server_alias(self): """Test that list_tools handles servers without alias gracefully""" From 36a369a747fccedb87e12cf26996aebfc221f57e Mon Sep 17 00:00:00 2001 From: Eric84626 Date: Sat, 20 Dec 2025 14:28:52 +0800 Subject: [PATCH 010/530] fix: upgraded mcp sdk depency version for fixing ClosedResourceError --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f222acc46e6..cb12a658814 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ google-cloud-aiplatform==1.47.0 # for vertex ai calls google-cloud-iam==2.19.1 # for GCP IAM Redis authentication google-genai==1.22.0 anthropic[vertex]==0.54.0 -mcp==1.21.2 ; python_version >= "3.10" # for MCP server +mcp==1.25.0 ; python_version >= "3.10" # for MCP server google-generativeai==0.5.0 # for vertex ai calls async_generator==1.10.0 # for async ollama calls langfuse==2.59.7 # for langfuse self-hosted logging From b84aafbab6c55881718746bfdd9e6488562f73a0 Mon Sep 17 00:00:00 2001 From: Ephrim Stanley Date: Tue, 23 Dec 2025 08:31:21 -0500 Subject: [PATCH 011/530] Add end to end integration tests for batches --- BATCH_FIXES_README.md | 365 +++++++++++ .../proxy/hooks/managed_files.py | 41 +- litellm/proxy/_types.py | 2 +- litellm/proxy/batches_endpoints/endpoints.py | 1 + .../openai_files_endpoints/files_endpoints.py | 1 + tests/batches_tests/local-litellm/README.md | 15 + .../local-litellm/docker-compose.dev.yml | 38 ++ .../local-litellm/docker-compose.yml | 109 ++++ .../local-litellm/litellm-config.yaml | 57 ++ .../local-litellm/mock-server/Dockerfile | 18 + .../local-litellm/mock-server/main.py | 47 ++ .../mock-server/mock_azure_batch.py | 582 ++++++++++++++++++ .../local-litellm/mock-server/mock_chat.py | 124 ++++ .../mock-server/mock_embeddings.py | 26 + .../mock-server/mock_responses.py | 92 +++ .../local-litellm/mock-server/pyproject.toml | 20 + .../local-litellm/mock-server/uv.lock | 416 +++++++++++++ .../local-litellm/patches/README.md | 231 +++++++ .../batches_tests/test_managed_files_base.py | 220 +++++++ .../test_managed_files_endtoend.py | 204 ++++++ .../test_managed_files_permissions.py | 436 +++++++++++++ 21 files changed, 3041 insertions(+), 4 deletions(-) create mode 100644 BATCH_FIXES_README.md create mode 100644 tests/batches_tests/local-litellm/README.md create mode 100644 tests/batches_tests/local-litellm/docker-compose.dev.yml create mode 100644 tests/batches_tests/local-litellm/docker-compose.yml create mode 100644 tests/batches_tests/local-litellm/litellm-config.yaml create mode 100644 tests/batches_tests/local-litellm/mock-server/Dockerfile create mode 100644 tests/batches_tests/local-litellm/mock-server/main.py create mode 100644 tests/batches_tests/local-litellm/mock-server/mock_azure_batch.py create mode 100644 tests/batches_tests/local-litellm/mock-server/mock_chat.py create mode 100644 tests/batches_tests/local-litellm/mock-server/mock_embeddings.py create mode 100644 tests/batches_tests/local-litellm/mock-server/mock_responses.py create mode 100644 tests/batches_tests/local-litellm/mock-server/pyproject.toml create mode 100644 tests/batches_tests/local-litellm/mock-server/uv.lock create mode 100644 tests/batches_tests/local-litellm/patches/README.md create mode 100644 tests/batches_tests/test_managed_files_base.py create mode 100644 tests/batches_tests/test_managed_files_endtoend.py create mode 100644 tests/batches_tests/test_managed_files_permissions.py diff --git a/BATCH_FIXES_README.md b/BATCH_FIXES_README.md new file mode 100644 index 00000000000..205ab2d29a1 --- /dev/null +++ b/BATCH_FIXES_README.md @@ -0,0 +1,365 @@ +# LiteLLM Batch API Fixes + +This document describes bugs found in LiteLLM's managed batch/files functionality and the patches applied to fix them. It also provides step-by-step instructions to reproduce the tests from a clean slate. + +## Table of Contents + +1. [Bug 1: File Deletion Fails for Batch Output Files](#bug-1-file-deletion-fails-for-batch-output-files) +2. [Bug 2: File Deletion Returns Wrong Response](#bug-2-file-deletion-returns-wrong-response) +3. [Bug 3: Batch Listing Fails with Duplicate Argument](#bug-3-batch-listing-fails-with-duplicate-argument) +4. [Bug 4: File Retrieve Returns None for Batch Output Files](#bug-4-file-retrieve-returns-none-for-batch-output-files) +5. [Mock Server: Azure-like Credential Validation](#mock-server-azure-like-credential-validation) +6. [Test Setup Instructions](#test-setup-instructions) + +--- + +## Bug 1: File Deletion Fails for Batch Output Files + +### Description + +**Broken Feature:** `DELETE /files/{file_id}` - Deleting batch output files fails with a Pydantic validation error. + +**Error Message:** +``` +openai.InternalServerError: Error code: 500 - { + 'error': { + 'message': '1 validation error for LiteLLM_ManagedFileTable\nfile_object\n Input should be a valid dictionary or instance of OpenAIFileObject [type=model_type, input_value=None, input_type=NoneType]' + } +} +``` + +**Root Cause:** When LiteLLM stores batch output files in `LiteLLM_ManagedFileTable`, it sets `file_object=None`. However, the Pydantic model requires this field to be a valid `OpenAIFileObject`. + +### Patch + +**File:** `litellm/proxy/_types.py`, line ~3759 + +```python +# Before +class LiteLLM_ManagedFileTable(LiteLLMPydanticObjectBase): + file_object: OpenAIFileObject + +# After +class LiteLLM_ManagedFileTable(LiteLLMPydanticObjectBase): + file_object: Optional[OpenAIFileObject] = None # PATCHED +``` + +--- + +## Bug 2: File Deletion Returns Wrong Response + +### Description + +**Broken Feature:** `DELETE /files/{file_id}` - Even after fixing Bug #1, the method returns `None` instead of the delete confirmation. + +**Error Message:** +``` +Exception: LiteLLM Managed File object with id=... not found +``` + +**Root Cause:** `afile_delete` in `managed_files.py` calls `llm_router.afile_delete()` (which deletes the file at the provider) but discards the response. + +### Patch + +**File:** `enterprise/litellm_enterprise/proxy/hooks/managed_files.py`, line ~879 + +```python +# Before +async def afile_delete(self, file_id, ...): + for model_id, model_file_id in mapping.items(): + await llm_router.afile_delete(model=model_id, file_id=model_file_id, **data) + # Returns None when stored_file_object is None + +# After +async def afile_delete(self, file_id, ...): + delete_response = None # PATCHED: Capture response + for model_id, model_file_id in mapping.items(): + delete_response = await llm_router.afile_delete(model=model_id, file_id=model_file_id, **data) + + stored_file_object = await self.delete_unified_file_id(file_id, ...) + if stored_file_object: + return stored_file_object + elif delete_response: # PATCHED: Return provider response + delete_response.id = file_id # Replace with unified ID + return delete_response + else: + raise Exception(...) +``` + +--- + +## Bug 3: Batch Listing Fails with Duplicate Argument + +### Description + +**Broken Feature:** `GET /batches?target_model_names=...` - Listing batches fails when using `target_model_names` query parameter. + +**Error Message:** +``` +openai.InternalServerError: Error code: 500 - { + 'error': { + 'message': "alist_batches() got multiple values for keyword argument 'model'" + } +} +``` + +**Root Cause:** The code passes `model` explicitly AND includes it in `**data`: +```python +model = target_model_names.split(",")[0] +response = await llm_router.alist_batches( + model=model, # Passed explicitly + **data, # Also contains 'model' and 'target_model_names' keys +) +``` + +### Patch + +**File:** `litellm/proxy/batches_endpoints/endpoints.py`, line ~576-577 + +```python +# Before +model = target_model_names.split(",")[0] +response = await llm_router.alist_batches(model=model, **data) + +# After +model = target_model_names.split(",")[0] +data.pop("model", None) # PATCHED: Remove duplicate +data.pop("target_model_names", None) # PATCHED: Remove to avoid passing to downstream +response = await llm_router.alist_batches(model=model, **data) +``` + +--- + +## Bug 4: File Retrieve Returns None for Batch Output Files + +### Description + +**Broken Feature:** `GET /files/{file_id}` - Retrieving batch output file metadata returns `None`. + +**Error Message:** +``` +AttributeError: 'NoneType' object has no attribute 'id' +``` + +**Root Cause:** `afile_retrieve` returns `stored_file_object.file_object` which is `None` for batch output files. It should fetch the file metadata from the provider instead. + +### Patch (Part A) + +**File:** `enterprise/litellm_enterprise/proxy/hooks/managed_files.py`, line ~839-868 + +Add `import litellm` at the top of the file, then modify `afile_retrieve`: + +```python +# Before +async def afile_retrieve(self, file_id, litellm_parent_otel_span): + stored = await self.get_unified_file_id(file_id, ...) + return stored.file_object # Returns None for batch output files! + +# After +import litellm # Added at top of file + +async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router=None): # PATCHED: Added llm_router + stored = await self.get_unified_file_id(file_id, ...) + if stored: + if stored.file_object: + return stored.file_object + # PATCHED: Fetch from provider when file_object is None + elif stored.model_mappings and llm_router: + for model_id, model_file_id in stored.model_mappings.items(): + deployment = llm_router.get_deployment(model_id=model_id) + if deployment: + credentials = llm_router.get_deployment_credentials(model_id=model_id) or {} + # Extract custom_llm_provider - afile_retrieve needs it as explicit param + custom_llm_provider = credentials.pop("custom_llm_provider", None) + if not custom_llm_provider: + # Infer from model name (e.g., "azure/gpt-5" -> "azure") + model_name = deployment.litellm_params.model or "" + if "/" in model_name: + custom_llm_provider = model_name.split("/")[0] + else: + custom_llm_provider = "openai" + response = await litellm.afile_retrieve( + file_id=model_file_id, + custom_llm_provider=custom_llm_provider, # Explicit param for Azure + **credentials + ) + response.id = file_id # Replace with unified ID + return response +``` + +### Patch (Part B) + +**File:** `litellm/proxy/openai_files_endpoints/files_endpoints.py`, line ~888 + +```python +# Before +response = await managed_files_obj.afile_retrieve( + file_id=file_id, + litellm_parent_otel_span=user_api_key_dict.parent_otel_span, +) + +# After +response = await managed_files_obj.afile_retrieve( + file_id=file_id, + litellm_parent_otel_span=user_api_key_dict.parent_otel_span, + llm_router=llm_router, # PATCHED: Pass router to fetch from provider +) +``` + +--- + +## Test Setup Instructions + +### Prerequisites + +- Python 3.11+ +- Docker and Docker Compose +- Poetry (Python package manager) + +### Step 1: Clone and Setup Environment + +```bash +# Install dependencies +poetry install --extras "proxy extra_proxy" + +# Install enterprise package in editable mode (required for patches to work) +poetry run pip install -e enterprise +``` + +### Step 2: Terminal 1 - Start Database and Mock Server + +```bash +cd tests/batches_tests/local-litellm + +# Build and start PostgreSQL and Mock Azure Server +docker compose -f docker-compose.dev.yml up --build +``` + +Wait until you see both services are healthy: +- `litellm_dev_db` - PostgreSQL database +- `mock-server` - Mock Azure OpenAI server (with credential validation enabled by default) + +**Note:** The mock server now validates credentials like real Azure. Use `--build` to ensure you have the latest mock server with credential validation. + +### Step 3: Terminal 2 - Start LiteLLM Proxy + +```bash +cd /path/to/litellm + +# Set environment variables +export DATABASE_URL="postgresql://llmproxy:dbpassword9090@localhost:5432/litellm" +export LITELLM_MASTER_KEY="sk-1234" +export LITELLM_SALT_KEY="mock-salt-key-12345" + +# For real Azure testing (optional): +# export OPENAI_API_KEY="your-azure-api-key" +# export OPENAI_API_BASE=https://your azure endpoint" + +# Generate Prisma client (first time only) +poetry run python -m prisma generate + +# Start the proxy server +poetry run litellm --config tests/batches_tests/local-litellm/litellm-config.yaml --detailed_debug --port 4000 +``` + +Wait until you see: +``` +INFO: Uvicorn running on http://0.0.0.0:4000 +``` + +### Step 4: Terminal 3 - Run Tests + +```bash +cd /path/to/litellm + +# Run the end-to-end managed files test with mock server +USE_MOCK_SERVER=true poetry run pytest tests/batches_tests/test_managed_files_endtoend.py -s -vvv +``` + +### Expected Output + +The test should pass with output similar to: + +``` +tests/batches_tests/test_managed_files_endtoend.py::TestManagedFilesAPI::test_e2e_managed_batch[gpt] +Creating batch input file... +Created batch input file: bGl0ZWxs... + +Creating batch... +Created batch: bGl0ZWxs... + +Waiting for batch to reach completed state... +Batch status: completed + +Retrieving batch output file metadata... +Output file metadata: ... + +Fetching batch output file content... +Output file content: ... + +Deleting input file... +Deleting output file... + +PASSED +``` + +--- + +## Configuration Files + +### `tests/batches_tests/local-litellm/litellm-config-local.yaml` + +This config file sets up models for local testing: +- Mock OpenAI models pointing to `http://localhost:8090` +- Mock Azure batch model pointing to `http://localhost:8090` +- (Optional) Real Azure batch model with API key from environment + +### `tests/batches_tests/local-litellm/docker-compose.dev.yml` + +Docker Compose file that runs: +- PostgreSQL 16 database on port 5432 +- Mock Azure OpenAI server on port 8090 + +--- + +## Troubleshooting + +### "No module named prisma" + +```bash +poetry run pip install prisma==0.11.0 +poetry run python -m prisma generate +``` + +### Database connection error + +Ensure PostgreSQL is running and the DATABASE_URL is correct: +```bash +docker ps | grep postgres +# Should show litellm_dev_db running on port 5432 +``` + +### Patches not being picked up/ + +1. Clear Python cache: + ```bash + find enterprise -name "__pycache__" -type d -exec rm -rf {} + + find litellm -name "__pycache__" -type d -exec rm -rf {} + + ``` + +2. Verify editable install: + ```bash + poetry run pip show litellm-enterprise | grep "Editable" + # Should show: Editable project location: /path/to/litellm/enterprise + ``` + +3. Restart the proxy server + +### Azure credentials error when testing with real Azure + +Set the environment variable before starting the proxy: +```bash +export OPENAI_API_KEY="your-actual-azure-api-key" +``` + +--- diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index a83d7e224b5..1afaee30c74 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union, cas from fastapi import HTTPException +import litellm from litellm import Router, verbose_logger from litellm._uuid import uuid from litellm.caching.caching import DualCache @@ -836,13 +837,41 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): return response async def afile_retrieve( - self, file_id: str, litellm_parent_otel_span: Optional[Span] + self, file_id: str, litellm_parent_otel_span: Optional[Span], llm_router=None ) -> OpenAIFileObject: stored_file_object = await self.get_unified_file_id( file_id, litellm_parent_otel_span ) if stored_file_object: - return stored_file_object.file_object + # PATCHED: If file_object is None (batch output files), fetch from provider + if stored_file_object.file_object: + return stored_file_object.file_object + elif stored_file_object.model_mappings and llm_router: + for model_id, model_file_id in stored_file_object.model_mappings.items(): + # PATCHED: Get deployment info and credentials from router + deployment = llm_router.get_deployment(model_id=model_id) + if deployment: + credentials = llm_router.get_deployment_credentials(model_id=model_id) or {} + # Extract custom_llm_provider - afile_retrieve needs it as explicit param + custom_llm_provider = credentials.pop("custom_llm_provider", None) + if not custom_llm_provider: + # Infer from model name (e.g., "azure/gpt-5" -> "azure") + model_name = deployment.litellm_params.model or "" + if "/" in model_name: + custom_llm_provider = model_name.split("/")[0] + else: + custom_llm_provider = "openai" + response = await litellm.afile_retrieve( + file_id=model_file_id, + custom_llm_provider=custom_llm_provider, + **credentials + ) + response.id = file_id # Replace with unified ID + return response + else: + raise Exception(f"No deployment found for model_id={model_id}") + else: + raise Exception(f"LiteLLM Managed File object with id={file_id} has no file_object, or no model_mappings/llm_router to fetch from provider") else: raise Exception(f"LiteLLM Managed File object with id={file_id} not found") @@ -868,10 +897,12 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): [file_id], litellm_parent_otel_span ) + # PATCHED: Capture delete response from provider + delete_response = None specific_model_file_id_mapping = model_file_id_mapping.get(file_id) if specific_model_file_id_mapping: for model_id, model_file_id in specific_model_file_id_mapping.items(): - await llm_router.afile_delete(model=model_id, file_id=model_file_id, **data) # type: ignore + delete_response = await llm_router.afile_delete(model=model_id, file_id=model_file_id, **data) # type: ignore stored_file_object = await self.delete_unified_file_id( file_id, litellm_parent_otel_span @@ -879,6 +910,10 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): if stored_file_object: return stored_file_object + # PATCHED: Return provider response with unified ID when stored_file_object is None + elif delete_response: + delete_response.id = file_id + return delete_response else: raise Exception(f"LiteLLM Managed File object with id={file_id} not found") diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 06067035c18..9767e07bbc8 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3756,7 +3756,7 @@ class SpendUpdateQueueItem(TypedDict, total=False): class LiteLLM_ManagedFileTable(LiteLLMPydanticObjectBase): unified_file_id: str - file_object: OpenAIFileObject + file_object: Optional[OpenAIFileObject] = None # PATCHED: Allow None for batch output files model_mappings: Dict[str, str] flat_model_file_ids: List[str] created_by: Optional[str] diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index 086105042e8..dd68a54f694 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -574,6 +574,7 @@ async def list_batches( raise ValueError("target_model_names is required for this routing scenario") model = target_model_names.split(",")[0] data.pop("model", None) + data.pop("target_model_names", None) # PATCHED: Remove to avoid passing to downstream response = await llm_router.alist_batches( model=model, after=after, diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 810f5c62720..586f7840835 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -885,6 +885,7 @@ async def get_file( response = await managed_files_obj.afile_retrieve( file_id=file_id, litellm_parent_otel_span=user_api_key_dict.parent_otel_span, + llm_router=llm_router, # PATCHED: Pass router to fetch from provider if file_object is None ) else: response = await litellm.afile_retrieve( diff --git a/tests/batches_tests/local-litellm/README.md b/tests/batches_tests/local-litellm/README.md new file mode 100644 index 00000000000..b4f5e38fdec --- /dev/null +++ b/tests/batches_tests/local-litellm/README.md @@ -0,0 +1,15 @@ +# Local LiteLLM + +Local LiteLLM proxy with a mock LLM server for testing. + +## Start + +```bash +docker compose up --build +``` + +## Stop + +```bash +docker compose down +``` diff --git a/tests/batches_tests/local-litellm/docker-compose.dev.yml b/tests/batches_tests/local-litellm/docker-compose.dev.yml new file mode 100644 index 00000000000..035b65648e0 --- /dev/null +++ b/tests/batches_tests/local-litellm/docker-compose.dev.yml @@ -0,0 +1,38 @@ +# Docker Compose for local development +# Runs db and mock-server only - proxy runs locally via poetry + +services: + db: + image: postgres:16 + container_name: litellm_dev_db + restart: always + environment: + POSTGRES_DB: litellm + POSTGRES_USER: llmproxy + POSTGRES_PASSWORD: dbpassword9090 + healthcheck: + test: ["CMD-SHELL", "pg_isready -d litellm -U llmproxy"] + interval: 1s + timeout: 5s + retries: 10 + volumes: + - postgres_data_dev:/var/lib/postgresql/data + ports: + - "5432:5432" + + mock-server: + build: + context: ./mock-server + dockerfile: Dockerfile + ports: + - "8090:8090" + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 http://localhost:8090/health || exit 1"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + postgres_data_dev: + diff --git a/tests/batches_tests/local-litellm/docker-compose.yml b/tests/batches_tests/local-litellm/docker-compose.yml new file mode 100644 index 00000000000..914d659ac81 --- /dev/null +++ b/tests/batches_tests/local-litellm/docker-compose.yml @@ -0,0 +1,109 @@ +services: + # Default LiteLLM without patches + litellm: + image: ghcr.io/berriai/litellm:main-latest + profiles: ["default", "unpatched"] + ports: + - "4000:4000" + volumes: + - ./litellm-config.yaml:/app/config.yaml + command: + - "--config=/app/config.yaml" + - "--detailed_debug" + environment: + DATABASE_URL: "postgresql://llmproxy:dbpassword9090@db:5432/litellm" + STORE_MODEL_IN_DB: "True" + LITELLM_MASTER_KEY: "sk-1234" + LITELLM_SALT_KEY: "mock-salt-key-12345" + LITELLM_LOG: "DEBUG" + real_azure_api_key: "${real_azure_api_key:-}" + depends_on: + db: + condition: service_healthy + mock-server: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 http://localhost:4000/health/liveliness || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # LiteLLM with patches enabled + litellm-patched: + image: ghcr.io/berriai/litellm:main-latest + profiles: ["patched"] + ports: + - "4000:4000" + volumes: + - ./litellm-config.yaml:/app/config.yaml + # patch1 + - ./patches/managed_files.py:/usr/lib/python3.13/site-packages/litellm_enterprise/proxy/hooks/managed_files.py + - ./patches/managed_files.py:/app/enterprise/litellm_enterprise/proxy/hooks/managed_files.py + # patch2 + - ./patches/_types.py:/usr/lib/python3.13/site-packages/litellm/proxy/_types.py + - ./patches/_types.py:/app/litellm/proxy/_types.py + # patch3 + - ./patches/batches_endpoints.py:/usr/lib/python3.13/site-packages/litellm/proxy/batches_endpoints/endpoints.py + - ./patches/batches_endpoints.py:/app/litellm/proxy/batches_endpoints/endpoints.py + # patch4 + - ./patches/files_endpoints.py:/usr/lib/python3.13/site-packages/litellm/proxy/openai_files_endpoints/files_endpoints.py + - ./patches/files_endpoints.py:/app/litellm/proxy/openai_files_endpoints/files_endpoints.py + command: + - "--config=/app/config.yaml" + - "--detailed_debug" + environment: + DATABASE_URL: "postgresql://llmproxy:dbpassword9090@db:5432/litellm" + STORE_MODEL_IN_DB: "True" + LITELLM_MASTER_KEY: "sk-1234" + LITELLM_SALT_KEY: "mock-salt-key-12345" + LITELLM_LOG: "DEBUG" + real_azure_api_key: "${real_azure_api_key:-}" + depends_on: + db: + condition: service_healthy + mock-server: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 http://localhost:4000/health/liveliness || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + db: + image: postgres:16 + restart: always + container_name: litellm_local_db + environment: + POSTGRES_DB: litellm + POSTGRES_USER: llmproxy + POSTGRES_PASSWORD: dbpassword9090 + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -d litellm -U llmproxy"] + interval: 1s + timeout: 5s + retries: 10 + + mock-server: + build: + context: ./mock-server + dockerfile: Dockerfile + ports: + - "8090:8090" + environment: + TIME_TO_SLEEP: "0" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8090/health || exit 1"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s + +volumes: + postgres_data: + name: litellm_local_postgres_data diff --git a/tests/batches_tests/local-litellm/litellm-config.yaml b/tests/batches_tests/local-litellm/litellm-config.yaml new file mode 100644 index 00000000000..28721f1081c --- /dev/null +++ b/tests/batches_tests/local-litellm/litellm-config.yaml @@ -0,0 +1,57 @@ +model_list: + - model_name: openai-fake-gpt-3.5-turbo + litellm_params: + model: openai/openai-fake-gpt-3.5-turbo + api_base: http://localhost:8090/v1 + api_key: fake-key + - model_name: openai-fake-gpt-4 + litellm_params: + model: openai/openai-fake-gpt-4 + api_base: http://localhost:8090/v1 + api_key: fake-key + - model_name: openai-fake-gpt-4o + litellm_params: + model: openai/openai-fake-gpt-4o + api_base: http://localhost:8090/v1 + api_key: fake-key + - model_name: fake-text-embedding-3-small + litellm_params: + model: openai/fake-text-embedding-3-small + api_base: http://localhost:8090/v1 + api_key: fake-key + - model_name: o3-mini-batch-2025-01-31 + litellm_params: + model: openai/o3-mini-batch-2025-01-31 + api_base: http://localhost:8090/openai/v1 + api_key: fake-key + model_info: + mode: batch + - model_name: azure-fake-gpt-5-batch-2025-08-07 + litellm_params: + api_base: http://localhost:8090 + api_key: fake-key + api_version: 2025-03-01-preview + base_model: azure/gpt-5 + model: azure/gpt-5-batch-2025-08-07 + custom_llm_provider: azure + model_info: + mode: batch + - model_name: gpt-5-batch-2025-08-07 + litellm_params: + api_base: os.environ/OPENAI_API_BASE + api_key: os.environ/OPENAI_API_KEY + api_version: 2025-03-01-preview + base_model: azure/gpt-5 + model: azure/gpt-5-batch-2025-08-07 + custom_llm_provider: azure + model_info: + mode: batch + +general_settings: + master_key: sk-1234 + database_url: "postgresql://llmproxy:dbpassword9090@localhost:5432/litellm" + +litellm_settings: + drop_params: true + set_verbose: true + json_logs: true diff --git a/tests/batches_tests/local-litellm/mock-server/Dockerfile b/tests/batches_tests/local-litellm/mock-server/Dockerfile new file mode 100644 index 00000000000..dd31250349c --- /dev/null +++ b/tests/batches_tests/local-litellm/mock-server/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY pyproject.toml . +COPY main.py . +COPY mock_azure_batch.py . +COPY mock_chat.py . +COPY mock_responses.py . +COPY mock_embeddings.py . + +RUN pip install --no-cache-dir $(python -c "import tomllib; print(' '.join(tomllib.load(open('pyproject.toml', 'rb'))['project']['dependencies']))") + +EXPOSE 8090 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8090"] diff --git a/tests/batches_tests/local-litellm/mock-server/main.py b/tests/batches_tests/local-litellm/mock-server/main.py new file mode 100644 index 00000000000..943ab36681f --- /dev/null +++ b/tests/batches_tests/local-litellm/mock-server/main.py @@ -0,0 +1,47 @@ +from dotenv import load_dotenv +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded + +from mock_azure_batch import setup_batch_routes +from mock_chat import setup_chat_routes +from mock_embeddings import setup_embeddings_routes +from mock_responses import setup_responses_routes + + +def get_request_url(request: Request): + return str(request.url) + + +limiter = Limiter(key_func=get_request_url) +load_dotenv() + +app = FastAPI() +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +setup_chat_routes(app) +setup_responses_routes(app) +setup_embeddings_routes(app) +setup_batch_routes(app) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8090) diff --git a/tests/batches_tests/local-litellm/mock-server/mock_azure_batch.py b/tests/batches_tests/local-litellm/mock-server/mock_azure_batch.py new file mode 100644 index 00000000000..3512c8472d5 --- /dev/null +++ b/tests/batches_tests/local-litellm/mock-server/mock_azure_batch.py @@ -0,0 +1,582 @@ +import asyncio +import io +import json +import logging +import os +import time +import uuid +from typing import Dict, List, Optional + +from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, Depends +from fastapi.responses import StreamingResponse +from fastapi.security import APIKeyHeader +from pydantic import BaseModel + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Azure-like credential validation +# Set MOCK_REQUIRE_CREDENTIALS=true to enforce credential checks (like real Azure) +REQUIRE_CREDENTIALS = os.environ.get("MOCK_REQUIRE_CREDENTIALS", "true").lower() == "true" +VALID_API_KEYS = {"fake-key", "sk-1234", "test-key"} # Accept these API keys + +api_key_header = APIKeyHeader(name="api-key", auto_error=False) +auth_header = APIKeyHeader(name="Authorization", auto_error=False) + + +def validate_credentials( + api_key: Optional[str] = Depends(api_key_header), + authorization: Optional[str] = Depends(auth_header), +): + """ + Validate Azure-style credentials. + Azure accepts either: + - api-key header + - Authorization: Bearer header + """ + if not REQUIRE_CREDENTIALS: + return True + + # Check api-key header + if api_key: + if api_key in VALID_API_KEYS: + return True + logger.warning(f"Invalid api-key provided: {api_key[:8]}...") + raise HTTPException( + status_code=401, + detail={ + "error": { + "code": "401", + "message": "Access denied due to invalid subscription key or wrong API endpoint. " + "Make sure to provide a valid key for an active subscription and use a " + "correct regional API endpoint for your resource." + } + } + ) + + # Check Authorization header (Bearer token) + if authorization: + if authorization.startswith("Bearer "): + # Accept any bearer token for mock purposes + return True + logger.warning(f"Invalid Authorization header format") + + # No credentials provided + logger.warning("No credentials provided in request") + raise HTTPException( + status_code=401, + detail={ + "error": { + "code": "401", + "message": "Missing credentials. Please pass one of `api_key`, `azure_ad_token`, " + "`azure_ad_token_provider`, or the `AZURE_OPENAI_API_KEY` or " + "`AZURE_OPENAI_AD_TOKEN` environment variables." + } + } + ) + + +class FileObject(BaseModel): + id: str + object: str = "file" + bytes: int + created_at: int + filename: str + purpose: str + status: str = "processed" + status_details: Optional[str] = None + expires_at: Optional[int] = None + + +class BatchObject(BaseModel): + id: str + object: str = "batch" + endpoint: str + errors: Optional[Dict] = None + input_file_id: str + completion_window: str + status: str + output_file_id: Optional[str] = None + error_file_id: Optional[str] = None + created_at: int + in_progress_at: Optional[int] = None + expires_at: Optional[int] = None + finalizing_at: Optional[int] = None + completed_at: Optional[int] = None + failed_at: Optional[int] = None + expired_at: Optional[int] = None + cancelling_at: Optional[int] = None + cancelled_at: Optional[int] = None + request_counts: Optional[Dict[str, int]] = None + metadata: Optional[Dict] = None + + +class BatchListResponse(BaseModel): + object: str = "list" + data: List[Dict] + first_id: Optional[str] = None + last_id: Optional[str] = None + has_more: bool = False + + +file_storage: Dict[str, Dict] = {} +batch_storage: Dict[str, BatchObject] = {} +batch_results: Dict[str, List[Dict]] = {} + +PROCESSING_DELAY_SECONDS = float(1) +VALIDATING_DELAY_SECONDS = float(3) + + +async def process_batch(batch_id: str): + logger.info(f"Starting batch processing for {batch_id}") + try: + batch = batch_storage[batch_id] + + await asyncio.sleep(VALIDATING_DELAY_SECONDS) + batch.status = "in_progress" + batch.in_progress_at = int(time.time()) + logger.info(f"Batch {batch_id} status: in_progress") + + await process_batch_requests(batch_id) + await asyncio.sleep(PROCESSING_DELAY_SECONDS) + + batch.status = "finalizing" + batch.finalizing_at = int(time.time()) + logger.info(f"Batch {batch_id} status: finalizing") + await asyncio.sleep(PROCESSING_DELAY_SECONDS) + + await create_output_file(batch_id) + + batch.status = "completed" + batch.completed_at = int(time.time()) + logger.info(f"Batch {batch_id} status: completed") + + except Exception as e: + logger.error(f"Batch {batch_id} failed: {e}") + batch = batch_storage[batch_id] + batch.status = "failed" + batch.failed_at = int(time.time()) + batch.errors = { + "object": "list", + "data": [{"code": "processing_error", "message": str(e)}], + } + + +async def process_batch_requests(batch_id: str): + batch = batch_storage[batch_id] + input_file = file_storage[batch.input_file_id] + + requests = [] + for line in input_file["content"].split("\n"): + if line.strip(): + try: + requests.append(json.loads(line)) + except json.JSONDecodeError as e: + logger.warning(f"Invalid JSON line in batch {batch_id}: {e}") + + logger.info(f"Batch {batch_id} has {len(requests)} requests") + + results = [] + failed_count = 0 + for req in requests: + result = await process_single_request(req) + if result.get("error"): + failed_count += 1 + results.append(result) + + batch_results[batch_id] = results + batch.request_counts = { + "total": len(requests), + "completed": len(results) - failed_count, + "failed": failed_count, + } + + +async def process_single_request(request_data: Dict) -> Dict: + custom_id = request_data.get("custom_id") + url = request_data.get("url", "/v1/chat/completions") + body = request_data.get("body", {}) + + if "/chat/completions" in url: + response_body = { + "id": f"chatcmpl-{uuid.uuid4().hex}", + "object": "chat.completion", + "created": int(time.time()), + "model": body.get("model", "gpt-4o"), + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Mock batch response."}, + "finish_reason": "stop", + }, + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + status_code = 200 + else: + response_body = {"error": {"message": f"Unsupported endpoint: {url}"}} + status_code = 400 + + return { + "id": f"batch_req_{uuid.uuid4().hex[:12]}", + "custom_id": custom_id, + "response": { + "status_code": status_code, + "request_id": f"req_{uuid.uuid4().hex[:12]}", + "body": response_body, + }, + "error": None, + } + + +async def create_output_file(batch_id: str): + results = batch_results.get(batch_id, []) + output_lines = [json.dumps(result) for result in results] + output_content = "\n".join(output_lines) + + output_file_id = f"file-batch-output-{uuid.uuid4().hex[:12]}" + file_storage[output_file_id] = { + "content": output_content, + "filename": f"batch_output_{batch_id}.jsonl", + "purpose": "batch_output", + "bytes": len(output_content.encode()), + "created_at": int(time.time()), + } + + batch = batch_storage[batch_id] + batch.output_file_id = output_file_id + logger.info(f"Created output file {output_file_id} for batch {batch_id}") + + +def validate_batch_input(content: str) -> tuple[bool, str, List[Dict]]: + requests = [] + custom_ids = set() + + lines = content.strip().split("\n") + if not lines or all(not line.strip() for line in lines): + return False, "empty_batch", [] + + for line_num, line in enumerate(lines, 1): + if not line.strip(): + continue + try: + req = json.loads(line) + except json.JSONDecodeError: + return False, "invalid_json_line", [] + + for field in ["custom_id", "method", "url", "body"]: + if field not in req: + return False, "invalid_request", [] + + if req["custom_id"] in custom_ids: + return False, "duplicate_custom_id", [] + custom_ids.add(req["custom_id"]) + + requests.append(req) + + if len(requests) > 100000: + return False, "too_many_tasks", [] + + return True, "", requests + + +def setup_batch_routes(app: FastAPI): + # Files endpoints (OpenAI and Azure paths) + # All endpoints require credentials (like real Azure) + @app.post("/openai/v1/files") + @app.post("/openai/files") + @app.post("/v1/files") + @app.post("/files") + async def create_file(request: Request, _=Depends(validate_credentials)): + form = await request.form() + logger.info(f"File upload form fields: {list(form.keys())}") + + file: UploadFile = form.get("file") + purpose: str = form.get("purpose", "batch") + + if not file: + raise HTTPException(status_code=400, detail="No file provided") + + logger.info(f"Uploading file: {file.filename}, purpose: {purpose}") + + content = await file.read() + content_str = content.decode("utf-8") + + file_id = f"file-{uuid.uuid4().hex[:24]}" + created_at = int(time.time()) + + expires_at = None + expires_after_seconds = form.get("expires_after[seconds]") + if expires_after_seconds: + try: + seconds = int(expires_after_seconds) + logger.info(f"expires_after[seconds] = {seconds}") + if seconds < 259200 or seconds > 2592000: + raise HTTPException( + status_code=400, + detail={ + "error": { + "code": "invalidPayload", + "message": "Value for Seconds must be between 259200 and 2592000.", + }, + }, + ) + expires_at = created_at + seconds + logger.info(f"Calculated expires_at: {expires_at}") + except ValueError as e: + logger.warning(f"Failed to parse expires_after[seconds]: {e}") + + file_storage[file_id] = { + "content": content_str, + "filename": file.filename or "batch_input.jsonl", + "purpose": purpose, + "bytes": len(content), + "created_at": created_at, + "expires_at": expires_at, + } + + logger.info(f"Created file {file_id}, expires_at={expires_at}") + return FileObject( + id=file_id, + bytes=len(content), + created_at=created_at, + filename=file.filename or "batch_input.jsonl", + purpose=purpose, + expires_at=expires_at, + ).model_dump() + + @app.get("/openai/v1/files/{file_id}") + @app.get("/openai/files/{file_id}") + @app.get("/v1/files/{file_id}") + @app.get("/files/{file_id}") + async def get_file(file_id: str, _=Depends(validate_credentials)): + logger.info(f"Getting file: {file_id}") + if file_id not in file_storage: + raise HTTPException(status_code=404, detail="File not found") + + file_data = file_storage[file_id] + return FileObject( + id=file_id, + bytes=file_data["bytes"], + created_at=file_data["created_at"], + filename=file_data["filename"], + purpose=file_data["purpose"], + expires_at=file_data.get("expires_at"), + ).model_dump() + + @app.get("/openai/v1/files/{file_id}/content") + @app.get("/openai/files/{file_id}/content") + @app.get("/v1/files/{file_id}/content") + @app.get("/files/{file_id}/content") + async def get_file_content(file_id: str, _=Depends(validate_credentials)): + logger.info(f"Getting file content: {file_id}") + if file_id not in file_storage: + raise HTTPException(status_code=404, detail="File not found") + + file_data = file_storage[file_id] + content = file_data["content"] + + return StreamingResponse( + io.StringIO(content), + media_type="application/octet-stream", + headers={ + "Content-Disposition": f"attachment; filename={file_data['filename']}", + }, + ) + + @app.delete("/openai/v1/files/{file_id}") + @app.delete("/openai/files/{file_id}") + @app.delete("/v1/files/{file_id}") + @app.delete("/files/{file_id}") + async def delete_file(file_id: str, _=Depends(validate_credentials)): + logger.info(f"Deleting file: {file_id}") + if file_id not in file_storage: + raise HTTPException(status_code=404, detail="File not found") + + del file_storage[file_id] + return {"id": file_id, "object": "file", "deleted": True} + + @app.get("/openai/v1/files") + @app.get("/openai/files") + @app.get("/v1/files") + @app.get("/files") + async def list_files( + purpose: Optional[str] = None, + limit: int = Query(10000, le=10000), + _=Depends(validate_credentials), + ): + logger.info(f"Listing files, purpose: {purpose}, limit: {limit}") + files = [] + for file_id, file_data in file_storage.items(): + if purpose is None or file_data.get("purpose") == purpose: + files.append( + FileObject( + id=file_id, + bytes=file_data["bytes"], + created_at=file_data["created_at"], + filename=file_data["filename"], + purpose=file_data["purpose"], + expires_at=file_data.get("expires_at"), + ).model_dump(), + ) + return {"object": "list", "data": files[:limit]} + + # Batches endpoints (OpenAI and Azure paths) + @app.post("/openai/v1/batches") + @app.post("/openai/batches") + @app.post("/v1/batches") + @app.post("/batches") + async def create_batch(request_data: dict, _=Depends(validate_credentials)): + input_file_id = request_data.get("input_file_id") + endpoint = request_data.get("endpoint", "/v1/chat/completions") + completion_window = request_data.get("completion_window", "24h") + metadata = request_data.get("metadata", {}) + output_expires_after = request_data.get("output_expires_after") + + logger.info( + f"Creating batch with input_file: {input_file_id}, endpoint: {endpoint}, output_expires_after: {output_expires_after}", + ) + + if not input_file_id or input_file_id not in file_storage: + raise HTTPException(status_code=400, detail="Input file not found") + + input_file = file_storage[input_file_id] + is_valid, error_code, _ = validate_batch_input(input_file["content"]) + if not is_valid: + raise HTTPException( + status_code=400, + detail={ + "error": { + "code": error_code, + "message": f"Validation failed: {error_code}", + }, + }, + ) + + batch_id = f"batch_{uuid.uuid4()}" + created_at = int(time.time()) + + if output_expires_after: + seconds = ( + output_expires_after.get("seconds", 0) + if isinstance(output_expires_after, dict) + else 0 + ) + expires_at = created_at + seconds + logger.info( + f"Using output_expires_after: {seconds}s, expires_at: {expires_at}", + ) + elif completion_window == "24h": + expires_at = created_at + (24 * 60 * 60) + else: + expires_at = created_at + (24 * 60 * 60) + + batch = BatchObject( + id=batch_id, + endpoint=endpoint, + input_file_id=input_file_id, + completion_window=completion_window, + status="validating", + created_at=created_at, + expires_at=expires_at, + request_counts={"total": 0, "completed": 0, "failed": 0}, + metadata=metadata, + ) + + batch_storage[batch_id] = batch + logger.info(f"Created batch {batch_id}") + + asyncio.create_task(process_batch(batch_id)) + + return batch.model_dump() + + @app.get("/openai/v1/batches/{batch_id}") + @app.get("/openai/batches/{batch_id}") + @app.get("/v1/batches/{batch_id}") + @app.get("/batches/{batch_id}") + async def get_batch(batch_id: str, _=Depends(validate_credentials)): + logger.info(f"Getting batch: {batch_id}") + if batch_id not in batch_storage: + raise HTTPException(status_code=404, detail="Batch not found") + + return batch_storage[batch_id].model_dump() + + @app.get("/openai/v1/batches") + @app.get("/openai/batches") + @app.get("/v1/batches") + @app.get("/batches") + async def list_batches( + after: Optional[str] = Query(None), + limit: int = Query(20, le=100), + _=Depends(validate_credentials), + ): + logger.info(f"Listing batches, after: {after}, limit: {limit}") + batches = list(batch_storage.values()) + batches.sort(key=lambda x: x.created_at, reverse=True) + + if after: + after_index = next((i for i, b in enumerate(batches) if b.id == after), -1) + if after_index >= 0: + batches = batches[after_index + 1 :] + + batches = batches[:limit] + + return BatchListResponse( + data=[batch.model_dump() for batch in batches], + first_id=batches[0].id if batches else None, + last_id=batches[-1].id if batches else None, + has_more=len(batches) == limit, + ).model_dump() + + @app.post("/openai/v1/batches/{batch_id}/cancel") + @app.post("/openai/batches/{batch_id}/cancel") + @app.post("/v1/batches/{batch_id}/cancel") + @app.post("/batches/{batch_id}/cancel") + async def cancel_batch(batch_id: str, _=Depends(validate_credentials)): + logger.info(f"Cancelling batch: {batch_id}") + if batch_id not in batch_storage: + raise HTTPException(status_code=404, detail="Batch not found") + + batch = batch_storage[batch_id] + if batch.status in ["completed", "failed", "cancelled", "expired"]: + raise HTTPException( + status_code=400, + detail=f"Cannot cancel batch in {batch.status} status", + ) + + batch.status = "cancelled" + batch.cancelled_at = int(time.time()) + logger.info(f"Batch {batch_id} cancelled") + + return batch.model_dump() + + # Debug endpoints + @app.get("/debug/batches") + async def debug_list_batches(): + return { + "batches": { + batch_id: batch.model_dump() + for batch_id, batch in batch_storage.items() + }, + "files": { + file_id: {k: v for k, v in data.items() if k != "content"} + for file_id, data in file_storage.items() + }, + } + + @app.post("/reset") + @app.post("/debug/clear") + async def reset_all(): + file_storage.clear() + batch_storage.clear() + batch_results.clear() + logger.info("All data cleared") + return {"message": "All data cleared"} + + @app.get("/debug/status") + async def debug_status(): + return { + "files_count": len(file_storage), + "batches_count": len(batch_storage), + "batch_statuses": {bid: b.status for bid, b in batch_storage.items()}, + } diff --git a/tests/batches_tests/local-litellm/mock-server/mock_chat.py b/tests/batches_tests/local-litellm/mock-server/mock_chat.py new file mode 100644 index 00000000000..c33523579a5 --- /dev/null +++ b/tests/batches_tests/local-litellm/mock-server/mock_chat.py @@ -0,0 +1,124 @@ +import json +import time +import uuid +from datetime import datetime + +from fastapi import FastAPI, Request +from fastapi.responses import StreamingResponse + + +def get_request_details(request: Request, body: dict = None) -> str: + details = { + "method": request.method, + "url": str(request.url), + "path": request.url.path, + "headers": dict(request.headers), + "query_params": dict(request.query_params), + } + return json.dumps(details, indent=2) + + +def data_generator(response_details: str, model: str): + response_id = uuid.uuid4().hex + content = response_details + chunk_size = 50 + for i in range(0, len(content), chunk_size): + text_chunk = content[i : i + chunk_size] + chunk = { + "id": f"chatcmpl-{response_id}", + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": [{"index": 0, "delta": {"content": text_chunk}}], + } + yield f"data: {json.dumps(chunk)}\n\n" + final_chunk = { + "id": f"chatcmpl-{response_id}", + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + yield f"data: {json.dumps(final_chunk)}\n\n" + yield "data: [DONE]\n\n" + + +def setup_chat_routes(app: FastAPI): + @app.post("/chat/completions") + @app.post("/v1/chat/completions") + @app.post("/openai/deployments/{model:path}/chat/completions") + async def completion(request: Request): + data = await request.json() + model = data.get("model", "unknown") + request_details = get_request_details(request, data) + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + response_details = f"Request:{request_details}, Canned Response:{timestamp}" + + if data.get("stream"): + return StreamingResponse( + content=data_generator(response_details, model), + media_type="text/event-stream", + ) + else: + response_id = uuid.uuid4().hex + response = { + "id": f"chatcmpl-{response_id}", + "object": "chat.completion", + "created": int(time.time()), + "model": model, + "system_fingerprint": "fp_mock_server", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": response_details, + }, + "logprobs": None, + "finish_reason": "stop", + }, + ], + "usage": { + "prompt_tokens": 9, + "completion_tokens": 12, + "total_tokens": 21, + }, + } + return response + + @app.post("/completions") + @app.post("/v1/completions") + async def text_completion(request: Request): + data = await request.json() + model = data.get("model", "unknown") + request_details = get_request_details(request, data) + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + response_details = f"Request:{request_details}, Canned Response:{timestamp}" + + if data.get("stream"): + return StreamingResponse( + content=data_generator(response_details, model), + media_type="text/event-stream", + ) + else: + response = { + "id": f"cmpl-{uuid.uuid4().hex}", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "logprobs": None, + "text": response_details, + }, + ], + "created": int(time.time()), + "model": model, + "object": "text_completion", + "system_fingerprint": None, + "usage": { + "completion_tokens": 16, + "prompt_tokens": 10, + "total_tokens": 26, + }, + } + return response diff --git a/tests/batches_tests/local-litellm/mock-server/mock_embeddings.py b/tests/batches_tests/local-litellm/mock-server/mock_embeddings.py new file mode 100644 index 00000000000..9f7aa46adcd --- /dev/null +++ b/tests/batches_tests/local-litellm/mock-server/mock_embeddings.py @@ -0,0 +1,26 @@ +from fastapi import FastAPI, Request + + +def setup_embeddings_routes(app: FastAPI): + @app.post("/embeddings") + @app.post("/v1/embeddings") + @app.post("/openai/deployments/{model:path}/embeddings") + async def embeddings(request: Request): + data = await request.json() + model = data.get("model", "unknown") + _small_embedding = [ + -0.006929283495992422, + -0.005336422007530928, + -4.547132266452536e-05, + -0.024047505110502243, + ] + big_embedding = _small_embedding * 100 + return { + "object": "list", + "data": [{"object": "embedding", "index": 0, "embedding": big_embedding}], + "model": model, + "usage": {"prompt_tokens": 5, "total_tokens": 5}, + } + + + diff --git a/tests/batches_tests/local-litellm/mock-server/mock_responses.py b/tests/batches_tests/local-litellm/mock-server/mock_responses.py new file mode 100644 index 00000000000..40e7ae34fbd --- /dev/null +++ b/tests/batches_tests/local-litellm/mock-server/mock_responses.py @@ -0,0 +1,92 @@ +import json +import time +import uuid +from datetime import datetime + +from fastapi import FastAPI, Request + + +def get_request_details(request: Request, body: dict = None) -> str: + details = { + "method": request.method, + "url": str(request.url), + "path": request.url.path, + "headers": dict(request.headers), + "query_params": dict(request.query_params), + } + return json.dumps(details, indent=2) + + +def setup_responses_routes(app: FastAPI): + @app.post("/responses") + @app.post("/v1/responses") + @app.post("/openai/responses") + async def responses_api(request: Request): + data = await request.json() + model = data.get("model", "unknown") + request_details = get_request_details(request, data) + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + response_details = f"Request:{request_details}, Canned Response:{timestamp}" + response_id = uuid.uuid4().hex + message_id = f"msg_{uuid.uuid4().hex[:34]}" + return { + "id": f"resp_{response_id}", + "created_at": int(time.time()), + "error": None, + "incomplete_details": None, + "instructions": None, + "metadata": {}, + "model": model, + "object": "response", + "output": [ + { + "id": message_id, + "content": [ + { + "annotations": [], + "text": response_details, + "type": "output_text", + "logprobs": [], + }, + ], + "role": "assistant", + "status": "completed", + "type": "message", + }, + ], + "parallel_tool_calls": True, + "temperature": data.get("temperature", 1.0), + "tool_choice": data.get("tool_choice", "auto"), + "tools": data.get("tools", []), + "top_p": data.get("top_p", 1.0), + "max_output_tokens": data.get("max_output_tokens"), + "previous_response_id": None, + "reasoning": {"effort": None, "summary": None}, + "status": "completed", + "text": {"format": {"type": "text"}, "verbosity": "medium"}, + "truncation": "disabled", + "usage": { + "input_tokens": 11, + "input_tokens_details": { + "audio_tokens": None, + "cached_tokens": 0, + "text_tokens": None, + }, + "output_tokens": 19, + "output_tokens_details": {"reasoning_tokens": 0, "text_tokens": None}, + "total_tokens": 30, + "cost": None, + }, + "user": None, + "store": True, + "background": False, + "content_filters": None, + "max_tool_calls": None, + "prompt_cache_key": None, + "safety_identifier": None, + "service_tier": "default", + "top_logprobs": 0, + } + + + diff --git a/tests/batches_tests/local-litellm/mock-server/pyproject.toml b/tests/batches_tests/local-litellm/mock-server/pyproject.toml new file mode 100644 index 00000000000..ecb462983cc --- /dev/null +++ b/tests/batches_tests/local-litellm/mock-server/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "mock-server" +version = "0.1.0" +description = "Mock LLM server for testing LiteLLM" +requires-python = ">=3.11" +dependencies = [ + "fastapi", + "uvicorn", + "slowapi", + "python-dotenv>=0.2.0", + "python-multipart", + "pydantic", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/tests/batches_tests/local-litellm/mock-server/uv.lock b/tests/batches_tests/local-litellm/mock-server/uv.lock new file mode 100644 index 00000000000..9f00d16db95 --- /dev/null +++ b/tests/batches_tests/local-litellm/mock-server/uv.lock @@ -0,0 +1,416 @@ +version = 1 +revision = 1 +requires-python = ">=3.11" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" }, +] + +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f" }, +] + +[[package]] +name = "fastapi" +version = "0.125.0" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/17/71/2df15009fb4bdd522a069d2fbca6007c6c5487fce5cb965be00fc335f1d1/fastapi-0.125.0.tar.gz", hash = "sha256:16b532691a33e2c5dee1dac32feb31dc6eb41a3dd4ff29a95f9487cb21c054c0" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/34/2f/ff2fcc98f500713368d8b650e1bbc4a0b3ebcdd3e050dcdaad5f5a13fd7e/fastapi-0.125.0-py3-none-any.whl", hash = "sha256:2570ec4f3aecf5cca8f0428aed2398b774fcdfee6c2116f86e80513f2f86a7a1" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea" }, +] + +[[package]] +name = "limits" +version = "5.6.0" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/bb/e5/c968d43a65128cd54fb685f257aafb90cd5e4e1c67d084a58f0e4cbed557/limits-5.6.0.tar.gz", hash = "sha256:807fac75755e73912e894fdd61e2838de574c5721876a19f7ab454ae1fffb4b5" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/40/96/4fcd44aed47b8fcc457653b12915fcad192cd646510ef3f29fd216f4b0ab/limits-5.6.0-py3-none-any.whl", hash = "sha256:b585c2104274528536a5b68864ec3835602b3c4a802cd6aa0b07419798394021" }, +] + +[[package]] +name = "mock-server" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "slowapi" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi" }, + { name = "pydantic" }, + { name = "python-dotenv", specifier = ">=0.2.0" }, + { name = "python-multipart" }, + { name = "slowapi" }, + { name = "uvicorn" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.21" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090" }, +] + +[[package]] +name = "slowapi" +version = "0.1.9" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +dependencies = [ + { name = "limits" }, +] +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" }, +] + +[[package]] +name = "uvicorn" +version = "0.38.0" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02" }, +] + +[[package]] +name = "wrapt" +version = "2.0.1" +source = { registry = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/simple" } +sdist = { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f" } +wheels = [ + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/98/60/553997acf3939079dab022e37b67b1904b5b0cc235503226898ba573b10c/wrapt-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e17283f533a0d24d6e5429a7d11f250a58d28b4ae5186f8f47853e3e70d2590" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/2d/50/e5b3d30895d77c52105c6d5cbf94d5b38e2a3dd4a53d22d246670da98f7c/wrapt-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85df8d92158cb8f3965aecc27cf821461bb5f40b450b03facc5d9f0d4d6ddec6" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/f0/40/660b2898703e5cbbb43db10cdefcc294274458c3ca4c68637c2b99371507/wrapt-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1be685ac7700c966b8610ccc63c3187a72e33cab53526a27b2a285a662cd4f7" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/5b/36/825b44c8a10556957bc0c1d84c7b29a40e05fcf1873b6c40aa9dbe0bd972/wrapt-2.0.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0b6d3b95932809c5b3fecc18fda0f1e07452d05e2662a0b35548985f256e28" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/83/73/0a5d14bb1599677304d3c613a55457d34c344e9b60eda8a737c2ead7619e/wrapt-2.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da7384b0e5d4cae05c97cd6f94faaf78cc8b0f791fc63af43436d98c4ab37bb" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/01/22/1c158fe763dbf0a119f985d945711d288994fe5514c0646ebe0eb18b016d/wrapt-2.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ec65a78fbd9d6f083a15d7613b2800d5663dbb6bb96003899c834beaa68b242c" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/5c/28/4f16861af67d6de4eae9927799b559c20ebdd4fe432e89ea7fe6fcd9d709/wrapt-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7de3cc939be0e1174969f943f3b44e0d79b6f9a82198133a5b7fc6cc92882f16" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/a0/8b/7960122e625fad908f189b59c4aae2d50916eb4098b0fb2819c5a177414f/wrapt-2.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fb1a5b72cbd751813adc02ef01ada0b0d05d3dcbc32976ce189a1279d80ad4a2" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/3e/73/7881eee5ac31132a713ab19a22c9e5f1f7365c8b1df50abba5d45b781312/wrapt-2.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3fa272ca34332581e00bf7773e993d4f632594eb2d1b0b162a9038df0fd971dd" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/45/00/9499a3d14e636d1f7089339f96c4409bbc7544d0889f12264efa25502ae8/wrapt-2.0.1-cp311-cp311-win32.whl", hash = "sha256:fc007fdf480c77301ab1afdbb6ab22a5deee8885f3b1ed7afcb7e5e84a0e27be" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/70/5d/8f3d7eea52f22638748f74b102e38fdf88cb57d08ddeb7827c476a20b01b/wrapt-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:47434236c396d04875180171ee1f3815ca1eada05e24a1ee99546320d54d1d1b" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/14/e2/32195e57a8209003587bbbad44d5922f13e0ced2a493bb46ca882c5b123d/wrapt-2.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:837e31620e06b16030b1d126ed78e9383815cbac914693f54926d816d35d8edf" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/cb/73/8cb252858dc8254baa0ce58ce382858e3a1cf616acebc497cb13374c95c6/wrapt-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1fdbb34da15450f2b1d735a0e969c24bdb8d8924892380126e2a293d9902078c" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/19/42/44a0db2108526ee6e17a5ab72478061158f34b08b793df251d9fbb9a7eb4/wrapt-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d32794fe940b7000f0519904e247f902f0149edbe6316c710a8562fb6738841" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/4d/8a/5b4b1e44b791c22046e90d9b175f9a7581a8cc7a0debbb930f81e6ae8e25/wrapt-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:386fb54d9cd903ee0012c09291336469eb7b244f7183d40dc3e86a16a4bace62" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/11/53/3e794346c39f462bcf1f58ac0487ff9bdad02f9b6d5ee2dc84c72e0243b2/wrapt-2.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7b219cb2182f230676308cdcacd428fa837987b89e4b7c5c9025088b8a6c9faf" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/c6/7e/10b7b0e8841e684c8ca76b462a9091c45d62e8f2de9c4b1390b690eadf16/wrapt-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:641e94e789b5f6b4822bb8d8ebbdfc10f4e4eae7756d648b717d980f657a9eb9" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/0e/d1/3c1e4321fc2f5ee7fd866b2d822aa89b84495f28676fd976c47327c5b6aa/wrapt-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe21b118b9f58859b5ebaa4b130dee18669df4bd111daad082b7beb8799ad16b" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/a4/b0/d2f0a413cf201c8c2466de08414a15420a25aa83f53e647b7255cc2fab5d/wrapt-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17fb85fa4abc26a5184d93b3efd2dcc14deb4b09edcdb3535a536ad34f0b4dba" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/bd/45/bddb11d28ca39970a41ed48a26d210505120f925918592283369219f83cc/wrapt-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b89ef9223d665ab255ae42cc282d27d69704d94be0deffc8b9d919179a609684" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/81/af/34ba6dd570ef7a534e7eec0c25e2615c355602c52aba59413411c025a0cb/wrapt-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a453257f19c31b31ba593c30d997d6e5be39e3b5ad9148c2af5a7314061c63eb" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/e2/3e/693a13b4146646fb03254636f8bafd20c621955d27d65b15de07ab886187/wrapt-2.0.1-cp312-cp312-win32.whl", hash = "sha256:3e271346f01e9c8b1130a6a3b0e11908049fe5be2d365a5f402778049147e7e9" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/a7/36/715ec5076f925a6be95f37917b66ebbeaa1372d1862c2ccd7a751574b068/wrapt-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2da620b31a90cdefa9cd0c2b661882329e2e19d1d7b9b920189956b76c564d75" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/ef/3e/62451cd7d80f65cc125f2b426b25fbb6c514bf6f7011a0c3904fc8c8df90/wrapt-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:aea9c7224c302bc8bfc892b908537f56c430802560e827b75ecbde81b604598b" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/73/81/d08d83c102709258e7730d3cd25befd114c60e43ef3891d7e6877971c514/wrapt-2.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5e53b428f65ece6d9dad23cb87e64506392b720a0b45076c05354d27a13351a1" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/f6/14/393afba2abb65677f313aa680ff0981e829626fed39b6a7e3ec807487790/wrapt-2.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ad3ee9d0f254851c71780966eb417ef8e72117155cff04821ab9b60549694a55" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/c4/10/a4a1f2fba205a9462e36e708ba37e5ac95f4987a0f1f8fd23f0bf1fc3b0f/wrapt-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7b822c61ed04ee6ad64bc90d13368ad6eb094db54883b5dde2182f67a7f22c0" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/12/db/99ba5c37cf1c4fad35349174f1e38bd8d992340afc1ff27f526729b98986/wrapt-2.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7164a55f5e83a9a0b031d3ffab4d4e36bbec42e7025db560f225489fa929e509" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/30/3f/a1c8d2411eb826d695fc3395a431757331582907a0ec59afce8fe8712473/wrapt-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60690ba71a57424c8d9ff28f8d006b7ad7772c22a4af432188572cd7fa004a1" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/b3/8d/72c74a63f201768d6a04a8845c7976f86be6f5ff4d74996c272cefc8dafc/wrapt-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cd1a4bd9a7a619922a8557e1318232e7269b5fb69d4ba97b04d20450a6bf970" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/c7/5a/df37cf4042cb13b08256f8e27023e2f9b3d471d553376616591bb99bcb31/wrapt-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4c2e3d777e38e913b8ce3a6257af72fb608f86a1df471cb1d4339755d0a807c" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/54/34/40d6bc89349f9931e1186ceb3e5fbd61d307fef814f09fbbac98ada6a0c8/wrapt-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3d366aa598d69416b5afedf1faa539fac40c1d80a42f6b236c88c73a3c8f2d41" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/70/66/81c3461adece09d20781dee17c2366fdf0cb8754738b521d221ca056d596/wrapt-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c235095d6d090aa903f1db61f892fffb779c1eaeb2a50e566b52001f7a0f66ed" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/46/3a/d0146db8be8761a9e388cc9cc1c312b36d583950ec91696f19bbbb44af5a/wrapt-2.0.1-cp314-cp314-win32.whl", hash = "sha256:bfb5539005259f8127ea9c885bdc231978c06b7a980e63a8a61c8c4c979719d0" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/1a/38/5359da9af7d64554be63e9046164bd4d8ff289a2dd365677d25ba3342c08/wrapt-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:4ae879acc449caa9ed43fc36ba08392b9412ee67941748d31d94e3cedb36628c" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/aa/3f/96db0619276a833842bf36343685fa04f987dd6e3037f314531a1e00492b/wrapt-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:8639b843c9efd84675f1e100ed9e99538ebea7297b62c4b45a7042edb84db03e" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/71/49/5f5d1e867bf2064bf3933bc6cf36ade23505f3902390e175e392173d36a2/wrapt-2.0.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:9219a1d946a9b32bb23ccae66bdb61e35c62773ce7ca6509ceea70f344656b7b" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/2b/89/0009a218d88db66ceb83921e5685e820e2c61b59bbbb1324ba65342668bc/wrapt-2.0.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fa4184e74197af3adad3c889a1af95b53bb0466bced92ea99a0c014e48323eec" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/ae/18/9b968e920dd05d6e44bcc918a046d02afea0fb31b2f1c80ee4020f377cbe/wrapt-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c5ef2f2b8a53b7caee2f797ef166a390fef73979b15778a4a153e4b5fedce8fa" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/a6/7d/78bdcb75826725885d9ea26c49a03071b10c4c92da93edda612910f150e4/wrapt-2.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e042d653a4745be832d5aa190ff80ee4f02c34b21f4b785745eceacd0907b815" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/dd/77/cac1d46f47d32084a703df0d2d29d47e7eb2a7d19fa5cbca0e529ef57659/wrapt-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2afa23318136709c4b23d87d543b425c399887b4057936cd20386d5b1422b6fa" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/8a/11/b521406daa2421508903bf8d5e8b929216ec2af04839db31c0a2c525eee0/wrapt-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c72328f668cf4c503ffcf9434c2b71fdd624345ced7941bc6693e61bbe36bef" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/0c/c0/340b272bed297baa7c9ce0c98ef7017d9c035a17a6a71dce3184b8382da2/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3793ac154afb0e5b45d1233cb94d354ef7a983708cc3bb12563853b1d8d53747" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/f3/93/bfcb1fb2bdf186e9c2883a4d1ab45ab099c79cbf8f4e70ea453811fa3ea7/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fec0d993ecba3991645b4857837277469c8cc4c554a7e24d064d1ca291cfb81f" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/d2/6b/dca504fb18d971139d232652656180e3bd57120e1193d9a5899c3c0b7cdd/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:949520bccc1fa227274da7d03bf238be15389cd94e32e4297b92337df9b7a349" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/1d/f6/a1de4bd3653afdf91d250ca5c721ee51195df2b61a4603d4b373aa804d1d/wrapt-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:be9e84e91d6497ba62594158d3d31ec0486c60055c49179edc51ee43d095f79c" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/01/3a/07cd60a9d26fe73efead61c7830af975dfdba8537632d410462672e4432b/wrapt-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61c4956171c7434634401db448371277d07032a81cc21c599c22953374781395" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/41/99/8a06b8e17dddbf321325ae4eb12465804120f699cd1b8a355718300c62da/wrapt-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:35cdbd478607036fee40273be8ed54a451f5f23121bd9d4be515158f9498f7ad" }, + { url = "https://artifacts.prod.devops.point72.com/artifactory/api/pypi/pypi-remote/packages/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca" }, +] diff --git a/tests/batches_tests/local-litellm/patches/README.md b/tests/batches_tests/local-litellm/patches/README.md new file mode 100644 index 00000000000..776670b5b5d --- /dev/null +++ b/tests/batches_tests/local-litellm/patches/README.md @@ -0,0 +1,231 @@ +# LiteLLM Patches + +Patches for LiteLLM `main-latest` (as of 2025-12-19). + +--- + +## 1. File Deletion Fails for Batch Output Files + +### Broken Feature + +`DELETE /files/{file_id}` - Deleting batch output files fails with a Pydantic validation error. + +### Error Message + +``` +openai.InternalServerError: Error code: 500 - { + 'error': { + 'message': '1 validation error for LiteLLM_ManagedFileTable\nfile_object\n Input should be a valid dictionary or instance of OpenAIFileObject [type=model_type, input_value=None, input_type=NoneType]' + } +} +``` + +### Root Cause + +When LiteLLM stores batch output files in `LiteLLM_ManagedFileTable`, it sets `file_object=None`. However, the Pydantic model requires this field to be a valid `OpenAIFileObject`. + +### Code Change (`_types.py`) + +```python +# Before +class LiteLLM_ManagedFileTable(LiteLLMPydanticObjectBase): + file_object: OpenAIFileObject + +# After +class LiteLLM_ManagedFileTable(LiteLLMPydanticObjectBase): + file_object: Optional[OpenAIFileObject] = None # PATCHED +``` + +--- + +## 2. File Deletion Returns Wrong Response + +### Broken Feature + +`DELETE /files/{file_id}` - Even after fixing patch #1, the method returns `None` instead of the delete confirmation. + +### Error Message + +``` +Exception: LiteLLM Managed File object with id=... not found +``` + +### Root Cause + +`afile_delete` in `managed_files.py` calls `llm_router.afile_delete` (which deletes the file at the provider) but discards the response. + +### Code Change (`managed_files.py`) + +```python +# Before +async def afile_delete(self, file_id, ...): + for model_id, model_file_id in mapping.items(): + await llm_router.afile_delete(model=model_id, file_id=model_file_id, **data) + # Returns None + +# After +async def afile_delete(self, file_id, ...): + delete_response = None + for model_id, model_file_id in mapping.items(): + delete_response = await llm_router.afile_delete(...) # PATCHED: Capture response + if delete_response: + delete_response.id = file_id # PATCHED: Replace with unified ID + return delete_response +``` + +--- + +## 3. Batch Listing Fails with Duplicate Argument + +### Broken Feature + +`GET /batches?target_model_names=...` - Listing batches fails when using `target_model_names` query parameter. + +### Error Message + +``` +openai.InternalServerError: Error code: 500 - { + 'error': { + 'message': "alist_batches() got multiple values for keyword argument 'model'" + } +} +``` + +### Root Cause + +The code passes `model` explicitly AND includes it in `**data`: + +```python +model = target_model_names.split(",")[0] +response = await llm_router.alist_batches( + model=model, # Passed explicitly + **data, # Also contains 'model' key +) +``` + +### Code Change (`batches_endpoints.py`) + +```python +# Before +model = target_model_names.split(",")[0] +response = await llm_router.alist_batches(model=model, **data) + +# After +model = target_model_names.split(",")[0] +data.pop("model", None) # PATCHED: Remove duplicate +data.pop("target_model_names", None) # PATCHED: Remove to avoid passing to downstream +response = await llm_router.alist_batches(model=model, **data) +``` + +--- + +## 4. File Retrieve Returns None for Batch Output Files + +### Broken Feature + +`GET /files/{file_id}` - Retrieving batch output file metadata returns `None`. + +### Error Message + +``` +AttributeError: 'NoneType' object has no attribute 'id' +``` + +### Root Cause + +`afile_retrieve` returns `stored_file_object.file_object` which is `None` for batch output files. It should fetch from the provider. + +### Code Change (`managed_files.py` + `files_endpoints.py`) + +```python +# managed_files.py - After +async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router=None): + stored = await self.get_unified_file_id(file_id, ...) + if stored.file_object: + return stored.file_object + # PATCHED: Fetch from provider when file_object is None + for model_id, model_file_id in stored.model_mappings.items(): + response = await llm_router.afile_retrieve(model=model_id, file_id=model_file_id) + response.id = file_id + return response +``` + +```python +# files_endpoints.py - After +response = await managed_files_obj.afile_retrieve( + file_id=file_id, + litellm_parent_otel_span=user_api_key_dict.parent_otel_span, + llm_router=llm_router, # PATCHED: Pass router +) +``` + +--- + +## Known Issues (Not Bugs) + +### Azure Batch Creation Response Missing `endpoint` + +**Behavior:** Azure's batch creation response returns `endpoint=''` (empty string). + +**Expected:** When you call `batches.retrieve()` or `batches.list()`, Azure returns `endpoint='/v1/chat/completions'` correctly. + +**Workaround:** If you need the endpoint immediately after creation, retrieve the batch to get the correct value. + +### Azure Batch Listing Returns Raw IDs + +**Behavior:** `batches.list()` returns raw Azure batch IDs (e.g., `batch_abc123`) instead of LiteLLM unified IDs. + +**Root Cause:** LiteLLM routes `batches.list()` directly to Azure instead of querying its internal managed batches database. + +**Workaround:** Use `batches.retrieve(unified_batch_id)` instead of relying on list. + +### Config Fix: Azure Batch Listing 404 + +**Behavior:** `batches.list()` returns empty results because Azure returns 404. + +**Root Cause:** LiteLLM defaults to OpenAI handler instead of Azure handler for batch operations. + +**Fix (config):** Add `custom_llm_provider: azure` to your model's `litellm_params`: + +```yaml +litellm_params: + model: azure/gpt-5-batch + custom_llm_provider: azure # Required for Azure batch operations +``` + +--- + +## Patch Files + +| Patch File | Container Path | +|------------|----------------| +| `_types.py` | `/usr/lib/python3.13/site-packages/litellm/proxy/_types.py` | +| `managed_files.py` | `/usr/lib/python3.13/site-packages/litellm_enterprise/proxy/hooks/managed_files.py` | +| `batches_endpoints.py` | `/usr/lib/python3.13/site-packages/litellm/proxy/batches_endpoints/endpoints.py` | +| `files_endpoints.py` | `/usr/lib/python3.13/site-packages/litellm/proxy/openai_files_endpoints/files_endpoints.py` | + +--- + +## Usage + +### With Patches + +```bash +./start-patched.sh +``` + +### Without Patches + +```bash +./start-unpatched.sh +``` + +### Docker Compose Volumes + +```yaml +volumes: + - ./patches/_types.py:/usr/lib/python3.13/site-packages/litellm/proxy/_types.py + - ./patches/managed_files.py:/usr/lib/python3.13/site-packages/litellm_enterprise/proxy/hooks/managed_files.py + - ./patches/batches_endpoints.py:/usr/lib/python3.13/site-packages/litellm/proxy/batches_endpoints/endpoints.py + - ./patches/files_endpoints.py:/usr/lib/python3.13/site-packages/litellm/proxy/openai_files_endpoints/files_endpoints.py +``` diff --git a/tests/batches_tests/test_managed_files_base.py b/tests/batches_tests/test_managed_files_base.py new file mode 100644 index 00000000000..9ea6d58355f --- /dev/null +++ b/tests/batches_tests/test_managed_files_base.py @@ -0,0 +1,220 @@ +"""Base class for managed files and batch API tests.""" + +import json +import os +import time +import uuid + +import httpx +import openai +import pytest +from tenacity import Retrying, stop_after_delay, wait_fixed + + +LOCAL_LITELLM_BASE_URL = "http://localhost:4000" +LOCAL_AZURE_BASE_URL = "http://localhost:8090" + +USE_LITELLM = os.environ.get("USE_LITELLM", "true").lower() == "true" +if USE_LITELLM: + base_url = LOCAL_LITELLM_BASE_URL + api_key = "sk-1234" +else: + base_url = LOCAL_AZURE_BASE_URL + api_key = "sk-1234" + +USE_MOCK_SERVER = os.environ.get("USE_MOCK_SERVER", "false").lower() == "true" +if USE_MOCK_SERVER: + model_name = "azure-fake-gpt-5-batch-2025-08-07" + MODEL_NAMES = [ + "azure-fake-gpt-5-batch-2025-08-07", + # "anthropic-fake-claude-sonnet-4-batch-2025-08-07", + # "vertex-fake-gemini-2.5-pro-batch-2025-08-07", + ] +else: + model_name = "gpt-5-batch-2025-08-07" + MODEL_NAMES = [ + "gpt-5-batch-2025-08-07", + # "claude-sonnet-4-batch-2025-08-07", + # "gemini-2.5-pro-batch-2025-08-07", + ] + + +def _extract_model_id(model_name: str) -> str: + if "gpt" in model_name: + return "gpt" + elif "claude" in model_name or "anthropic" in model_name: + return "anthropic" + elif "gemini" in model_name or "vertex" in model_name: + return "gemini" + return model_name.split("-")[0] + + +MODEL_IDS = [_extract_model_id(m) for m in MODEL_NAMES] + +MIN_EXPIRY_SECONDS = 259200 + + +class ManagedFilesBase: + """Base class with shared helpers for managed files and batch tests.""" + + base_url = base_url + api_key = api_key + + @pytest.fixture(autouse=True) + def setup_test(self): + print(f"Base URL: {self.base_url}, Model: {model_name}\n") + self.reset_mock_server() + + @staticmethod + def generate_request_id(): + return f"req-{uuid.uuid4().hex[:8]}" + + def create_openai_client(self, api_key: str) -> openai.OpenAI: + return openai.OpenAI( + base_url=self.base_url, + api_key=api_key, + http_client=httpx.Client(verify=False), + ) + + def create_batch_request_file_on_disk(self, tmpdir, model: str): + request_id = self.generate_request_id() + batch_request = { + "custom_id": request_id, + "method": "POST", + "url": "/v1/chat/completions", + "body": { + "model": model, + "messages": [ + {"role": "user", "content": "What is 2+2?"}, + ], + }, + } + + request_file = os.path.join(tmpdir, f"request-{request_id}.jsonl") + with open(request_file, "w") as f: + f.write(json.dumps(batch_request)) + + return request_file + + def create_batch_input_file( + self, + client: openai.OpenAI, + request_file: str, + expiry_seconds: int = MIN_EXPIRY_SECONDS, + ): + batch_input_file = client.files.create( + file=open(request_file, "rb"), + purpose="batch", + extra_body={ + "target_model_names": model_name, + "expires_after": { + "seconds": expiry_seconds, + "anchor": "created_at", + }, + }, + ) + return batch_input_file + + def create_batch( + self, + client: openai.OpenAI, + input_file_id: str, + expiry_seconds: int = MIN_EXPIRY_SECONDS, + ): + batch = client.batches.create( + input_file_id=input_file_id, + endpoint="/v1/chat/completions", + completion_window="24h", + extra_body={ + "output_expires_after": { + "seconds": expiry_seconds, + "anchor": "created_at", + }, + }, + ) + return batch + + def wait_for_batch_state( + self, + client: openai.OpenAI, + batch_id: str, + expected_status: str, + max_seconds: int = 60, + wait_seconds: int = 5, + ): + for attempt in Retrying( + stop=stop_after_delay(max_seconds), + wait=wait_fixed(wait_seconds), + ): + with attempt: + batch_response = client.batches.retrieve(batch_id=batch_id) + print( + f"[{time.strftime('%H:%M:%S')}] Batch status: {batch_response.status}, expected: {expected_status}", + ) + if batch_response.status == expected_status: + return batch_response + if batch_response.status in ["failed", "expired", "cancelled"]: + raise Exception( + f"Batch failed with status: {batch_response.status}", + ) + raise Exception(f"Batch not in {expected_status} state yet") + return None + + def wait_for_batch_completed( + self, + client: openai.OpenAI, + batch_id: str, + max_seconds: int = 120, + wait_seconds: int = 5, + ): + return self.wait_for_batch_state( + client, + batch_id, + "completed", + max_seconds, + wait_seconds, + ) + + def shorten_id(self, id_str: str) -> str: + if id_str is None: + return "None" + if len(id_str) <= 20: + return id_str + return id_str[:8] + "..." + id_str[-8:] + + def reset_mock_server(self): + if not USE_MOCK_SERVER: + return + print("Resetting mock server state...") + reset_response = httpx.post(f"{LOCAL_AZURE_BASE_URL}/reset") + assert reset_response.status_code == 200, f"Reset failed: {reset_response.text}" + + def print_file_metadata(self, file_obj, label="File"): + print(f"{label} metadata:") + print(f"\tid={self.shorten_id(file_obj.id)}") + print(f"\tobject={file_obj.object}") + print(f"\tbytes={file_obj.bytes}") + print(f"\tfilename={file_obj.filename}") + print(f"\tpurpose={file_obj.purpose}") + print(f"\tstatus={file_obj.status}") + print(f"\tcreated_at={file_obj.created_at}") + print(f"\texpires_at={file_obj.expires_at}") + if file_obj.status_details: + print(f"\tstatus_details={file_obj.status_details}") + + def print_batch_metadata(self, batch): + print("Batch metadata:") + print(f"\tid={self.shorten_id(batch.id)}") + print(f"\tstatus={batch.status}") + print(f"\tendpoint={batch.endpoint}") + print(f"\tcompletion_window={batch.completion_window}") + print(f"\tinput_file_id={self.shorten_id(batch.input_file_id)}") + print(f"\tcreated_at={batch.created_at}") + print(f"\texpires_at={batch.expires_at}") + print(f"\tin_progress_at={batch.in_progress_at}") + print(f"\tcompleted_at={batch.completed_at}") + print(f"\toutput_file_id={self.shorten_id(batch.output_file_id)}") + print(f"\trequest_counts={batch.request_counts}") + + + diff --git a/tests/batches_tests/test_managed_files_endtoend.py b/tests/batches_tests/test_managed_files_endtoend.py new file mode 100644 index 00000000000..828060e9cd5 --- /dev/null +++ b/tests/batches_tests/test_managed_files_endtoend.py @@ -0,0 +1,204 @@ +import warnings + +import openai +import pytest +from tenacity import RetryError, Retrying, stop_after_delay, wait_fixed + +from test_managed_files_base import ( + MODEL_IDS, + MODEL_NAMES, + ManagedFilesBase, + MIN_EXPIRY_SECONDS, +) + + +class TestManagedFilesAPI(ManagedFilesBase): + """Test cases for managed files and batch API. + + Configuration via environment variables: + USE_LITELLM=true - Run against LiteLLM proxy + USE_LITELLM=false - Run against mock server directly (default) + """ + + @classmethod + def setup_class(cls): + cls.openai_client = cls.create_openai_client(cls, cls.api_key) + + def wait_for_batch_list(self, model_name, max_seconds=90, wait_seconds=10): + for attempt in Retrying( + stop=stop_after_delay(max_seconds), + wait=wait_fixed(wait_seconds), + ): + with attempt: + batches_list = self.openai_client.batches.list( + limit=10, + extra_query={"target_model_names": model_name}, + ) + print( + f"Batches in list: {len(batches_list.data)}", + ) + if len(batches_list.data) == 0: + raise Exception("No batches found in list yet") + return batches_list + return None + + @pytest.mark.parametrize("model_name", MODEL_NAMES, ids=MODEL_IDS) + def test_e2e_managed_batch(self, tmp_path, model_name): + print( + f"\n\nStarting test with base_url={self.base_url} and model_name={model_name}\n", + ) + + self.reset_mock_server() + + request_file = self.create_batch_request_file_on_disk(tmp_path, model_name) + + print("Creating batch input file...") + batch_input_file = self.create_batch_input_file( + self.openai_client, + request_file, + MIN_EXPIRY_SECONDS, + ) + print(f"Created batch input file: {self.shorten_id(batch_input_file.id)}\n") + + print( + f"Retrieving batch input file metadata for file id: {self.shorten_id(batch_input_file.id)}", + ) + input_file_metadata = self.openai_client.files.retrieve(batch_input_file.id) + assert input_file_metadata.id == batch_input_file.id, "File ID mismatch" + assert input_file_metadata.object == "file", "object should be 'file'" + assert input_file_metadata.bytes > 0, "bytes not set" + assert input_file_metadata.filename == "modified_file.jsonl", ( + "filename mismatch" + ) + assert input_file_metadata.purpose == "batch", "purpose mismatch" + assert input_file_metadata.status in ["uploaded", "processed", "error"], ( + "invalid status" + ) + assert input_file_metadata.created_at > 0, "created_at not set" + if not input_file_metadata.expires_at: + warnings.warn("batch input file expires_at not set") + self.print_file_metadata(input_file_metadata, "Input file") + + print("\nCreating batch...") + batch = self.create_batch( + self.openai_client, + batch_input_file.id, + MIN_EXPIRY_SECONDS, + ) + print(f"Created batch: {self.shorten_id(batch.id)}") + assert batch.id, "No batch ID returned" + assert batch.input_file_id == batch_input_file.id, "File ID mismatch" + assert batch.status in [ + "validating", + "in_progress", + "finalizing", + "completed", + ], "Status mismatch" + if not batch.expires_at: + warnings.warn("batch expires_at not set") + else: + assert batch.expires_at > 0, "batch expires_at not set" + + if not batch.endpoint: + warnings.warn( + "batch.endpoint empty in creation response - Azure API quirk, not a bug", + ) + else: + assert batch.endpoint == "/v1/chat/completions", "endpoint mismatch" + assert batch.completion_window == "24h", "completion_window mismatch" + assert batch.created_at > 0, "created_at not set" + + self.print_batch_metadata(batch) + + print("\nListing batches...") + try: + batches_list = self.wait_for_batch_list( + model_name, + max_seconds=30, + wait_seconds=5, + ) + batches = batches_list.data if batches_list else [] + batch_ids = [b.id for b in batches] + if batch.id not in batch_ids: + warnings.warn( + f"Batch {batch.id} not found in list. batches.list returns raw IDs and not the encoded IDs. raw IDs: {batch_ids}", + ) + except RetryError: + warnings.warn( + "batches.list() returned empty list after retries - known LiteLLM issue with managed batches", + ) + except openai.APIError as e: + pytest.fail(f"batches.list() failed: {e}") + + print( + f"\nWaiting for batch {self.shorten_id(batch.id)} to reach completed state...", + ) + try: + batch_response = self.wait_for_batch_state( + self.openai_client, + batch.id, + "completed", + max_seconds=30 * 60, + wait_seconds=5, + ) + except RetryError: + raise TimeoutError("Timed out waiting for batch to be in state: completed") + + print("\nRetrieving batch output file metadata...") + output_file_metadata = self.openai_client.files.retrieve( + batch_response.output_file_id, + ) + assert output_file_metadata.id == batch_response.output_file_id, ( + "Output file ID mismatch" + ) + assert output_file_metadata.object == "file", "object should be 'file'" + assert output_file_metadata.bytes > 0, "bytes not set" + assert output_file_metadata.filename, "filename not set" + assert output_file_metadata.purpose in ["batch_output", "batch"], ( + "purpose should be batch_output" + ) + assert output_file_metadata.created_at > 0, "created_at not set" + self.print_file_metadata(output_file_metadata, "Output file") + + print("\nFetching batch output file content...") + batch_file_content = self.openai_client.files.content( + batch_response.output_file_id, + ) + assert batch_file_content.text, "No batch file content returned" + assert len(batch_file_content.text) > 0, "Batch file content is empty" + print(f"Output file content ({len(batch_file_content.text)} bytes):") + for line in batch_file_content.text.strip().split("\n")[:3]: + print(f"\t{line}") + + print(f"\nDeleting input file: {self.shorten_id(batch_input_file.id)}") + try: + self.openai_client.files.delete(batch_input_file.id) + except openai.APIError as e: + pytest.fail(f"files.delete() failed: {e}") + + print( + f"\nDeleting output file: {self.shorten_id(batch_response.output_file_id)}", + ) + try: + self.openai_client.files.delete(batch_response.output_file_id) + except openai.APIError as e: + pytest.fail(f"files.delete() failed: {e}") + + print("\nVerifying input file is deleted...") + try: + self.openai_client.files.content(batch_input_file.id) + assert False, f"Input file {batch_input_file.id} exists after deletion" + except (openai.NotFoundError, openai.PermissionDeniedError): + print("Input file correctly not accessible after deletion") + + print("\nVerifying output file is deleted...") + try: + self.openai_client.files.content(batch_response.output_file_id) + assert False, ( + f"Output file {batch_response.output_file_id} exists after deletion" + ) + except (openai.NotFoundError, openai.PermissionDeniedError): + print("Output file correctly not accessible after deletion") + + + diff --git a/tests/batches_tests/test_managed_files_permissions.py b/tests/batches_tests/test_managed_files_permissions.py new file mode 100644 index 00000000000..c4b9e8828f4 --- /dev/null +++ b/tests/batches_tests/test_managed_files_permissions.py @@ -0,0 +1,436 @@ +""" +Test cross-user batch access permissions. + +This test verifies that a batch and related files created by one API key +cannot be accessed, modified, or cancelled by a different API key. + +Reference: https://github.com/BerriAI/litellm/pull/17401/files +""" + +import time + +import httpx +import openai +import pytest + +from test_managed_files_base import ( + ManagedFilesBase, + MODEL_NAMES, + MODEL_IDS, +) + + +BATCH_ROUTES = [ + "/v1/files", + "/files", + "/v1/files/*", + "/files/*", + "/v1/batches", + "/batches", + "/v1/batches/*", + "/batches/*", +] + + +class TestManagedFilesPermissions(ManagedFilesBase): + """Test cases for cross-user batch access permissions. + + Verifies that: + - User A can create and access their own batches and files + - User B cannot access, retrieve, cancel, or delete User A's batches/files + """ + + master_api_key = "sk-1234" + + @classmethod + def setup_class(cls): + cls.admin_client = httpx.Client(base_url=cls.base_url, verify=False) + + @classmethod + def teardown_class(cls): + cls.admin_client.close() + + def user_suffix(self) -> str: + return f"{time.strftime('%Y%m%d%H%M%S')}{int(time.time() * 1000) % 1000:03d}" + + def create_user_and_key(self, user_suffix: str) -> tuple[str, str]: + user_email = f"test-user-{user_suffix}-{self.user_suffix()}@test.com" + + user_response = self.admin_client.post( + "/user/new", + json={ + "user_email": user_email, + "user_alias": user_email, + "user_role": "internal_user", + "auto_create_key": "false", + }, + headers={ + "Authorization": f"Bearer {self.master_api_key}", + "Content-Type": "application/json", + }, + timeout=30, + ) + assert user_response.status_code == 200, ( + f"Failed to create user: {user_response.status_code} - {user_response.text}" + ) + user_data = user_response.json() + user_id = user_data.get("user_id") + + key_response = self.admin_client.post( + "/key/generate", + json={ + "user_id": user_id, + "key_alias": user_email, + "allowed_routes": BATCH_ROUTES, + }, + headers={ + "Authorization": f"Bearer {self.master_api_key}", + "Content-Type": "application/json", + }, + timeout=30, + ) + assert key_response.status_code == 200, ( + f"Failed to create key: {key_response.status_code} - {key_response.text}" + ) + key_data = key_response.json() + api_key = key_data.get("key") + + print(f"Created user {user_email} with key {key_data.get('key_alias')}") + return user_id, api_key + + def create_user_key_and_client( + self, + user_suffix: str, + ) -> tuple[str, str, openai.OpenAI]: + user_id, api_key = self.create_user_and_key(user_suffix) + return user_id, api_key, self.create_openai_client(api_key) + + def create_key_and_client(self, user_id: str, key_suffix: str) -> str: + key_alias = f"additional-key-{key_suffix}-{self.user_suffix()}" + key_response = self.admin_client.post( + "/key/generate", + json={ + "user_id": user_id, + "key_alias": key_alias, + "allowed_routes": BATCH_ROUTES, + }, + headers={ + "Authorization": f"Bearer {self.master_api_key}", + "Content-Type": "application/json", + }, + timeout=30, + ) + assert key_response.status_code == 200, ( + f"Failed to create additional key: {key_response.status_code} - {key_response.text}" + ) + api_key = key_response.json().get("key") + print(f"Created additional key {api_key[:20]}... for user {user_id}") + return api_key, self.create_openai_client(api_key) + + @pytest.mark.parametrize("model_name", MODEL_NAMES, ids=MODEL_IDS) + def test_user_b_cannot_retrieve_user_a_batch(self, tmp_path, model_name): + user_a_id, user_a_key, client_A = self.create_user_key_and_client("a") + user_b_id, user_b_key, client_B = self.create_user_key_and_client("b") + + # User A creates a batch input file and batch + request_file = self.create_batch_request_file_on_disk(tmp_path, model_name) + batch_input_file = self.create_batch_input_file(client_A, request_file) + batch = self.create_batch(client_A, batch_input_file.id) + + # User A retrieves their own batch + batch_a = client_A.batches.retrieve(batch_id=batch.id) + assert batch_a.id == batch.id, ( + "User A should be able to retrieve their own batch" + ) + + # User B cannot retrieve User A's batch + try: + client_B.batches.retrieve(batch_id=batch.id) + pytest.fail("User B should NOT be able to retrieve User A's batch") + except openai.PermissionDeniedError as e: + assert e.status_code == 403 + + @pytest.mark.parametrize("model_name", MODEL_NAMES, ids=MODEL_IDS) + def test_user_b_cannot_cancel_user_a_batch(self, tmp_path, model_name): + user_a_id, user_a_key, client_A = self.create_user_key_and_client("a") + user_b_id, user_b_key, client_B = self.create_user_key_and_client("b") + + # User A creates a batch input file and batch + request_file = self.create_batch_request_file_on_disk(tmp_path, model_name) + batch_input_file = self.create_batch_input_file(client_A, request_file) + batch = self.create_batch(client_A, batch_input_file.id) + + # User B cannot cancel User A's batch + try: + client_B.batches.cancel(batch_id=batch.id) + pytest.fail("User B should NOT be able to cancel User A's batch") + except openai.PermissionDeniedError as e: + assert e.status_code == 403 + + @pytest.mark.parametrize("model_name", MODEL_NAMES, ids=MODEL_IDS) + def test_user_b_cannot_retrieve_user_a_batch_input_file(self, tmp_path, model_name): + user_a_id, user_a_key, client_A = self.create_user_key_and_client("a") + user_b_id, user_b_key, client_B = self.create_user_key_and_client("b") + + # User A creates a batch input file and batch + request_file = self.create_batch_request_file_on_disk(tmp_path, model_name) + batch_input_file = self.create_batch_input_file(client_A, request_file) + + # User A retrieves their own file + file_a = client_A.files.retrieve(file_id=batch_input_file.id) + assert file_a.id == batch_input_file.id, ( + "User A should be able to retrieve their own file" + ) + + # User B cannot retrieve User A's file + try: + client_B.files.retrieve(file_id=batch_input_file.id) + pytest.fail("User B should NOT be able to retrieve User A's file") + except openai.PermissionDeniedError as e: + assert e.status_code == 403 + + @pytest.mark.parametrize("model_name", MODEL_NAMES, ids=MODEL_IDS) + def test_user_b_cannot_download_user_a_batch_input_file_content( + self, + tmp_path, + model_name, + ): + user_a_id, user_a_key, client_A = self.create_user_key_and_client("a") + user_b_id, user_b_key, client_B = self.create_user_key_and_client("b") + + # User A creates a batch input file and batch + request_file = self.create_batch_request_file_on_disk(tmp_path, model_name) + batch_input_file = self.create_batch_input_file(client_A, request_file) + + # User A can download their own file content + content_a = client_A.files.content(file_id=batch_input_file.id) + assert content_a.text, ( + "User A should be able to download their own file content" + ) + + # User B cannot download User A's file content + try: + client_B.files.content(file_id=batch_input_file.id) + pytest.fail("User B should NOT be able to download User A's file content") + except openai.PermissionDeniedError as e: + assert e.status_code == 403 + + @pytest.mark.parametrize("model_name", MODEL_NAMES, ids=MODEL_IDS) + def test_user_b_cannot_delete_user_a_batch_input_file(self, tmp_path, model_name): + user_a_id, user_a_key, client_A = self.create_user_key_and_client("a") + user_b_id, user_b_key, client_B = self.create_user_key_and_client("b") + + # User A creates a batch input file + request_file = self.create_batch_request_file_on_disk(tmp_path, model_name) + batch_input_file = self.create_batch_input_file(client_A, request_file) + + # User B cannot delete User A's file + try: + client_B.files.delete(file_id=batch_input_file.id) + pytest.fail("User B should NOT be able to delete User A's file") + except openai.PermissionDeniedError as e: + assert e.status_code == 403 + + # User A can still retrieve their own file + file_a = client_A.files.retrieve(file_id=batch_input_file.id) + assert file_a.id == batch_input_file.id, "File should still exist" + + # User A can delete their own file + try: + client_A.files.delete(file_id=batch_input_file.id) + except openai.APIError as e: + pytest.fail(f"User A should be able to delete their own file: {e}") + + @pytest.mark.parametrize("model_name", MODEL_NAMES, ids=MODEL_IDS) + def test_user_b_cannot_retrieve_user_a_batch_output_file( + self, + tmp_path, + model_name, + ): + user_a_id, user_a_key, client_A = self.create_user_key_and_client("a") + user_b_id, user_b_key, client_B = self.create_user_key_and_client("b") + + # User A creates a batch input file and batch + request_file = self.create_batch_request_file_on_disk(tmp_path, model_name) + batch_input_file = self.create_batch_input_file(client_A, request_file) + batch = self.create_batch(client_A, batch_input_file.id) + + # Wait for batch to complete + completed_batch = self.wait_for_batch_completed(client_A, batch.id) + assert completed_batch.output_file_id, "Batch should have an output file" + + # User A retrieves their own output file + file_a = client_A.files.retrieve(file_id=completed_batch.output_file_id) + assert file_a.id == completed_batch.output_file_id, ( + "User A should be able to retrieve their own output file" + ) + + # User B cannot retrieve User A's output file + try: + client_B.files.retrieve(file_id=completed_batch.output_file_id) + pytest.fail("User B should NOT be able to retrieve User A's output file") + except openai.PermissionDeniedError as e: + assert e.status_code == 403 + + @pytest.mark.parametrize("model_name", MODEL_NAMES, ids=MODEL_IDS) + def test_user_b_cannot_download_user_a_batch_output_file_content( + self, + tmp_path, + model_name, + ): + user_a_id, user_a_key, client_A = self.create_user_key_and_client("a") + user_b_id, user_b_key, client_B = self.create_user_key_and_client("b") + + # User A creates a batch input file and batch + request_file = self.create_batch_request_file_on_disk(tmp_path, model_name) + batch_input_file = self.create_batch_input_file(client_A, request_file) + batch = self.create_batch(client_A, batch_input_file.id) + + # Wait for batch to complete + completed_batch = self.wait_for_batch_completed(client_A, batch.id) + assert completed_batch.output_file_id, "Batch should have an output file" + + # User A can download their own output file content + content_a = client_A.files.content(file_id=completed_batch.output_file_id) + assert content_a.text, ( + "User A should be able to download their own output file content" + ) + + # User B cannot download User A's output file content + try: + client_B.files.content(file_id=completed_batch.output_file_id) + pytest.fail( + "User B should NOT be able to download User A's output file content", + ) + except openai.PermissionDeniedError as e: + assert e.status_code == 403 + + @pytest.mark.parametrize("model_name", MODEL_NAMES, ids=MODEL_IDS) + def test_user_b_cannot_delete_user_a_batch_output_file(self, tmp_path, model_name): + user_a_id, user_a_key, client_A = self.create_user_key_and_client("a") + user_b_id, user_b_key, client_B = self.create_user_key_and_client("b") + + # User A creates a batch input file and batch + request_file = self.create_batch_request_file_on_disk(tmp_path, model_name) + batch_input_file = self.create_batch_input_file(client_A, request_file) + batch = self.create_batch(client_A, batch_input_file.id) + + # Wait for batch to complete + completed_batch = self.wait_for_batch_completed(client_A, batch.id) + assert completed_batch.output_file_id, "Batch should have an output file" + + # User B cannot delete User A's output file + try: + client_B.files.delete(file_id=completed_batch.output_file_id) + pytest.fail("User B should NOT be able to delete User A's output file") + except openai.PermissionDeniedError as e: + assert e.status_code == 403 + + # User A can still retrieve their own output file + file_a = client_A.files.retrieve(file_id=completed_batch.output_file_id) + assert file_a.id == completed_batch.output_file_id, ( + "Output file should still exist" + ) + + # User A can delete their own output file + try: + client_A.files.delete(file_id=completed_batch.output_file_id) + except openai.APIError as e: + pytest.fail(f"User A should be able to delete their own output file: {e}") + + @pytest.mark.parametrize("model_name", MODEL_NAMES, ids=MODEL_IDS) + def test_user_b_cannot_list_user_a_batches(self, tmp_path, model_name): + user_a_id, user_a_key, client_A = self.create_user_key_and_client("a") + user_b_id, user_b_key, client_B = self.create_user_key_and_client("b") + + # User A creates a batch input file and batch + request_file = self.create_batch_request_file_on_disk(tmp_path, model_name) + batch_input_file = self.create_batch_input_file(client_A, request_file) + batch = self.create_batch(client_A, batch_input_file.id) + + # User A can see their own batch in the list + batches_a = client_A.batches.list( + limit=10, + extra_query={"target_model_names": model_name}, + ) + batch_ids_a = [b.id for b in batches_a.data] + assert batch.id in batch_ids_a, "User A should see their own batch in the list" + + # User B's batch list should NOT contain User A's batch + batches_b = client_B.batches.list( + limit=10, + extra_query={"target_model_names": model_name}, + ) + batch_ids_b = [b.id for b in batches_b.data] + assert batch.id not in batch_ids_b, ( + "User B should NOT see User A's batch in the list" + ) + + @pytest.mark.parametrize("model_name", MODEL_NAMES, ids=MODEL_IDS) + def test_user_api_keys_are_interchangeable(self, tmp_path, model_name): + # Create user with 3 keys + user_id, key1, client_Key1 = self.create_user_key_and_client("a") + key2, client_Key2 = self.create_key_and_client(user_id, "a2") + key3, client_Key3 = self.create_key_and_client(user_id, "a3") + + # Key1: Create batch input file and batch + request_file = self.create_batch_request_file_on_disk(tmp_path, model_name) + batch_input_file = self.create_batch_input_file(client_Key1, request_file) + batch = self.create_batch(client_Key1, batch_input_file.id) + + # Key1: Retrieve batch + batch_retrieved = client_Key1.batches.retrieve(batch_id=batch.id) + assert batch_retrieved.id == batch.id, "Key1 should retrieve its own batch" + + # Key2: Wait for batch completion and retrieve output + completed_batch = self.wait_for_batch_completed(client_Key2, batch.id) + assert completed_batch.output_file_id, "Batch should have an output file" + + # Key2: Retrieve output file metadata + output_file = client_Key2.files.retrieve(file_id=completed_batch.output_file_id) + assert output_file.id == completed_batch.output_file_id, ( + "Key2 should retrieve output file" + ) + + # Key2: Download output file content + output_content = client_Key2.files.content( + file_id=completed_batch.output_file_id, + ) + assert output_content.text, "Key2 should download output file content" + + # Key3: List batches and verify batch is visible + batches = client_Key3.batches.list( + limit=10, + extra_query={"target_model_names": model_name}, + ) + batch_ids = [b.id for b in batches.data] + assert batch.id in batch_ids, "Key3 should see batch in list" + + # Key3: Delete input file + try: + client_Key3.files.delete(file_id=batch_input_file.id) + except openai.APIError as e: + pytest.fail(f"Key3 should delete input file: {e}") + + # Key3: Delete output file + try: + client_Key3.files.delete(file_id=completed_batch.output_file_id) + except openai.APIError as e: + pytest.fail(f"Key3 should delete output file: {e}") + + # Key1: Create another batch for cancellation test + request_file2 = self.create_batch_request_file_on_disk(tmp_path, model_name) + batch_input_file2 = self.create_batch_input_file(client_Key1, request_file2) + batch2 = self.create_batch(client_Key1, batch_input_file2.id) + + # Key3: Cancel the batch created by Key1 + try: + cancelled_batch = client_Key3.batches.cancel(batch_id=batch2.id) + assert cancelled_batch.id == batch2.id, ( + "Key3 should cancel batch created by Key1" + ) + except openai.BadRequestError: + pass # Batch may have already completed + + + From 73083a1f5b400f1d9724a84be1e6dbeaa2d56127 Mon Sep 17 00:00:00 2001 From: Ephrim Stanley Date: Tue, 23 Dec 2025 11:54:46 -0500 Subject: [PATCH 012/530] Add end to end integration tests for batches --- BATCH_FIXES_README.md | 174 +++--------------- .../proxy/hooks/managed_files.py | 55 +++--- litellm/proxy/batches_endpoints/endpoints.py | 1 - 3 files changed, 48 insertions(+), 182 deletions(-) diff --git a/BATCH_FIXES_README.md b/BATCH_FIXES_README.md index 205ab2d29a1..e04f18b900f 100644 --- a/BATCH_FIXES_README.md +++ b/BATCH_FIXES_README.md @@ -6,9 +6,8 @@ This document describes bugs found in LiteLLM's managed batch/files functionalit 1. [Bug 1: File Deletion Fails for Batch Output Files](#bug-1-file-deletion-fails-for-batch-output-files) 2. [Bug 2: File Deletion Returns Wrong Response](#bug-2-file-deletion-returns-wrong-response) -3. [Bug 3: Batch Listing Fails with Duplicate Argument](#bug-3-batch-listing-fails-with-duplicate-argument) -4. [Bug 4: File Retrieve Returns None for Batch Output Files](#bug-4-file-retrieve-returns-none-for-batch-output-files) -5. [Mock Server: Azure-like Credential Validation](#mock-server-azure-like-credential-validation) +3. [Bug 3: File Retrieve Returns None for Batch Output Files](#bug-3-file-retrieve-returns-none-for-batch-output-files) +4. [Known Limitation: Error Files Not Retrievable](#known-limitation-error-files-not-retrievable) 6. [Test Setup Instructions](#test-setup-instructions) --- @@ -30,20 +29,6 @@ openai.InternalServerError: Error code: 500 - { **Root Cause:** When LiteLLM stores batch output files in `LiteLLM_ManagedFileTable`, it sets `file_object=None`. However, the Pydantic model requires this field to be a valid `OpenAIFileObject`. -### Patch - -**File:** `litellm/proxy/_types.py`, line ~3759 - -```python -# Before -class LiteLLM_ManagedFileTable(LiteLLMPydanticObjectBase): - file_object: OpenAIFileObject - -# After -class LiteLLM_ManagedFileTable(LiteLLMPydanticObjectBase): - file_object: Optional[OpenAIFileObject] = None # PATCHED -``` - --- ## Bug 2: File Deletion Returns Wrong Response @@ -59,78 +44,9 @@ Exception: LiteLLM Managed File object with id=... not found **Root Cause:** `afile_delete` in `managed_files.py` calls `llm_router.afile_delete()` (which deletes the file at the provider) but discards the response. -### Patch - -**File:** `enterprise/litellm_enterprise/proxy/hooks/managed_files.py`, line ~879 - -```python -# Before -async def afile_delete(self, file_id, ...): - for model_id, model_file_id in mapping.items(): - await llm_router.afile_delete(model=model_id, file_id=model_file_id, **data) - # Returns None when stored_file_object is None - -# After -async def afile_delete(self, file_id, ...): - delete_response = None # PATCHED: Capture response - for model_id, model_file_id in mapping.items(): - delete_response = await llm_router.afile_delete(model=model_id, file_id=model_file_id, **data) - - stored_file_object = await self.delete_unified_file_id(file_id, ...) - if stored_file_object: - return stored_file_object - elif delete_response: # PATCHED: Return provider response - delete_response.id = file_id # Replace with unified ID - return delete_response - else: - raise Exception(...) -``` - --- -## Bug 3: Batch Listing Fails with Duplicate Argument - -### Description - -**Broken Feature:** `GET /batches?target_model_names=...` - Listing batches fails when using `target_model_names` query parameter. - -**Error Message:** -``` -openai.InternalServerError: Error code: 500 - { - 'error': { - 'message': "alist_batches() got multiple values for keyword argument 'model'" - } -} -``` - -**Root Cause:** The code passes `model` explicitly AND includes it in `**data`: -```python -model = target_model_names.split(",")[0] -response = await llm_router.alist_batches( - model=model, # Passed explicitly - **data, # Also contains 'model' and 'target_model_names' keys -) -``` - -### Patch - -**File:** `litellm/proxy/batches_endpoints/endpoints.py`, line ~576-577 - -```python -# Before -model = target_model_names.split(",")[0] -response = await llm_router.alist_batches(model=model, **data) - -# After -model = target_model_names.split(",")[0] -data.pop("model", None) # PATCHED: Remove duplicate -data.pop("target_model_names", None) # PATCHED: Remove to avoid passing to downstream -response = await llm_router.alist_batches(model=model, **data) -``` - ---- - -## Bug 4: File Retrieve Returns None for Batch Output Files +## Bug 3: File Retrieve Returns None for Batch Output Files ### Description @@ -143,71 +59,29 @@ AttributeError: 'NoneType' object has no attribute 'id' **Root Cause:** `afile_retrieve` returns `stored_file_object.file_object` which is `None` for batch output files. It should fetch the file metadata from the provider instead. -### Patch (Part A) - -**File:** `enterprise/litellm_enterprise/proxy/hooks/managed_files.py`, line ~839-868 - -Add `import litellm` at the top of the file, then modify `afile_retrieve`: - -```python -# Before -async def afile_retrieve(self, file_id, litellm_parent_otel_span): - stored = await self.get_unified_file_id(file_id, ...) - return stored.file_object # Returns None for batch output files! - -# After -import litellm # Added at top of file - -async def afile_retrieve(self, file_id, litellm_parent_otel_span, llm_router=None): # PATCHED: Added llm_router - stored = await self.get_unified_file_id(file_id, ...) - if stored: - if stored.file_object: - return stored.file_object - # PATCHED: Fetch from provider when file_object is None - elif stored.model_mappings and llm_router: - for model_id, model_file_id in stored.model_mappings.items(): - deployment = llm_router.get_deployment(model_id=model_id) - if deployment: - credentials = llm_router.get_deployment_credentials(model_id=model_id) or {} - # Extract custom_llm_provider - afile_retrieve needs it as explicit param - custom_llm_provider = credentials.pop("custom_llm_provider", None) - if not custom_llm_provider: - # Infer from model name (e.g., "azure/gpt-5" -> "azure") - model_name = deployment.litellm_params.model or "" - if "/" in model_name: - custom_llm_provider = model_name.split("/")[0] - else: - custom_llm_provider = "openai" - response = await litellm.afile_retrieve( - file_id=model_file_id, - custom_llm_provider=custom_llm_provider, # Explicit param for Azure - **credentials - ) - response.id = file_id # Replace with unified ID - return response -``` - -### Patch (Part B) - -**File:** `litellm/proxy/openai_files_endpoints/files_endpoints.py`, line ~888 - -```python -# Before -response = await managed_files_obj.afile_retrieve( - file_id=file_id, - litellm_parent_otel_span=user_api_key_dict.parent_otel_span, -) - -# After -response = await managed_files_obj.afile_retrieve( - file_id=file_id, - litellm_parent_otel_span=user_api_key_dict.parent_otel_span, - llm_router=llm_router, # PATCHED: Pass router to fetch from provider -) -``` - --- +## Known Limitation: Error Files Not Retrievable + +### Description + +When a batch fails, the provider returns an `error_file_id` containing details about failed requests. Currently, **error files are NOT retrievable** through the managed files API (`GET /files/{file_id}`). + +### Root Cause + +Only `output_file_id` is stored in `LiteLLM_ManagedFileTable` when a batch completes. The `error_file_id` is encoded in the batch response but never stored in the managed files table. + +**In `async_post_call_success_hook`:** +```python +# Only output_file_id is handled: +if response.output_file_id and model_id: + await self.store_unified_file_id( + file_id=response.output_file_id, + ... + ) +# error_file_id is NOT stored +``` + ## Test Setup Instructions ### Prerequisites diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index 1afaee30c74..37eef176927 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -842,38 +842,31 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): stored_file_object = await self.get_unified_file_id( file_id, litellm_parent_otel_span ) - if stored_file_object: - # PATCHED: If file_object is None (batch output files), fetch from provider - if stored_file_object.file_object: - return stored_file_object.file_object - elif stored_file_object.model_mappings and llm_router: - for model_id, model_file_id in stored_file_object.model_mappings.items(): - # PATCHED: Get deployment info and credentials from router - deployment = llm_router.get_deployment(model_id=model_id) - if deployment: - credentials = llm_router.get_deployment_credentials(model_id=model_id) or {} - # Extract custom_llm_provider - afile_retrieve needs it as explicit param - custom_llm_provider = credentials.pop("custom_llm_provider", None) - if not custom_llm_provider: - # Infer from model name (e.g., "azure/gpt-5" -> "azure") - model_name = deployment.litellm_params.model or "" - if "/" in model_name: - custom_llm_provider = model_name.split("/")[0] - else: - custom_llm_provider = "openai" - response = await litellm.afile_retrieve( - file_id=model_file_id, - custom_llm_provider=custom_llm_provider, - **credentials - ) - response.id = file_id # Replace with unified ID - return response - else: - raise Exception(f"No deployment found for model_id={model_id}") - else: - raise Exception(f"LiteLLM Managed File object with id={file_id} has no file_object, or no model_mappings/llm_router to fetch from provider") - else: + + # Case 1 : This is not a managed file + if not stored_file_object: raise Exception(f"LiteLLM Managed File object with id={file_id} not found") + + # Case 2: Managed file and the file object exists in the database + if stored_file_object and stored_file_object.file_object: + return stored_file_object.file_object + + # Case 3: Managed file exists in the database but not the file object (for. e.g the batch task might not have run) + # So we fetch the file object from the provider. We deliberately do not store the result to avoid interfering with batch cost tracking code. + if not llm_router: + raise Exception( + f"LiteLLM Managed File object with id={file_id} has no file_object " + f"and llm_router is required to fetch from provider" + ) + + try: + model_id, model_file_id = next(iter(stored_file_object.model_mappings.items())) + credentials = llm_router.get_deployment_credentials_with_provider(model_id) or {} + response = await litellm.afile_retrieve(file_id=model_file_id, **credentials) + response.id = file_id # Replace with unified ID + return response + except Exception as e: + raise Exception(f"Failed to retrieve file {file_id} from provider: {str(e)}") from e async def afile_list( self, diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index dd68a54f694..086105042e8 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -574,7 +574,6 @@ async def list_batches( raise ValueError("target_model_names is required for this routing scenario") model = target_model_names.split(",")[0] data.pop("model", None) - data.pop("target_model_names", None) # PATCHED: Remove to avoid passing to downstream response = await llm_router.alist_batches( model=model, after=after, From 479e40672ecd745e493065020c76ac5aa31819a6 Mon Sep 17 00:00:00 2001 From: Ephrim Stanley Date: Tue, 23 Dec 2025 12:55:09 -0500 Subject: [PATCH 013/530] Add end to end integration tests for batches --- .../proxy/hooks/managed_files.py | 2 - litellm/proxy/_types.py | 2 +- .../openai_files_endpoints/files_endpoints.py | 2 +- .../proxy/hooks/test_managed_files.py | 244 ++++++++++++++++++ 4 files changed, 246 insertions(+), 4 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index 37eef176927..445d2b242b4 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -890,7 +890,6 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): [file_id], litellm_parent_otel_span ) - # PATCHED: Capture delete response from provider delete_response = None specific_model_file_id_mapping = model_file_id_mapping.get(file_id) if specific_model_file_id_mapping: @@ -903,7 +902,6 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): if stored_file_object: return stored_file_object - # PATCHED: Return provider response with unified ID when stored_file_object is None elif delete_response: delete_response.id = file_id return delete_response diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 9767e07bbc8..3865a4c65bc 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3756,7 +3756,7 @@ class SpendUpdateQueueItem(TypedDict, total=False): class LiteLLM_ManagedFileTable(LiteLLMPydanticObjectBase): unified_file_id: str - file_object: Optional[OpenAIFileObject] = None # PATCHED: Allow None for batch output files + file_object: Optional[OpenAIFileObject] = None model_mappings: Dict[str, str] flat_model_file_ids: List[str] created_by: Optional[str] diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 586f7840835..7e3f5820814 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -885,7 +885,7 @@ async def get_file( response = await managed_files_obj.afile_retrieve( file_id=file_id, litellm_parent_otel_span=user_api_key_dict.parent_otel_span, - llm_router=llm_router, # PATCHED: Pass router to fetch from provider if file_object is None + llm_router=llm_router, ) else: response = await litellm.afile_retrieve( diff --git a/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py b/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py index 5f66b03aad4..82c6821eb02 100644 --- a/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py +++ b/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py @@ -590,3 +590,247 @@ def test_update_responses_input_with_multiple_file_ids(): assert updated_input[0]["content"][2]["file_id"] == regular_file_id # Verify text content was preserved assert updated_input[0]["content"][1]["text"] == "Compare these files" + + +@pytest.mark.asyncio +async def test_store_unified_file_id_with_none_file_object(): + """ + Test that store_unified_file_id works when file_object is None + (e.g., for batch output files that are stored before file metadata is available). + """ + from litellm.proxy._types import UserAPIKeyAuth + + prisma_client = AsyncMock() + prisma_client.db.litellm_managedfiletable.create = AsyncMock(return_value=MagicMock()) + internal_usage_cache = MagicMock() + internal_usage_cache.async_set_cache = AsyncMock() + + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + internal_usage_cache=internal_usage_cache, + prisma_client=prisma_client, + ) + + # Store with file_object=None (simulating batch output file storage) + await proxy_managed_files.store_unified_file_id( + file_id="test-unified-file-id", + file_object=None, + litellm_parent_otel_span=None, + model_mappings={"model-123": "file-provider-xyz"}, + user_api_key_dict=UserAPIKeyAuth(user_id="test-user"), + ) + + # Verify DB create was called with expected data (without file_object) + prisma_client.db.litellm_managedfiletable.create.assert_called_once() + call_args = prisma_client.db.litellm_managedfiletable.create.call_args + assert call_args.kwargs["data"]["unified_file_id"] == "test-unified-file-id" + assert "file_object" not in call_args.kwargs["data"] + + +@pytest.mark.asyncio +async def test_afile_delete_returns_provider_response_when_stored_file_object_none(): + """ + Test that afile_delete returns the provider's delete response when the + stored file_object is None (e.g., for batch output files). + """ + from litellm.types.llms.openai import OpenAIFileObject + + unified_file_id = "bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9qc29uO3VuaWZpZWRfaWQsdGVzdC1pZDt0YXJnZXRfbW9kZWxfbmFtZXMsZ3B0LTRvO2xsbV9vdXRwdXRfZmlsZV9pZCxmaWxlLXByb3ZpZGVyLXh5ejtsbG1fb3V0cHV0X2ZpbGVfbW9kZWxfaWQsbW9kZWwtMTIz" + + prisma_client = AsyncMock() + db_record = MagicMock() + db_record.model_mappings = '{"model-123": "file-provider-xyz"}' + prisma_client.db.litellm_managedfiletable.find_first = AsyncMock(return_value=db_record) + prisma_client.db.litellm_managedfiletable.delete = AsyncMock() + + internal_usage_cache = MagicMock() + internal_usage_cache.async_get_cache = AsyncMock(return_value={ + "unified_file_id": unified_file_id, + "model_mappings": {"model-123": "file-provider-xyz"}, + "flat_model_file_ids": ["file-provider-xyz"], + "file_object": None, + "created_by": "test-user", + "updated_by": "test-user", + }) + internal_usage_cache.async_set_cache = AsyncMock() + + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + internal_usage_cache=internal_usage_cache, + prisma_client=prisma_client, + ) + + # Mock the delete_unified_file_id to return None (simulating file_object=None) + proxy_managed_files.delete_unified_file_id = AsyncMock(return_value=None) + + # Mock router response + provider_delete_response = OpenAIFileObject( + id="file-provider-xyz", + object="file", + bytes=1234, + created_at=1234567890, + filename="test.jsonl", + purpose="batch", + ) + + mock_router = MagicMock() + mock_router.afile_delete = AsyncMock(return_value=provider_delete_response) + + result = await proxy_managed_files.afile_delete( + file_id=unified_file_id, + litellm_parent_otel_span=None, + llm_router=mock_router, + ) + + # Should return the provider response with the unified file ID + assert result is not None + assert result.id == unified_file_id + + +@pytest.mark.asyncio +async def test_afile_retrieve_fetches_from_provider_when_file_object_none(): + """ + Test that afile_retrieve fetches from the provider when the stored + file_object is None (e.g., for batch output files). + """ + from litellm.types.llms.openai import OpenAIFileObject + + prisma_client = AsyncMock() + internal_usage_cache = MagicMock() + + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + internal_usage_cache=internal_usage_cache, + prisma_client=prisma_client, + ) + + # Mock get_unified_file_id to return a stored object with file_object=None + stored_file = MagicMock() + stored_file.file_object = None + stored_file.model_mappings = {"model-123": "file-provider-xyz"} + proxy_managed_files.get_unified_file_id = AsyncMock(return_value=stored_file) + + # Mock the router and provider response + provider_file_response = OpenAIFileObject( + id="file-provider-xyz", + object="file", + bytes=5678, + created_at=1234567890, + filename="output.jsonl", + purpose="batch_output", + ) + + mock_router = MagicMock() + mock_router.get_deployment_credentials_with_provider = MagicMock(return_value={ + "api_key": "test-key", + "api_base": "https://api.openai.com", + }) + + with patch("litellm.afile_retrieve", new_callable=AsyncMock) as mock_afile_retrieve: + mock_afile_retrieve.return_value = provider_file_response + + unified_file_id = "test-unified-file-id" + result = await proxy_managed_files.afile_retrieve( + file_id=unified_file_id, + litellm_parent_otel_span=None, + llm_router=mock_router, + ) + + # Should return the provider response with the unified file ID + assert result is not None + assert result.id == unified_file_id + mock_afile_retrieve.assert_called_once() + + +@pytest.mark.asyncio +async def test_afile_retrieve_raises_error_when_no_router_and_file_object_none(): + """ + Test that afile_retrieve raises an appropriate error when file_object is None + and no llm_router is provided to fetch from the provider. + """ + prisma_client = AsyncMock() + internal_usage_cache = MagicMock() + + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + internal_usage_cache=internal_usage_cache, + prisma_client=prisma_client, + ) + + # Mock get_unified_file_id to return a stored object with file_object=None + stored_file = MagicMock() + stored_file.file_object = None + stored_file.model_mappings = {"model-123": "file-provider-xyz"} + proxy_managed_files.get_unified_file_id = AsyncMock(return_value=stored_file) + + unified_file_id = "test-unified-file-id" + + with pytest.raises(Exception) as exc_info: + await proxy_managed_files.afile_retrieve( + file_id=unified_file_id, + litellm_parent_otel_span=None, + llm_router=None, + ) + + assert "llm_router is required" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_afile_retrieve_returns_stored_file_object_when_exists(): + """ + Test that afile_retrieve returns the stored file_object directly when it exists + (the normal case for user-uploaded files). + """ + from litellm.types.llms.openai import OpenAIFileObject + + prisma_client = AsyncMock() + internal_usage_cache = MagicMock() + + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + internal_usage_cache=internal_usage_cache, + prisma_client=prisma_client, + ) + + # Mock get_unified_file_id to return a stored object WITH file_object + stored_file_object = OpenAIFileObject( + id="test-unified-file-id", + object="file", + bytes=1234, + created_at=1234567890, + filename="input.jsonl", + purpose="batch", + ) + stored_file = MagicMock() + stored_file.file_object = stored_file_object + proxy_managed_files.get_unified_file_id = AsyncMock(return_value=stored_file) + + result = await proxy_managed_files.afile_retrieve( + file_id="test-unified-file-id", + litellm_parent_otel_span=None, + llm_router=None, + ) + + # Should return the stored file object directly + assert result == stored_file_object + + +@pytest.mark.asyncio +async def test_afile_retrieve_raises_error_for_non_managed_file(): + """ + Test that afile_retrieve raises an error when the file_id is not found + in the managed files table. + """ + prisma_client = AsyncMock() + internal_usage_cache = MagicMock() + + proxy_managed_files = _PROXY_LiteLLMManagedFiles( + internal_usage_cache=internal_usage_cache, + prisma_client=prisma_client, + ) + + # Mock get_unified_file_id to return None (file not found) + proxy_managed_files.get_unified_file_id = AsyncMock(return_value=None) + + with pytest.raises(Exception) as exc_info: + await proxy_managed_files.afile_retrieve( + file_id="non-existent-file-id", + litellm_parent_otel_span=None, + ) + + assert "not found" in str(exc_info.value) From 103633c79485acceedf813611bcc0c27c4995579 Mon Sep 17 00:00:00 2001 From: Ephrim Stanley Date: Tue, 23 Dec 2025 12:59:22 -0500 Subject: [PATCH 014/530] Fix linter errors: remove unused imports and variables --- .../proxy/hooks/test_managed_files.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py b/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py index 82c6821eb02..9a6e153a22b 100644 --- a/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py +++ b/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py @@ -1,18 +1,14 @@ import json -import os -import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException -from fastapi.testclient import TestClient from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles from litellm.caching import DualCache from litellm.proxy.openai_files_endpoints.common_utils import ( _is_base64_encoded_unified_file_id, ) -from litellm.types.utils import SpecialEnums def test_get_file_ids_from_messages(): @@ -255,7 +251,7 @@ async def test_can_user_call_unified_file_id(call_type): ) unified_file_id = "bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9vY3RldC1zdHJlYW07dW5pZmllZF9pZCxmMTNlNDAzZS01YWM3LTRhZjktOGQzNS0wNDgwZDMxOTgyYTg7dGFyZ2V0X21vZGVsX25hbWVzLGdwdC00by1taW5pLW9wZW5haTtsbG1fb3V0cHV0X2ZpbGVfaWQsZmlsZS1Ib3UxZDFXc3c1SDNKcjFMYllpZDJiO2xsbV9vdXRwdXRfZmlsZV9tb2RlbF9pZCxmODBiNWU2NzQ1NzdkNjkyMjM4YmVhNTIxZDdiMGI5ZGYyY2FmMTEwMTU2YmU5YzBjM2NjMmNkNTBjOTM1ZDI0" - with pytest.raises(HTTPException) as e: + with pytest.raises(HTTPException): await proxy_managed_files.async_pre_call_hook( user_api_key_dict=UserAPIKeyAuth( user_id="456", parent_otel_span=MagicMock() @@ -310,7 +306,7 @@ async def test_router_acreate_batch_only_selects_from_file_id_mapping(monkeypatc litellm, "acreate_batch", return_value=AsyncMock() ) as mock_acreate_batch: for _ in range(1000): - response = await router.acreate_batch( + await router.acreate_batch( model="gpt-3.5-turbo", input_file_id=file_id, model_file_id_mapping=model_file_id_mapping, @@ -329,7 +325,6 @@ async def test_output_file_id_for_batch_retrieve(): from openai.types.batch import BatchRequestCounts - from litellm.proxy._types import UserAPIKeyAuth from litellm.types.utils import LiteLLMBatch batch = LiteLLMBatch( @@ -381,8 +376,6 @@ async def test_output_file_id_for_batch_retrieve(): @pytest.mark.asyncio async def test_async_post_call_success_hook_twice_assert_no_unique_violation(): import asyncio - from litellm.proxy.proxy_server import proxy_logging_obj - from litellm.proxy.utils import PrismaClient from litellm.types.utils import LiteLLMBatch from litellm.proxy._types import UserAPIKeyAuth from openai.types.batch import BatchRequestCounts From b33f1ec2b215bd97d800a98004e6aeaa5d8d21ce Mon Sep 17 00:00:00 2001 From: hamzaq453 Date: Sun, 28 Dec 2025 19:28:11 +0500 Subject: [PATCH 015/530] Fix: Remove exec() usage and handle invalid OpenAPI parameter names - Add to_safe_identifier() to convert any parameter name to valid Python identifier - Refactor create_tool_function() to use closure with **kwargs instead of exec() - Handle edge cases: hyphens, dots, leading digits, Python keywords, special chars - Add comprehensive test suite covering all edge cases - Fixes #18471: OpenAPI MCP server crashes on invalid parameter names - Security: Eliminates arbitrary code execution risk from untrusted OpenAPI specs --- .../mcp_server/openapi_to_mcp_generator.py | 234 +++++--- .../test_openapi_to_mcp_generator.py | 528 ++++++++++++++++++ 2 files changed, 695 insertions(+), 67 deletions(-) create mode 100644 tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py index 72288f8e673..dc5d0ce73af 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -3,6 +3,8 @@ This module is used to generate MCP tools from OpenAPI specs. """ import json +import keyword +import re from typing import Any, Dict, Optional import httpx @@ -17,6 +19,64 @@ BASE_URL = "" HEADERS: Dict[str, str] = {} +def to_safe_identifier(name: str) -> str: + """ + Convert an OpenAPI parameter name to a safe Python identifier. + + This function ensures that any parameter name from an OpenAPI spec can be + used as a Python function parameter without causing syntax errors or + security issues. It handles: + - Hyphens, dots, and other special characters + - Leading digits + - Python keywords + - Special characters like $, @, etc. + + Args: + name: The original parameter name from the OpenAPI spec + + Returns: + A valid Python identifier that can be used in function signatures + + Examples: + >>> to_safe_identifier("repository-id") + 'repository_id' + >>> to_safe_identifier("2fa-code") + '_2fa_code' + >>> to_safe_identifier("user.name") + 'user_name' + >>> to_safe_identifier("$filter") + '_filter' + >>> to_safe_identifier("class") + 'class_' + """ + if not name: + return "_empty_" + + # Start with underscore if first char is not a letter + # Replace all non-alphanumeric chars (except underscore) with underscore + # Collapse multiple underscores + safe = re.sub(r'[^a-zA-Z0-9_]', '_', name) + safe = re.sub(r'_+', '_', safe) # Collapse multiple underscores + + # If starts with digit, prefix with underscore + if safe and safe[0].isdigit(): + safe = '_' + safe + + # If empty after sanitization, use a default + if not safe: + safe = '_param_' + + # If it's a Python keyword, append underscore + if keyword.iskeyword(safe): + safe = safe + '_' + + # Ensure it doesn't start with a digit (shouldn't happen after above, but double-check) + if safe and safe[0].isdigit(): + safe = '_' + safe + + return safe + + def load_openapi_spec(filepath: str) -> Dict[str, Any]: """Load OpenAPI specification from JSON file.""" with open(filepath, "r") as f: @@ -112,12 +172,20 @@ def create_tool_function( ): """Create a tool function for an OpenAPI operation. + This function creates an async tool function that can be called with + keyword arguments. Parameter names from the OpenAPI spec are safely + mapped to valid Python identifiers to avoid syntax errors and security + issues. + Args: path: API endpoint path method: HTTP method (get, post, put, delete, patch) operation: OpenAPI operation object base_url: Base URL for the API headers: Optional headers to include in requests (e.g., authentication) + + Returns: + An async function that accepts **kwargs and makes the HTTP request """ if headers is None: headers = {} @@ -125,77 +193,109 @@ def create_tool_function( path_params, query_params, body_params = extract_parameters(operation) all_params = path_params + query_params + body_params - # Build function signature dynamically - if all_params: - params_str = ", ".join(f"{p}: str = ''" for p in all_params) - else: - params_str = "" - - # Create the function code as a string - func_code = f''' -async def tool_function({params_str}) -> str: - """Dynamically generated tool function.""" - url = base_url + path + # Create mapping from original parameter names to safe identifiers + # This allows us to accept kwargs with original names but use safe names internally + param_name_map: Dict[str, str] = {} + safe_to_original_map: Dict[str, str] = {} - # Replace path parameters - path_param_names = {path_params} - for param_name in path_param_names: - param_value = locals().get(param_name, "") - if param_value: - url = url.replace("{{" + param_name + "}}", str(param_value)) - - # Build query params - query_param_names = {query_params} - params = {{}} - for param_name in query_param_names: - param_value = locals().get(param_name, "") - if param_value: - params[param_name] = param_value - - # Build request body - body_param_names = {body_params} - json_body = None - if body_param_names: - body_value = locals().get("body", {{}}) - if isinstance(body_value, dict): - json_body = body_value - elif body_value: - # If it's a string, try to parse as JSON - import json as json_module - try: - json_body = json_module.loads(body_value) if isinstance(body_value, str) else {{"data": body_value}} - except: - json_body = {{"data": body_value}} - - # Make HTTP request - async with httpx.AsyncClient() as client: - if "{method.lower()}" == "get": - response = await client.get(url, params=params, headers=headers) - elif "{method.lower()}" == "post": - response = await client.post(url, params=params, json=json_body, headers=headers) - elif "{method.lower()}" == "put": - response = await client.put(url, params=params, json=json_body, headers=headers) - elif "{method.lower()}" == "delete": - response = await client.delete(url, params=params, headers=headers) - elif "{method.lower()}" == "patch": - response = await client.patch(url, params=params, json=json_body, headers=headers) - else: - return "Unsupported HTTP method: {method}" + for orig_name in all_params: + safe_name = to_safe_identifier(orig_name) + # Handle collisions: if safe name already exists, append a counter + counter = 1 + original_safe = safe_name + while safe_name in safe_to_original_map: + safe_name = f"{original_safe}_{counter}" + counter += 1 - return response.text -''' + param_name_map[orig_name] = safe_name + safe_to_original_map[safe_name] = orig_name - # Execute the function code to create the actual function - local_vars = { - "httpx": httpx, - "headers": headers, - "base_url": base_url, - "path": path, - "method": method, - } - exec(func_code, local_vars) + # Store original parameter lists for use in the closure + original_path_params = path_params + original_query_params = query_params + original_body_params = body_params + original_method = method.lower() - return local_vars["tool_function"] + async def tool_function(**kwargs: Any) -> str: + """ + Dynamically generated tool function. + + Accepts keyword arguments where keys are the original OpenAPI parameter names. + The function safely handles parameter names that aren't valid Python identifiers. + """ + # Build URL from base_url and path + url = base_url + path + + # Replace path parameters using original names from OpenAPI spec + for orig_param_name in original_path_params: + # Try to get value using original name first, then safe name + param_value = kwargs.get(orig_param_name, "") + if not param_value and orig_param_name in param_name_map: + safe_name = param_name_map[orig_param_name] + param_value = kwargs.get(safe_name, "") + + if param_value: + # Replace {param_name} or {{param_name}} in URL + url = url.replace("{" + orig_param_name + "}", str(param_value)) + url = url.replace("{{" + orig_param_name + "}}", str(param_value)) + + # Build query params using original parameter names + params: Dict[str, Any] = {} + for orig_param_name in original_query_params: + # Try to get value using original name first, then safe name + param_value = kwargs.get(orig_param_name, "") + if not param_value and orig_param_name in param_name_map: + safe_name = param_name_map[orig_param_name] + param_value = kwargs.get(safe_name, "") + + if param_value: + # Use original parameter name in query string (as expected by API) + params[orig_param_name] = param_value + + # Build request body + json_body: Optional[Dict[str, Any]] = None + if original_body_params: + # Try "body" first (most common), then check all body param names + body_value = kwargs.get("body", {}) + if not body_value: + for orig_param_name in original_body_params: + body_value = kwargs.get(orig_param_name, {}) + if body_value: + break + # Also try safe name + if orig_param_name in param_name_map: + safe_name = param_name_map[orig_param_name] + body_value = kwargs.get(safe_name, {}) + if body_value: + break + + if isinstance(body_value, dict): + json_body = body_value + elif body_value: + # If it's a string, try to parse as JSON + try: + json_body = json.loads(body_value) if isinstance(body_value, str) else {"data": body_value} + except (json.JSONDecodeError, TypeError): + json_body = {"data": body_value} + + # Make HTTP request + async with httpx.AsyncClient() as client: + if original_method == "get": + response = await client.get(url, params=params, headers=headers) + elif original_method == "post": + response = await client.post(url, params=params, json=json_body, headers=headers) + elif original_method == "put": + response = await client.put(url, params=params, json=json_body, headers=headers) + elif original_method == "delete": + response = await client.delete(url, params=params, headers=headers) + elif original_method == "patch": + response = await client.patch(url, params=params, json=json_body, headers=headers) + else: + return f"Unsupported HTTP method: {original_method}" + + return response.text + + return tool_function def register_tools_from_openapi(spec: Dict[str, Any], base_url: str): diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py new file mode 100644 index 00000000000..fa28dc34981 --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py @@ -0,0 +1,528 @@ +""" +Tests for OpenAPI to MCP generator, focusing on security and edge cases. + +This test suite ensures that: +1. Parameter names with invalid Python identifiers are handled safely +2. No exec() is used (security) +3. All edge cases (hyphens, dots, keywords, special chars) work correctly +""" + +import json +import pytest +from unittest.mock import AsyncMock, patch +from typing import Dict, Any + +from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( + to_safe_identifier, + create_tool_function, + build_input_schema, + extract_parameters, +) + + +class TestToSafeIdentifier: + """Test the to_safe_identifier function for various edge cases.""" + + def test_hyphen_in_name(self): + """Test parameter names with hyphens.""" + assert to_safe_identifier("repository-id") == "repository_id" + assert to_safe_identifier("user-name") == "user_name" + assert to_safe_identifier("api-key") == "api_key" + + def test_leading_digit(self): + """Test parameter names starting with digits.""" + assert to_safe_identifier("2fa-code") == "_2fa_code" + assert to_safe_identifier("123abc") == "_123abc" + assert to_safe_identifier("0test") == "_0test" + + def test_dots_in_name(self): + """Test parameter names with dots.""" + assert to_safe_identifier("user.name") == "user_name" + assert to_safe_identifier("config.value") == "config_value" + assert to_safe_identifier("api.v2") == "api_v2" + + def test_dollar_sign(self): + """Test parameter names with dollar signs (OData style).""" + assert to_safe_identifier("$filter") == "_filter" + assert to_safe_identifier("$context") == "_context" + assert to_safe_identifier("$select") == "_select" + + def test_python_keywords(self): + """Test Python keywords are handled.""" + assert to_safe_identifier("class") == "class_" + assert to_safe_identifier("from") == "from_" + assert to_safe_identifier("not") == "not_" + assert to_safe_identifier("def") == "def_" + assert to_safe_identifier("import") == "import_" + + def test_special_characters(self): + """Test various special characters.""" + assert to_safe_identifier("user@domain") == "user_domain" + assert to_safe_identifier("test#hash") == "test_hash" + assert to_safe_identifier("path/to/resource") == "path_to_resource" + assert to_safe_identifier("param+value") == "param_value" + + def test_multiple_special_chars(self): + """Test names with multiple special characters.""" + assert to_safe_identifier("user-name.email@domain") == "user_name_email_domain" + assert to_safe_identifier("$filter.value") == "_filter_value" + + def test_already_valid_identifier(self): + """Test that valid identifiers remain unchanged (except keywords).""" + assert to_safe_identifier("valid_name") == "valid_name" + assert to_safe_identifier("validName123") == "validName123" + assert to_safe_identifier("_private") == "_private" + + def test_empty_string(self): + """Test empty string handling.""" + assert to_safe_identifier("") == "_empty_" + + def test_only_special_chars(self): + """Test names that are only special characters.""" + result = to_safe_identifier("---") + assert result.startswith("_") + assert len(result) > 0 + + def test_collision_handling(self): + """Test that similar names produce different safe identifiers.""" + # These should produce different results + name1 = to_safe_identifier("user-name") + name2 = to_safe_identifier("user_name") + # They might be the same after sanitization, which is acceptable + # The important thing is they're both valid identifiers + + +class TestCreateToolFunction: + """Test create_tool_function with various parameter name edge cases.""" + + @pytest.mark.asyncio + async def test_hyphenated_path_parameter(self): + """Test function with hyphenated path parameter (e.g., repository-id).""" + operation = { + "parameters": [ + { + "name": "repository-id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ] + } + + func = create_tool_function( + path="/repos/{repository-id}", + method="get", + operation=operation, + base_url="https://api.example.com", + ) + + # Should not raise SyntaxError + assert callable(func) + assert func.__name__ == "tool_function" + + # Test calling with original parameter name + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.text = '{"id": "123"}' + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + result = await func(**{"repository-id": "test-repo"}) + assert result == '{"id": "123"}' + + # Verify URL was constructed correctly + call_args = mock_client.return_value.__aenter__.return_value.get.call_args + assert "repository-id" in str(call_args[0][0]) or "test-repo" in str(call_args[0][0]) + + @pytest.mark.asyncio + async def test_leading_digit_parameter(self): + """Test function with parameter starting with digit (e.g., 2fa-code).""" + operation = { + "parameters": [ + { + "name": "2fa-code", + "in": "query", + "required": False, + "schema": {"type": "string"}, + } + ] + } + + func = create_tool_function( + path="/verify", + method="post", + operation=operation, + base_url="https://api.example.com", + ) + + assert callable(func) + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.text = "verified" + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + + result = await func(**{"2fa-code": "123456"}) + assert result == "verified" + + # Verify query parameter was included + call_args = mock_client.return_value.__aenter__.return_value.post.call_args + assert call_args[1]["params"]["2fa-code"] == "123456" + + @pytest.mark.asyncio + async def test_dot_in_parameter_name(self): + """Test function with dot in parameter name (e.g., user.name).""" + operation = { + "parameters": [ + { + "name": "user.name", + "in": "query", + "required": False, + "schema": {"type": "string"}, + } + ] + } + + func = create_tool_function( + path="/search", + method="get", + operation=operation, + base_url="https://api.example.com", + ) + + assert callable(func) + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.text = "found" + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + result = await func(**{"user.name": "john.doe"}) + assert result == "found" + + call_args = mock_client.return_value.__aenter__.return_value.get.call_args + assert call_args[1]["params"]["user.name"] == "john.doe" + + @pytest.mark.asyncio + async def test_dollar_sign_parameter(self): + """Test function with dollar sign parameter (OData style, e.g., $filter).""" + operation = { + "parameters": [ + { + "name": "$filter", + "in": "query", + "required": False, + "schema": {"type": "string"}, + } + ] + } + + func = create_tool_function( + path="/entities", + method="get", + operation=operation, + base_url="https://api.example.com", + ) + + assert callable(func) + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.text = "[]" + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + result = await func(**{"$filter": "name eq 'test'"}) + assert result == "[]" + + call_args = mock_client.return_value.__aenter__.return_value.get.call_args + assert call_args[1]["params"]["$filter"] == "name eq 'test'" + + @pytest.mark.asyncio + async def test_python_keyword_parameter(self): + """Test function with Python keyword as parameter name (e.g., class).""" + operation = { + "parameters": [ + { + "name": "class", + "in": "query", + "required": False, + "schema": {"type": "string"}, + } + ] + } + + func = create_tool_function( + path="/items", + method="get", + operation=operation, + base_url="https://api.example.com", + ) + + assert callable(func) + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.text = "items" + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + result = await func(**{"class": "premium"}) + assert result == "items" + + call_args = mock_client.return_value.__aenter__.return_value.get.call_args + assert call_args[1]["params"]["class"] == "premium" + + @pytest.mark.asyncio + async def test_multiple_problematic_parameters(self): + """Test function with multiple problematic parameter names.""" + operation = { + "parameters": [ + { + "name": "repository-id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + }, + { + "name": "2fa-code", + "in": "query", + "required": False, + "schema": {"type": "string"}, + }, + { + "name": "$filter", + "in": "query", + "required": False, + "schema": {"type": "string"}, + }, + ] + } + + func = create_tool_function( + path="/repos/{repository-id}", + method="get", + operation=operation, + base_url="https://api.example.com", + ) + + assert callable(func) + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.text = "success" + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + result = await func( + **{ + "repository-id": "test-repo", + "2fa-code": "123", + "$filter": "active", + } + ) + assert result == "success" + + @pytest.mark.asyncio + async def test_request_body_parameter(self): + """Test function with request body parameter.""" + operation = { + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"name": {"type": "string"}}, + } + } + }, + } + } + + func = create_tool_function( + path="/create", + method="post", + operation=operation, + base_url="https://api.example.com", + ) + + assert callable(func) + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.text = "created" + mock_client.return_value.__aenter__.return_value.post = AsyncMock( + return_value=mock_response + ) + + result = await func(**{"body": {"name": "test"}}) + assert result == "created" + + call_args = mock_client.return_value.__aenter__.return_value.post.call_args + assert call_args[1]["json"] == {"name": "test"} + + @pytest.mark.asyncio + async def test_no_parameters(self): + """Test function with no parameters.""" + operation = {} + + func = create_tool_function( + path="/health", + method="get", + operation=operation, + base_url="https://api.example.com", + ) + + assert callable(func) + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.text = "ok" + mock_client.return_value.__aenter__.return_value.get = AsyncMock( + return_value=mock_response + ) + + result = await func() + assert result == "ok" + + @pytest.mark.asyncio + async def test_all_http_methods(self): + """Test all supported HTTP methods.""" + methods = ["get", "post", "put", "delete", "patch"] + + for method in methods: + operation = { + "parameters": [ + { + "name": "repository-id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ] + } + + func = create_tool_function( + path="/repos/{repository-id}", + method=method, + operation=operation, + base_url="https://api.example.com", + ) + + assert callable(func) + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.text = "success" + + client_method = getattr( + mock_client.return_value.__aenter__.return_value, method + ) + client_method.return_value = mock_response + client_method = AsyncMock(return_value=mock_response) + setattr( + mock_client.return_value.__aenter__.return_value, + method, + client_method, + ) + + result = await func(**{"repository-id": "test"}) + assert result == "success" + + def test_no_exec_usage(self): + """Verify that create_tool_function does not use exec().""" + import ast + import inspect + + # Get the source code of create_tool_function + source = inspect.getsource(create_tool_function) + + # Parse the AST + tree = ast.parse(source) + + # Check for exec() calls + exec_calls = [] + for node in ast.walk(tree): + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Name) and node.func.id == "exec": + exec_calls.append(node) + + # Should have no exec() calls + assert len(exec_calls) == 0, "create_tool_function should not use exec()" + + +class TestBuildInputSchema: + """Test that build_input_schema preserves original parameter names.""" + + def test_original_parameter_names_preserved(self): + """Test that original parameter names are preserved in input schema.""" + operation = { + "parameters": [ + { + "name": "repository-id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + }, + { + "name": "2fa-code", + "in": "query", + "required": False, + "schema": {"type": "string"}, + }, + { + "name": "$filter", + "in": "query", + "required": False, + "schema": {"type": "string"}, + }, + ] + } + + schema = build_input_schema(operation) + + # Original names should be in the schema + assert "repository-id" in schema["properties"] + assert "2fa-code" in schema["properties"] + assert "$filter" in schema["properties"] + + # Required should include original names + assert "repository-id" in schema["required"] + + +class TestExtractParameters: + """Test parameter extraction from OpenAPI operations.""" + + def test_extract_path_query_body_params(self): + """Test extraction of different parameter types.""" + operation = { + "parameters": [ + {"name": "repo-id", "in": "path"}, + {"name": "filter", "in": "query"}, + {"name": "data", "in": "body"}, + ], + "requestBody": { + "content": {"application/json": {"schema": {"type": "object"}}} + }, + } + + path_params, query_params, body_params = extract_parameters(operation) + + assert "repo-id" in path_params + assert "filter" in query_params + assert "data" in body_params + assert "body" in body_params # From requestBody + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) + From f5024624d7b9fdc5fd888edffbb7b388f192b09f Mon Sep 17 00:00:00 2001 From: yurekami Date: Mon, 29 Dec 2025 03:44:03 +0900 Subject: [PATCH 016/530] fix: correct deepseek-v3p2 pricing for Fireworks AI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated pricing for fireworks_ai/accounts/fireworks/models/deepseek-v3p2: - input_cost_per_token: 1.2e-06 -> 5.6e-07 ($0.56/1M tokens) - output_cost_per_token: 1.2e-06 -> 1.68e-06 ($1.68/1M tokens) Pricing verified from https://fireworks.ai/models/fireworks/deepseek-v3p2 Fixes #17998 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- litellm/model_prices_and_context_window_backup.json | 4 ++-- model_prices_and_context_window.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 513a4a554e0..60ddb74533c 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -10885,13 +10885,13 @@ "supports_tool_choice": true }, "fireworks_ai/accounts/fireworks/models/deepseek-v3p2": { - "input_cost_per_token": 1.2e-06, + "input_cost_per_token": 5.6e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 163840, "max_output_tokens": 163840, "max_tokens": 163840, "mode": "chat", - "output_cost_per_token": 1.2e-06, + "output_cost_per_token": 1.68e-06, "source": "https://fireworks.ai/models/fireworks/deepseek-v3p2", "supports_function_calling": true, "supports_reasoning": true, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 4651107c5b8..9eae448da97 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -10943,13 +10943,13 @@ "supports_tool_choice": true }, "fireworks_ai/accounts/fireworks/models/deepseek-v3p2": { - "input_cost_per_token": 1.2e-06, + "input_cost_per_token": 5.6e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 163840, "max_output_tokens": 163840, "max_tokens": 163840, "mode": "chat", - "output_cost_per_token": 1.2e-06, + "output_cost_per_token": 1.68e-06, "source": "https://fireworks.ai/models/fireworks/deepseek-v3p2", "supports_function_calling": true, "supports_reasoning": true, From 93628c06eed2b2a5aa0da1bc85e355052f8c36b6 Mon Sep 17 00:00:00 2001 From: Chesars Date: Mon, 29 Dec 2025 00:19:20 -0300 Subject: [PATCH 017/530] feat: Add MiniMax provider support to UI dashboard - Add MiniMax to provider_create_fields.json with credential fields: - api_key (required, password field) - api_base (optional, defaults to https://api.minimax.io/v1) - Add MiniMax to UI provider enum and mappings - Includes tooltip for International vs China endpoints - Default model placeholder: minimax/MiniMax-M2 This enables users to configure MiniMax models directly through the proxy UI dashboard without needing to edit YAML config files. Fixes #18481 --- .../provider_create_fields.json | 28 +++++++++++++++++++ .../src/components/provider_info_helpers.tsx | 2 ++ 2 files changed, 30 insertions(+) diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index 9916bdf6923..0e0991dc214 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -1680,6 +1680,34 @@ ], "default_model_placeholder": "gpt-3.5-turbo" }, + { + "provider": "MINIMAX", + "provider_display_name": "MiniMax", + "litellm_provider": "minimax", + "credential_fields": [ + { + "key": "api_key", + "label": "API Key", + "placeholder": "your-minimax-api-key", + "tooltip": "MiniMax API Key from https://platform.minimaxi.com/", + "required": true, + "field_type": "password", + "options": null, + "default_value": null + }, + { + "key": "api_base", + "label": "API Base URL", + "placeholder": "https://api.minimax.io/v1", + "tooltip": "International: https://api.minimax.io/v1, China: https://api.minimaxi.com/v1", + "required": false, + "field_type": "text", + "options": null, + "default_value": "https://api.minimax.io/v1" + } + ], + "default_model_placeholder": "minimax/MiniMax-M2" + }, { "provider": "MOONSHOT", "provider_display_name": "Moonshot", diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index 277786aa8ce..d44fe9446b6 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -24,6 +24,7 @@ export enum Providers { Hosted_Vllm = "vllm", Infinity = "Infinity", JinaAI = "Jina AI", + MiniMax = "MiniMax", MistralAI = "Mistral AI", Ollama = "Ollama", OpenAI = "OpenAI", @@ -55,6 +56,7 @@ export const provider_map: Record = { Google_AI_Studio: "gemini", Bedrock: "bedrock", Groq: "groq", + MiniMax: "minimax", MistralAI: "mistral", Cohere: "cohere", OpenAI_Compatible: "openai", From e31ee9be51121c63080d16ef45b53c32fa7c0f98 Mon Sep 17 00:00:00 2001 From: Chesars Date: Mon, 29 Dec 2025 00:21:14 -0300 Subject: [PATCH 018/530] feat: Add MiniMax official logo to UI - Downloaded official MiniMax logo from HuggingFace repository - Added minimax.svg to assets/logos directory - Updated providerLogoMap to reference the logo - Logo source: https://huggingface.co/MiniMaxAI/MiniMax-VL-01 --- ui/litellm-dashboard/public/assets/logos/minimax.svg | 1 + ui/litellm-dashboard/src/components/provider_info_helpers.tsx | 1 + 2 files changed, 2 insertions(+) create mode 100644 ui/litellm-dashboard/public/assets/logos/minimax.svg diff --git a/ui/litellm-dashboard/public/assets/logos/minimax.svg b/ui/litellm-dashboard/public/assets/logos/minimax.svg new file mode 100644 index 00000000000..59b741bbcb7 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/minimax.svg @@ -0,0 +1 @@ +资源 2 \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index d44fe9446b6..daea7abe877 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -112,6 +112,7 @@ export const providerLogoMap: Record = { [Providers.Google_AI_Studio]: `${asset_logos_folder}google.svg`, [Providers.Hosted_Vllm]: `${asset_logos_folder}vllm.png`, [Providers.Infinity]: `${asset_logos_folder}infinity.png`, + [Providers.MiniMax]: `${asset_logos_folder}minimax.svg`, [Providers.MistralAI]: `${asset_logos_folder}mistral.svg`, [Providers.Ollama]: `${asset_logos_folder}ollama.svg`, [Providers.OpenAI]: `${asset_logos_folder}openai_small.svg`, From e4c9b0bea2b92e932e2403bb8443c762beac4b2b Mon Sep 17 00:00:00 2001 From: Devaj Date: Mon, 29 Dec 2025 09:55:41 +0530 Subject: [PATCH 019/530] fix(vertex_ai): convert image URLs to base64 for Vertex AI Anthropic Fixes #18430 - Pass custom_llm_provider to anthropic_messages_pt instead of hardcoded 'anthropic' - Add check for vertex_ai provider to force base64 conversion for image URLs - Add tests to verify behavior for both Vertex AI and regular Anthropic --- .../prompt_templates/factory.py | 8 +- litellm/llms/anthropic/chat/transformation.py | 2 +- ..._vertex_ai_anthropic_image_url_handling.py | 179 ++++++++++++++++++ 3 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_anthropic_image_url_handling.py diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 04c8c235557..1543c0f9f45 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -930,7 +930,8 @@ def create_anthropic_image_param( # Check if the image URL is an HTTP/HTTPS URL if image_url.startswith("http://") or image_url.startswith("https://"): - # For Bedrock invoke, always convert URLs to base64 (Bedrock invoke doesn't support URLs) + # For Bedrock invoke and Vertex AI Anthropic, always convert URLs to base64 + # as these providers don't support URL sources for images if is_bedrock_invoke or image_url.startswith("http://"): base64_url = convert_url_to_base64(url=image_url) image_chunk = convert_to_anthropic_image_obj( @@ -1914,9 +1915,12 @@ def anthropic_messages_pt( # noqa: PLR0915 "format": image_url_value.get("format"), } # Bedrock invoke models have format: invoke/... + # Vertex AI Anthropic also doesn't support URL sources for images is_bedrock_invoke = model.lower().startswith("invoke/") + is_vertex_ai = llm_provider.startswith("vertex_ai") if llm_provider else False + force_base64 = is_bedrock_invoke or is_vertex_ai _anthropic_content_element = create_anthropic_image_param( - image_url_input, format=format, is_bedrock_invoke=is_bedrock_invoke + image_url_input, format=format, is_bedrock_invoke=force_base64 ) _content_element = add_cache_control_to_content( anthropic_content_element=_anthropic_content_element, diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index b477dbd457e..1c108f1e94a 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -994,7 +994,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): anthropic_messages = anthropic_messages_pt( model=model, messages=messages, - llm_provider="anthropic", + llm_provider=self.custom_llm_provider or "anthropic", ) except Exception as e: raise AnthropicError( diff --git a/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_anthropic_image_url_handling.py b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_anthropic_image_url_handling.py new file mode 100644 index 00000000000..fca784342d7 --- /dev/null +++ b/tests/test_litellm/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_anthropic_image_url_handling.py @@ -0,0 +1,179 @@ +""" +Tests for Vertex AI Anthropic image URL handling. + +Issue: https://github.com/BerriAI/litellm/issues/18430 +Vertex AI Anthropic models don't support URL sources for images. +LiteLLM should convert image URLs to base64 when using Vertex AI Anthropic. +""" +import os +import sys +from unittest.mock import patch, MagicMock + +import pytest + +sys.path.insert( + 0, os.path.abspath("../../../../../..") +) # Adds the parent directory to the system path + +from litellm.litellm_core_utils.prompt_templates.factory import ( + anthropic_messages_pt, + create_anthropic_image_param, +) + + +class TestVertexAIAnthropicImageURLHandling: + """Test that Vertex AI Anthropic converts image URLs to base64.""" + + @patch("litellm.litellm_core_utils.prompt_templates.factory.convert_url_to_base64") + def test_vertex_ai_anthropic_converts_https_url_to_base64( + self, mock_convert_url: MagicMock + ): + """ + Test that HTTPS image URLs are converted to base64 for Vertex AI Anthropic. + + For regular Anthropic, HTTPS URLs are passed through as URL type. + For Vertex AI Anthropic, HTTPS URLs should be converted to base64. + """ + mock_convert_url.return_value = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ==" + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.jpg"}, + }, + ], + } + ] + + # For Vertex AI, image URLs should be converted to base64 + result = anthropic_messages_pt( + messages=messages, + model="claude-sonnet-4", + llm_provider="vertex_ai", + ) + + # Verify convert_url_to_base64 was called + mock_convert_url.assert_called_once_with(url="https://example.com/image.jpg") + + # Check the result has base64 source type + user_message = result[0] + assert user_message["role"] == "user" + image_content = user_message["content"][1] + assert image_content["type"] == "image" + assert image_content["source"]["type"] == "base64" + + @patch("litellm.litellm_core_utils.prompt_templates.factory.convert_url_to_base64") + def test_regular_anthropic_uses_url_type_for_https( + self, mock_convert_url: MagicMock + ): + """ + Test that regular Anthropic API uses URL type for HTTPS images. + + This confirms the original behavior is preserved for non-Vertex AI. + """ + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.jpg"}, + }, + ], + } + ] + + # For regular Anthropic, HTTPS URLs should NOT be converted + result = anthropic_messages_pt( + messages=messages, + model="claude-sonnet-4", + llm_provider="anthropic", + ) + + # convert_url_to_base64 should NOT be called for regular Anthropic with HTTPS + mock_convert_url.assert_not_called() + + # Check the result has URL source type + user_message = result[0] + assert user_message["role"] == "user" + image_content = user_message["content"][1] + assert image_content["type"] == "image" + assert image_content["source"]["type"] == "url" + assert image_content["source"]["url"] == "https://example.com/image.jpg" + + @patch("litellm.litellm_core_utils.prompt_templates.factory.convert_url_to_base64") + def test_vertex_ai_beta_also_converts_to_base64( + self, mock_convert_url: MagicMock + ): + """ + Test that vertex_ai_beta provider also converts image URLs to base64. + """ + mock_convert_url.return_value = "data:image/png;base64,iVBORw0KGgo=" + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this image?"}, + { + "type": "image_url", + "image_url": "https://example.com/photo.png", + }, + ], + } + ] + + result = anthropic_messages_pt( + messages=messages, + model="claude-3-opus", + llm_provider="vertex_ai_beta", + ) + + # Verify convert_url_to_base64 was called + mock_convert_url.assert_called_once() + + # Check the result has base64 source type + user_message = result[0] + image_content = user_message["content"][1] + assert image_content["source"]["type"] == "base64" + + +class TestCreateAnthropicImageParam: + """Test the create_anthropic_image_param function directly.""" + + @patch("litellm.litellm_core_utils.prompt_templates.factory.convert_url_to_base64") + def test_force_base64_converts_https_url(self, mock_convert_url: MagicMock): + """ + Test that is_bedrock_invoke=True (used for both Bedrock and Vertex AI) + forces conversion of HTTPS URLs to base64. + """ + mock_convert_url.return_value = "data:image/jpeg;base64,/9j/4AAQSkZJRg==" + + result = create_anthropic_image_param( + image_url_input="https://example.com/image.jpg", + format=None, + is_bedrock_invoke=True, # This flag is set for both Bedrock and Vertex AI + ) + + mock_convert_url.assert_called_once_with(url="https://example.com/image.jpg") + assert result["source"]["type"] == "base64" + + @patch("litellm.litellm_core_utils.prompt_templates.factory.convert_url_to_base64") + def test_no_force_uses_url_type(self, mock_convert_url: MagicMock): + """ + Test that without force, HTTPS URLs use URL type. + """ + result = create_anthropic_image_param( + image_url_input="https://example.com/image.jpg", + format=None, + is_bedrock_invoke=False, + ) + + mock_convert_url.assert_not_called() + assert result["source"]["type"] == "url" + assert result["source"]["url"] == "https://example.com/image.jpg" From d7468dab7e812054568c73e99392e954114674a8 Mon Sep 17 00:00:00 2001 From: Daniel Krueger Date: Mon, 29 Dec 2025 15:29:54 +0100 Subject: [PATCH 020/530] fix authentication errors at messages API via azure_ai Use x-api-key instead of api-key. This has been removed by commit 61e737e361d1b2649e9fd491580529ea894dd0d4 for unknown reason. --- .../anthropic/messages_transformation.py | 7 ++++++- ...azure_anthropic_messages_transformation.py | 19 ++++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/litellm/llms/azure_ai/anthropic/messages_transformation.py b/litellm/llms/azure_ai/anthropic/messages_transformation.py index 73dc84167ab..55818cc07d6 100644 --- a/litellm/llms/azure_ai/anthropic/messages_transformation.py +++ b/litellm/llms/azure_ai/anthropic/messages_transformation.py @@ -48,7 +48,12 @@ class AzureAnthropicMessagesConfig(AnthropicMessagesConfig): headers = BaseAzureLLM._base_validate_azure_environment( headers=headers, litellm_params=litellm_params_obj ) - + + # Azure Anthropic uses x-api-key header (not api-key) + # Convert api-key to x-api-key if present + if "api-key" in headers and "x-api-key" not in headers: + headers["x-api-key"] = headers.pop("api-key") + # Set anthropic-version header if "anthropic-version" not in headers: headers["anthropic-version"] = "2023-06-01" diff --git a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py index d78a638fd89..bdced849c7e 100644 --- a/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py +++ b/tests/test_litellm/llms/azure_ai/claude/test_azure_anthropic_messages_transformation.py @@ -55,11 +55,12 @@ class TestAzureAnthropicMessagesConfig: assert isinstance(call_args[1]["litellm_params"], GenericLiteLLMParams) assert call_args[1]["litellm_params"].api_key == "test-api-key" assert "anthropic-version" in result - # api-key header is preserved as-is (no conversion to x-api-key) - assert "api-key" in result + assert "x-api-key" in result + assert result["x-api-key"] == "test-api-key" + assert "api-key" not in result - def test_validate_anthropic_messages_environment_preserves_api_key_header(self): - """Test that api-key header is preserved as-is (Azure handles the header internally)""" + def test_validate_anthropic_messages_environment_converts_api_key_to_x_api_key(self): + """Test that api-key header is converted to x-api-key""" config = AzureAnthropicMessagesConfig() headers = {} model = "claude-sonnet-4-5" @@ -79,9 +80,10 @@ class TestAzureAnthropicMessagesConfig: litellm_params=litellm_params, ) - # Verify api-key header is preserved as-is - assert "api-key" in result - assert result["api-key"] == "test-api-key" + # Verify api-key was converted to x-api-key + assert "x-api-key" in result + assert result["x-api-key"] == "test-api-key" + assert "api-key" not in result def test_validate_anthropic_messages_environment_sets_headers(self): """Test that required headers are set""" @@ -108,8 +110,7 @@ class TestAzureAnthropicMessagesConfig: assert result["anthropic-version"] == "2023-06-01" assert "content-type" in result assert result["content-type"] == "application/json" - # api-key header is preserved as-is - assert "api-key" in result + assert "x-api-key" in result def test_get_complete_url_with_base_url(self): """Test get_complete_url with base URL""" From 4573ab326b5b4293261ab7795b056018c06d1fa7 Mon Sep 17 00:00:00 2001 From: hamzaq453 Date: Tue, 30 Dec 2025 14:04:15 +0500 Subject: [PATCH 021/530] refactor: remove to_safe_identifier mapping from OpenAPI MCP generator --- .../mcp_server/openapi_to_mcp_generator.py | 159 ++++----------- .../test_openapi_to_mcp_generator.py | 186 ++++++------------ 2 files changed, 91 insertions(+), 254 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py index dc5d0ce73af..e4969df131e 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -3,8 +3,6 @@ This module is used to generate MCP tools from OpenAPI specs. """ import json -import keyword -import re from typing import Any, Dict, Optional import httpx @@ -19,64 +17,6 @@ BASE_URL = "" HEADERS: Dict[str, str] = {} -def to_safe_identifier(name: str) -> str: - """ - Convert an OpenAPI parameter name to a safe Python identifier. - - This function ensures that any parameter name from an OpenAPI spec can be - used as a Python function parameter without causing syntax errors or - security issues. It handles: - - Hyphens, dots, and other special characters - - Leading digits - - Python keywords - - Special characters like $, @, etc. - - Args: - name: The original parameter name from the OpenAPI spec - - Returns: - A valid Python identifier that can be used in function signatures - - Examples: - >>> to_safe_identifier("repository-id") - 'repository_id' - >>> to_safe_identifier("2fa-code") - '_2fa_code' - >>> to_safe_identifier("user.name") - 'user_name' - >>> to_safe_identifier("$filter") - '_filter' - >>> to_safe_identifier("class") - 'class_' - """ - if not name: - return "_empty_" - - # Start with underscore if first char is not a letter - # Replace all non-alphanumeric chars (except underscore) with underscore - # Collapse multiple underscores - safe = re.sub(r'[^a-zA-Z0-9_]', '_', name) - safe = re.sub(r'_+', '_', safe) # Collapse multiple underscores - - # If starts with digit, prefix with underscore - if safe and safe[0].isdigit(): - safe = '_' + safe - - # If empty after sanitization, use a default - if not safe: - safe = '_param_' - - # If it's a Python keyword, append underscore - if keyword.iskeyword(safe): - safe = safe + '_' - - # Ensure it doesn't start with a digit (shouldn't happen after above, but double-check) - if safe and safe[0].isdigit(): - safe = '_' + safe - - return safe - - def load_openapi_spec(filepath: str) -> Dict[str, Any]: """Load OpenAPI specification from JSON file.""" with open(filepath, "r") as f: @@ -173,9 +113,8 @@ def create_tool_function( """Create a tool function for an OpenAPI operation. This function creates an async tool function that can be called with - keyword arguments. Parameter names from the OpenAPI spec are safely - mapped to valid Python identifiers to avoid syntax errors and security - issues. + keyword arguments. Parameter names from the OpenAPI spec are accessed + directly via **kwargs, avoiding syntax errors from invalid Python identifiers. Args: path: API endpoint path @@ -191,108 +130,80 @@ def create_tool_function( headers = {} path_params, query_params, body_params = extract_parameters(operation) - all_params = path_params + query_params + body_params - - # Create mapping from original parameter names to safe identifiers - # This allows us to accept kwargs with original names but use safe names internally - param_name_map: Dict[str, str] = {} - safe_to_original_map: Dict[str, str] = {} - - for orig_name in all_params: - safe_name = to_safe_identifier(orig_name) - # Handle collisions: if safe name already exists, append a counter - counter = 1 - original_safe = safe_name - while safe_name in safe_to_original_map: - safe_name = f"{original_safe}_{counter}" - counter += 1 - - param_name_map[orig_name] = safe_name - safe_to_original_map[safe_name] = orig_name - - # Store original parameter lists for use in the closure - original_path_params = path_params - original_query_params = query_params - original_body_params = body_params original_method = method.lower() async def tool_function(**kwargs: Any) -> str: """ Dynamically generated tool function. - + Accepts keyword arguments where keys are the original OpenAPI parameter names. - The function safely handles parameter names that aren't valid Python identifiers. + The function safely handles parameter names that aren't valid Python identifiers + by using **kwargs instead of named parameters. """ # Build URL from base_url and path url = base_url + path - + # Replace path parameters using original names from OpenAPI spec - for orig_param_name in original_path_params: - # Try to get value using original name first, then safe name - param_value = kwargs.get(orig_param_name, "") - if not param_value and orig_param_name in param_name_map: - safe_name = param_name_map[orig_param_name] - param_value = kwargs.get(safe_name, "") - + for param_name in path_params: + param_value = kwargs.get(param_name, "") if param_value: # Replace {param_name} or {{param_name}} in URL - url = url.replace("{" + orig_param_name + "}", str(param_value)) - url = url.replace("{{" + orig_param_name + "}}", str(param_value)) - + url = url.replace("{" + param_name + "}", str(param_value)) + url = url.replace("{{" + param_name + "}}", str(param_value)) + # Build query params using original parameter names params: Dict[str, Any] = {} - for orig_param_name in original_query_params: - # Try to get value using original name first, then safe name - param_value = kwargs.get(orig_param_name, "") - if not param_value and orig_param_name in param_name_map: - safe_name = param_name_map[orig_param_name] - param_value = kwargs.get(safe_name, "") - + for param_name in query_params: + param_value = kwargs.get(param_name, "") if param_value: # Use original parameter name in query string (as expected by API) - params[orig_param_name] = param_value - + params[param_name] = param_value + # Build request body json_body: Optional[Dict[str, Any]] = None - if original_body_params: + if body_params: # Try "body" first (most common), then check all body param names body_value = kwargs.get("body", {}) if not body_value: - for orig_param_name in original_body_params: - body_value = kwargs.get(orig_param_name, {}) + for param_name in body_params: + body_value = kwargs.get(param_name, {}) if body_value: break - # Also try safe name - if orig_param_name in param_name_map: - safe_name = param_name_map[orig_param_name] - body_value = kwargs.get(safe_name, {}) - if body_value: - break - + if isinstance(body_value, dict): json_body = body_value elif body_value: # If it's a string, try to parse as JSON try: - json_body = json.loads(body_value) if isinstance(body_value, str) else {"data": body_value} + json_body = ( + json.loads(body_value) + if isinstance(body_value, str) + else {"data": body_value} + ) except (json.JSONDecodeError, TypeError): json_body = {"data": body_value} - + # Make HTTP request async with httpx.AsyncClient() as client: if original_method == "get": response = await client.get(url, params=params, headers=headers) elif original_method == "post": - response = await client.post(url, params=params, json=json_body, headers=headers) + response = await client.post( + url, params=params, json=json_body, headers=headers + ) elif original_method == "put": - response = await client.put(url, params=params, json=json_body, headers=headers) + response = await client.put( + url, params=params, json=json_body, headers=headers + ) elif original_method == "delete": response = await client.delete(url, params=params, headers=headers) elif original_method == "patch": - response = await client.patch(url, params=params, json=json_body, headers=headers) + response = await client.patch( + url, params=params, json=json_body, headers=headers + ) else: return f"Unsupported HTTP method: {original_method}" - + return response.text return tool_function diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py index fa28dc34981..cb48a940b57 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py @@ -7,91 +7,16 @@ This test suite ensures that: 3. All edge cases (hyphens, dots, keywords, special chars) work correctly """ -import json import pytest from unittest.mock import AsyncMock, patch -from typing import Dict, Any from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( - to_safe_identifier, create_tool_function, build_input_schema, extract_parameters, ) -class TestToSafeIdentifier: - """Test the to_safe_identifier function for various edge cases.""" - - def test_hyphen_in_name(self): - """Test parameter names with hyphens.""" - assert to_safe_identifier("repository-id") == "repository_id" - assert to_safe_identifier("user-name") == "user_name" - assert to_safe_identifier("api-key") == "api_key" - - def test_leading_digit(self): - """Test parameter names starting with digits.""" - assert to_safe_identifier("2fa-code") == "_2fa_code" - assert to_safe_identifier("123abc") == "_123abc" - assert to_safe_identifier("0test") == "_0test" - - def test_dots_in_name(self): - """Test parameter names with dots.""" - assert to_safe_identifier("user.name") == "user_name" - assert to_safe_identifier("config.value") == "config_value" - assert to_safe_identifier("api.v2") == "api_v2" - - def test_dollar_sign(self): - """Test parameter names with dollar signs (OData style).""" - assert to_safe_identifier("$filter") == "_filter" - assert to_safe_identifier("$context") == "_context" - assert to_safe_identifier("$select") == "_select" - - def test_python_keywords(self): - """Test Python keywords are handled.""" - assert to_safe_identifier("class") == "class_" - assert to_safe_identifier("from") == "from_" - assert to_safe_identifier("not") == "not_" - assert to_safe_identifier("def") == "def_" - assert to_safe_identifier("import") == "import_" - - def test_special_characters(self): - """Test various special characters.""" - assert to_safe_identifier("user@domain") == "user_domain" - assert to_safe_identifier("test#hash") == "test_hash" - assert to_safe_identifier("path/to/resource") == "path_to_resource" - assert to_safe_identifier("param+value") == "param_value" - - def test_multiple_special_chars(self): - """Test names with multiple special characters.""" - assert to_safe_identifier("user-name.email@domain") == "user_name_email_domain" - assert to_safe_identifier("$filter.value") == "_filter_value" - - def test_already_valid_identifier(self): - """Test that valid identifiers remain unchanged (except keywords).""" - assert to_safe_identifier("valid_name") == "valid_name" - assert to_safe_identifier("validName123") == "validName123" - assert to_safe_identifier("_private") == "_private" - - def test_empty_string(self): - """Test empty string handling.""" - assert to_safe_identifier("") == "_empty_" - - def test_only_special_chars(self): - """Test names that are only special characters.""" - result = to_safe_identifier("---") - assert result.startswith("_") - assert len(result) > 0 - - def test_collision_handling(self): - """Test that similar names produce different safe identifiers.""" - # These should produce different results - name1 = to_safe_identifier("user-name") - name2 = to_safe_identifier("user_name") - # They might be the same after sanitization, which is acceptable - # The important thing is they're both valid identifiers - - class TestCreateToolFunction: """Test create_tool_function with various parameter name edge cases.""" @@ -108,18 +33,18 @@ class TestCreateToolFunction: } ] } - + func = create_tool_function( path="/repos/{repository-id}", method="get", operation=operation, base_url="https://api.example.com", ) - + # Should not raise SyntaxError assert callable(func) assert func.__name__ == "tool_function" - + # Test calling with original parameter name with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() @@ -127,13 +52,15 @@ class TestCreateToolFunction: mock_client.return_value.__aenter__.return_value.get = AsyncMock( return_value=mock_response ) - + result = await func(**{"repository-id": "test-repo"}) assert result == '{"id": "123"}' - + # Verify URL was constructed correctly call_args = mock_client.return_value.__aenter__.return_value.get.call_args - assert "repository-id" in str(call_args[0][0]) or "test-repo" in str(call_args[0][0]) + assert "repository-id" in str(call_args[0][0]) or "test-repo" in str( + call_args[0][0] + ) @pytest.mark.asyncio async def test_leading_digit_parameter(self): @@ -148,26 +75,26 @@ class TestCreateToolFunction: } ] } - + func = create_tool_function( path="/verify", method="post", operation=operation, base_url="https://api.example.com", ) - + assert callable(func) - + with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.text = "verified" mock_client.return_value.__aenter__.return_value.post = AsyncMock( return_value=mock_response ) - + result = await func(**{"2fa-code": "123456"}) assert result == "verified" - + # Verify query parameter was included call_args = mock_client.return_value.__aenter__.return_value.post.call_args assert call_args[1]["params"]["2fa-code"] == "123456" @@ -185,26 +112,26 @@ class TestCreateToolFunction: } ] } - + func = create_tool_function( path="/search", method="get", operation=operation, base_url="https://api.example.com", ) - + assert callable(func) - + with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.text = "found" mock_client.return_value.__aenter__.return_value.get = AsyncMock( return_value=mock_response ) - + result = await func(**{"user.name": "john.doe"}) assert result == "found" - + call_args = mock_client.return_value.__aenter__.return_value.get.call_args assert call_args[1]["params"]["user.name"] == "john.doe" @@ -221,26 +148,26 @@ class TestCreateToolFunction: } ] } - + func = create_tool_function( path="/entities", method="get", operation=operation, base_url="https://api.example.com", ) - + assert callable(func) - + with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.text = "[]" mock_client.return_value.__aenter__.return_value.get = AsyncMock( return_value=mock_response ) - + result = await func(**{"$filter": "name eq 'test'"}) assert result == "[]" - + call_args = mock_client.return_value.__aenter__.return_value.get.call_args assert call_args[1]["params"]["$filter"] == "name eq 'test'" @@ -257,26 +184,26 @@ class TestCreateToolFunction: } ] } - + func = create_tool_function( path="/items", method="get", operation=operation, base_url="https://api.example.com", ) - + assert callable(func) - + with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.text = "items" mock_client.return_value.__aenter__.return_value.get = AsyncMock( return_value=mock_response ) - + result = await func(**{"class": "premium"}) assert result == "items" - + call_args = mock_client.return_value.__aenter__.return_value.get.call_args assert call_args[1]["params"]["class"] == "premium" @@ -305,23 +232,23 @@ class TestCreateToolFunction: }, ] } - + func = create_tool_function( path="/repos/{repository-id}", method="get", operation=operation, base_url="https://api.example.com", ) - + assert callable(func) - + with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.text = "success" mock_client.return_value.__aenter__.return_value.get = AsyncMock( return_value=mock_response ) - + result = await func( **{ "repository-id": "test-repo", @@ -347,26 +274,26 @@ class TestCreateToolFunction: }, } } - + func = create_tool_function( path="/create", method="post", operation=operation, base_url="https://api.example.com", ) - + assert callable(func) - + with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.text = "created" mock_client.return_value.__aenter__.return_value.post = AsyncMock( return_value=mock_response ) - + result = await func(**{"body": {"name": "test"}}) assert result == "created" - + call_args = mock_client.return_value.__aenter__.return_value.post.call_args assert call_args[1]["json"] == {"name": "test"} @@ -374,23 +301,23 @@ class TestCreateToolFunction: async def test_no_parameters(self): """Test function with no parameters.""" operation = {} - + func = create_tool_function( path="/health", method="get", operation=operation, base_url="https://api.example.com", ) - + assert callable(func) - + with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.text = "ok" mock_client.return_value.__aenter__.return_value.get = AsyncMock( return_value=mock_response ) - + result = await func() assert result == "ok" @@ -398,7 +325,7 @@ class TestCreateToolFunction: async def test_all_http_methods(self): """Test all supported HTTP methods.""" methods = ["get", "post", "put", "delete", "patch"] - + for method in methods: operation = { "parameters": [ @@ -410,20 +337,20 @@ class TestCreateToolFunction: } ] } - + func = create_tool_function( path="/repos/{repository-id}", method=method, operation=operation, base_url="https://api.example.com", ) - + assert callable(func) - + with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.text = "success" - + client_method = getattr( mock_client.return_value.__aenter__.return_value, method ) @@ -434,7 +361,7 @@ class TestCreateToolFunction: method, client_method, ) - + result = await func(**{"repository-id": "test"}) assert result == "success" @@ -442,20 +369,20 @@ class TestCreateToolFunction: """Verify that create_tool_function does not use exec().""" import ast import inspect - + # Get the source code of create_tool_function source = inspect.getsource(create_tool_function) - + # Parse the AST tree = ast.parse(source) - + # Check for exec() calls exec_calls = [] for node in ast.walk(tree): if isinstance(node, ast.Call): if isinstance(node.func, ast.Name) and node.func.id == "exec": exec_calls.append(node) - + # Should have no exec() calls assert len(exec_calls) == 0, "create_tool_function should not use exec()" @@ -487,14 +414,14 @@ class TestBuildInputSchema: }, ] } - + schema = build_input_schema(operation) - + # Original names should be in the schema assert "repository-id" in schema["properties"] assert "2fa-code" in schema["properties"] assert "$filter" in schema["properties"] - + # Required should include original names assert "repository-id" in schema["required"] @@ -514,9 +441,9 @@ class TestExtractParameters: "content": {"application/json": {"schema": {"type": "object"}}} }, } - + path_params, query_params, body_params = extract_parameters(operation) - + assert "repo-id" in path_params assert "filter" in query_params assert "data" in body_params @@ -525,4 +452,3 @@ class TestExtractParameters: if __name__ == "__main__": pytest.main([__file__, "-v"]) - From f3f1acd338fa0e348bf90de3617c56a882fc496a Mon Sep 17 00:00:00 2001 From: xuan07t2 Date: Wed, 31 Dec 2025 08:48:35 +0700 Subject: [PATCH 022/530] fix(vertex_ai): separate Tool objects for each tool type per API spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix Vertex AI API error: "tools[0].tool_type: one_of 'tool_type' has more than one initialized field" The Vertex AI API requires each Tool object to contain exactly one type of tool (e.g., FunctionDeclaration, GoogleSearch, CodeExecution). Previously, all tool types were combined into a single Tool object, causing INVALID_ARGUMENT errors when using multiple tools simultaneously. This change creates separate Tool objects for each tool type: - Function declarations in one Tool - Google Search in its own Tool - Code Execution in its own Tool - etc. Ref: https://cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1beta1/Tool 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../vertex_and_google_ai_studio_gemini.py | 44 +++- ...test_vertex_and_google_ai_studio_gemini.py | 191 ++++++++++++++++++ 2 files changed, 224 insertions(+), 11 deletions(-) diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index a5cc3dca8c1..ae4fabc6b64 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -552,24 +552,46 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): "Invalid tool={}. Use `litellm.set_verbose` or `litellm --detailed_debug` to see raw request." ) - # Only include function_declarations if there are actual functions - _tools = Tools() +# Build list of Tool objects - each Tool should contain exactly one type + # per Vertex AI API spec: "A Tool object should contain exactly one type of Tool" + _tools_list: List[Tools] = [] + + # Function declarations can be grouped together in one Tool if gtool_func_declarations: - _tools["function_declarations"] = gtool_func_declarations + func_tool = Tools() + func_tool["function_declarations"] = gtool_func_declarations + _tools_list.append(func_tool) + + # Each special tool type must be in its own Tool object if googleSearch is not None: - _tools[VertexToolName.GOOGLE_SEARCH.value] = googleSearch + search_tool = Tools() + search_tool[VertexToolName.GOOGLE_SEARCH.value] = googleSearch + _tools_list.append(search_tool) if googleSearchRetrieval is not None: - _tools[VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value] = googleSearchRetrieval + retrieval_tool = Tools() + retrieval_tool[VertexToolName.GOOGLE_SEARCH_RETRIEVAL.value] = googleSearchRetrieval + _tools_list.append(retrieval_tool) if enterpriseWebSearch is not None: - _tools[VertexToolName.ENTERPRISE_WEB_SEARCH.value] = enterpriseWebSearch + enterprise_tool = Tools() + enterprise_tool[VertexToolName.ENTERPRISE_WEB_SEARCH.value] = enterpriseWebSearch + _tools_list.append(enterprise_tool) if code_execution is not None: - _tools[VertexToolName.CODE_EXECUTION.value] = code_execution + code_tool = Tools() + code_tool[VertexToolName.CODE_EXECUTION.value] = code_execution + _tools_list.append(code_tool) if urlContext is not None: - _tools[VertexToolName.URL_CONTEXT.value] = urlContext + url_tool = Tools() + url_tool[VertexToolName.URL_CONTEXT.value] = urlContext + _tools_list.append(url_tool) if googleMaps is not None: - _tools[VertexToolName.GOOGLE_MAPS.value] = googleMaps + maps_tool = Tools() + maps_tool[VertexToolName.GOOGLE_MAPS.value] = googleMaps + _tools_list.append(maps_tool) if computerUse is not None: - _tools[VertexToolName.COMPUTER_USE.value] = computerUse + computer_tool = Tools() + computer_tool[VertexToolName.COMPUTER_USE.value] = computerUse + _tools_list.append(computer_tool) + # Add retrieval config to toolConfig if googleMaps has location data if google_maps_retrieval_config is not None: @@ -579,7 +601,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): "retrievalConfig" ] = google_maps_retrieval_config - return [_tools] + return _tools_list def _map_response_schema(self, value: dict) -> dict: old_schema = deepcopy(value) diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index 783d85f471b..91a28ee6ec9 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -2279,3 +2279,194 @@ def test_partial_json_chunk_on_first_chunk(): assert result is None, "Partial first chunk should return None" assert iterator.chunk_type == "accumulated_json", "Should switch to accumulated_json mode" + +# ==================== Tool Type Separation Tests ==================== +# These tests verify that each Tool object contains exactly one type per Vertex AI API spec +# Ref: https://cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1beta1/Tool + + +def test_vertex_ai_multiple_tool_types_separate_objects(): + """ + Test that multiple tool types are placed in separate Tool objects. + + This is required by Vertex AI API spec: + "A Tool object should contain exactly one type of Tool" + + Related error without this fix: + "tools[0].tool_type: one_of 'tool_type' has more than one initialized field: + enterprise_web_search, url_context" + + Input: + value=[ + {"enterpriseWebSearch": {}}, + {"url_context": {}}, + ] + + Expected Output: + tools=[ + {"enterpriseWebSearch": {}}, # First Tool object + {"url_context": {}}, # Second Tool object (separate!) + ] + + NOT (incorrect - causes API error): + tools=[ + {"enterpriseWebSearch": {}, "url_context": {}} # Multiple types in one object + ] + """ + v = VertexGeminiConfig() + optional_params = {} + + tools = v._map_function( + value=[ + {"enterpriseWebSearch": {}}, + {"url_context": {}}, + ], + optional_params=optional_params + ) + + # Should have 2 separate Tool objects + assert len(tools) == 2, f"Expected 2 separate Tool objects, got {len(tools)}" + + # Each Tool object should contain exactly ONE type + tool_types_in_first = [k for k in tools[0].keys()] + tool_types_in_second = [k for k in tools[1].keys()] + + assert len(tool_types_in_first) == 1, f"First Tool should have exactly 1 type, got {tool_types_in_first}" + assert len(tool_types_in_second) == 1, f"Second Tool should have exactly 1 type, got {tool_types_in_second}" + + # Verify the correct tool types are present + assert "enterpriseWebSearch" in tools[0], "First Tool should contain enterpriseWebSearch" + assert "url_context" in tools[1], "Second Tool should contain url_context" + + +def test_vertex_ai_function_declarations_with_other_tools_separate(): + """ + Test that function declarations and other tool types are in separate Tool objects. + + This ensures that when using both function calling AND special tools like + google_search or code_execution, they are properly separated per API spec. + + Input: + value=[ + {"type": "function", "function": {"name": "get_weather", "description": "Get weather"}}, + {"googleSearch": {}}, + {"code_execution": {}}, + ] + + Expected Output: + tools=[ + {"function_declarations": [{"name": "get_weather", "description": "Get weather"}]}, + {"googleSearch": {}}, + {"code_execution": {}}, + ] + """ + v = VertexGeminiConfig() + optional_params = {} + + tools = v._map_function( + value=[ + {"type": "function", "function": {"name": "get_weather", "description": "Get weather"}}, + {"googleSearch": {}}, + {"code_execution": {}}, + ], + optional_params=optional_params + ) + + # Should have 3 separate Tool objects + assert len(tools) == 3, f"Expected 3 separate Tool objects, got {len(tools)}" + + # Find each tool type + func_tool = None + search_tool = None + code_tool = None + + for tool in tools: + if "function_declarations" in tool: + func_tool = tool + elif "googleSearch" in tool: + search_tool = tool + elif "code_execution" in tool: + code_tool = tool + + # Verify all tools are present and separate + assert func_tool is not None, "function_declarations Tool should be present" + assert search_tool is not None, "googleSearch Tool should be present" + assert code_tool is not None, "code_execution Tool should be present" + + # Verify each Tool has exactly one type + assert len(func_tool.keys()) == 1, "function_declarations Tool should have only one key" + assert len(search_tool.keys()) == 1, "googleSearch Tool should have only one key" + assert len(code_tool.keys()) == 1, "code_execution Tool should have only one key" + + # Verify function declaration content + assert func_tool["function_declarations"][0]["name"] == "get_weather" + + +def test_vertex_ai_single_tool_type_still_works(): + """ + Test that single tool type usage still works correctly (backward compatibility). + + Input: + value=[{"code_execution": {}}] + + Expected Output: + tools=[{"code_execution": {}}] + """ + v = VertexGeminiConfig() + optional_params = {} + + tools = v._map_function( + value=[{"code_execution": {}}], + optional_params=optional_params + ) + + assert len(tools) == 1 + assert "code_execution" in tools[0] + assert tools[0]["code_execution"] == {} + + +def test_vertex_ai_multiple_function_declarations_grouped(): + """ + Test that multiple function declarations are grouped in ONE Tool object. + + Function declarations are the exception - they CAN be grouped together + in a single Tool object (up to 512 declarations). + + Input: + value=[ + {"type": "function", "function": {"name": "func1", "description": "First function"}}, + {"type": "function", "function": {"name": "func2", "description": "Second function"}}, + ] + + Expected Output: + tools=[ + { + "function_declarations": [ + {"name": "func1", "description": "First function"}, + {"name": "func2", "description": "Second function"}, + ] + } + ] + """ + v = VertexGeminiConfig() + optional_params = {} + + tools = v._map_function( + value=[ + {"type": "function", "function": {"name": "func1", "description": "First function"}}, + {"type": "function", "function": {"name": "func2", "description": "Second function"}}, + ], + optional_params=optional_params + ) + + # Should have only 1 Tool object (function declarations grouped) + assert len(tools) == 1, f"Expected 1 Tool object for grouped functions, got {len(tools)}" + + # Should contain function_declarations with 2 functions + assert "function_declarations" in tools[0] + assert len(tools[0]["function_declarations"]) == 2 + + # Verify function names + func_names = [f["name"] for f in tools[0]["function_declarations"]] + assert "func1" in func_names + assert "func2" in func_names From a17980c744f9945ba0f48f1a26d8d63f5d05ee91 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 31 Dec 2025 10:42:34 -0800 Subject: [PATCH 023/530] feat: hide new badges --- .../hooks/useDisableShowNewBadge.ts | 35 ++++ .../UsagePage/components/UsagePageView.tsx | 14 +- .../common_components/NewBadge.test.tsx | 52 ++++++ .../components/common_components/NewBadge.tsx | 17 +- .../src/components/navbar.tsx | 40 ++++- .../src/utils/localStorageUtils.test.ts | 157 ++++++++++++++++++ .../src/utils/localStorageUtils.ts | 33 ++++ 7 files changed, 331 insertions(+), 17 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowNewBadge.ts create mode 100644 ui/litellm-dashboard/src/components/common_components/NewBadge.test.tsx create mode 100644 ui/litellm-dashboard/src/utils/localStorageUtils.test.ts create mode 100644 ui/litellm-dashboard/src/utils/localStorageUtils.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowNewBadge.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowNewBadge.ts new file mode 100644 index 00000000000..d0a618e27ba --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowNewBadge.ts @@ -0,0 +1,35 @@ +// hooks/useDisableShowNewBadge.ts +import { useSyncExternalStore } from "react"; +import { getLocalStorageItem } from "@/utils/localStorageUtils"; +import { LOCAL_STORAGE_EVENT } from "@/utils/localStorageUtils"; + +function subscribe(callback: () => void) { + const onStorage = (e: StorageEvent) => { + if (e.key === "disableShowNewBadge") { + callback(); + } + }; + + const onCustom = (e: Event) => { + const { key } = (e as CustomEvent).detail; + if (key === "disableShowNewBadge") { + callback(); + } + }; + + window.addEventListener("storage", onStorage); + window.addEventListener(LOCAL_STORAGE_EVENT, onCustom); + + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener(LOCAL_STORAGE_EVENT, onCustom); + }; +} + +function getSnapshot() { + return getLocalStorageItem("disableShowNewBadge") === "true"; +} + +export function useDisableShowNewBadge() { + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index 920955138b9..a7107814113 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -27,7 +27,7 @@ import { Text, Title, } from "@tremor/react"; -import { Alert, Badge } from "antd"; +import { Alert } from "antd"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; @@ -419,13 +419,11 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
- - setUsageView(value)} - isAdmin={all_admin_roles.includes(userRole || "")} - /> - + setUsageView(value)} + isAdmin={all_admin_roles.includes(userRole || "")} + />
{/* Your Usage Panel */} diff --git a/ui/litellm-dashboard/src/components/common_components/NewBadge.test.tsx b/ui/litellm-dashboard/src/components/common_components/NewBadge.test.tsx new file mode 100644 index 00000000000..a24b52db6b8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/NewBadge.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import NewBadge from "./NewBadge"; + +// Mock the hook directly +vi.mock("@/app/(dashboard)/hooks/useDisableShowNewBadge", () => ({ + useDisableShowNewBadge: vi.fn(), +})); + +import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge"; + +const mockUseDisableShowNewBadge = vi.mocked(useDisableShowNewBadge); + +describe("NewBadge", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the badge when disableShowNewBadge is false", () => { + mockUseDisableShowNewBadge.mockReturnValue(false); + + render(Test Content); + + expect(screen.getByText("New")).toBeInTheDocument(); + expect(screen.getByText("Test Content")).toBeInTheDocument(); + }); + + it("should render the badge when disableShowNewBadge is not set", () => { + mockUseDisableShowNewBadge.mockReturnValue(false); + + render(); + + expect(screen.getByText("New")).toBeInTheDocument(); + }); + + it("should render only children when disableShowNewBadge is true", () => { + mockUseDisableShowNewBadge.mockReturnValue(true); + + render(Test Content); + + expect(screen.queryByText("New")).not.toBeInTheDocument(); + expect(screen.getByText("Test Content")).toBeInTheDocument(); + }); + + it("should render nothing when disableShowNewBadge is true and no children", () => { + mockUseDisableShowNewBadge.mockReturnValue(true); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/NewBadge.tsx b/ui/litellm-dashboard/src/components/common_components/NewBadge.tsx index 2a3d1a51248..97cdea8cfbb 100644 --- a/ui/litellm-dashboard/src/components/common_components/NewBadge.tsx +++ b/ui/litellm-dashboard/src/components/common_components/NewBadge.tsx @@ -1,5 +1,18 @@ import { Badge } from "antd"; +import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge"; -export default function NewBadge() { - return ; +export default function NewBadge({ children }: { children?: React.ReactNode }) { + const disableShowNewBadge = useDisableShowNewBadge(); + + if (disableShowNewBadge) { + return children ? <>{children} : null; + } + + return children ? ( + + {children} + + ) : ( + + ); } diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index e032649ae29..23b31ec4821 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import React, { useState, useEffect } from "react"; import type { MenuProps } from "antd"; -import { Dropdown, Tooltip } from "antd"; +import { Dropdown, Switch, Tooltip } from "antd"; import { getProxyBaseUrl } from "@/components/networking"; import { UserOutlined, @@ -13,8 +13,10 @@ import { MenuUnfoldOutlined, } from "@ant-design/icons"; import { clearTokenCookies } from "@/utils/cookieUtils"; +import { getLocalStorageItem, setLocalStorageItem, removeLocalStorageItem } from "@/utils/localStorageUtils"; import { fetchProxySettings } from "@/utils/proxyUtils"; import { useTheme } from "@/contexts/ThemeContext"; +import { emitLocalStorageChange } from "@/utils/localStorageUtils"; interface NavbarProps { userID: string | null; @@ -44,6 +46,7 @@ const Navbar: React.FC = ({ const baseUrl = getProxyBaseUrl(); const [logoutUrl, setLogoutUrl] = useState(""); const [version, setVersion] = useState(""); + const [disableShowNewBadge, setDisableShowNewBadge] = useState(false); const { logoUrl } = useTheme(); // Simple logo URL: use custom logo if available, otherwise default @@ -79,6 +82,11 @@ const Navbar: React.FC = ({ initializeProxySettings(); }, [accessToken]); + useEffect(() => { + const storedValue = getLocalStorageItem("disableShowNewBadge"); + setDisableShowNewBadge(storedValue === "true"); + }, []); + useEffect(() => { setLogoutUrl(proxySettings?.PROXY_LOGOUT_URL || ""); }, [proxySettings]); @@ -129,6 +137,28 @@ const Navbar: React.FC = ({ {userEmail || "Unknown"}
+
e.stopPropagation()} + > + Hide New Feature Indicators + { + setDisableShowNewBadge(checked); + if (checked) { + setLocalStorageItem("disableShowNewBadge", "true"); + emitLocalStorageChange("disableShowNewBadge"); + } else { + removeLocalStorageItem("disableShowNewBadge"); + emitLocalStorageChange("disableShowNewBadge"); + } + }} + aria-label="Toggle hide new feature indicators" + /> +
), @@ -148,11 +178,7 @@ const Navbar: React.FC = ({