From ebce0e5f8c6056d417831e8d60c4c17fba097e6d Mon Sep 17 00:00:00 2001 From: Alexsander Hamir Date: Tue, 10 Feb 2026 16:26:30 -0800 Subject: [PATCH] [Release - 02/10/2026] v1.81.10-nightly --- .circleci/config.yml | 2 - ci_cd/.grype.yaml | 33 ++++++++ ci_cd/security_scans.sh | 14 ++-- docs/my-website/docs/proxy/config_settings.md | 4 + litellm/_lazy_imports_registry.py | 4 - .../adapters/handler.py | 5 +- .../mcp_server/mcp_server_manager.py | 12 +-- .../spend_tracking/spend_tracking_utils.py | 62 ++++++++++---- model_prices_and_context_window.json | 11 +++ pyproject.toml | 4 +- requirements.txt | 2 +- tests/code_coverage_tests/check_licenses.py | 11 ++- tests/code_coverage_tests/liccheck.ini | 2 + .../code_coverage_tests/recursive_detector.py | 1 + .../image_gen_tests/test_image_generation.py | 84 ++++++++++++++++++- tests/local_testing/test_get_model_info.py | 10 ++- .../mcp_server/test_mcp_server_manager.py | 2 +- .../test_budget_endpoints.py | 1 + .../test_spend_management_endpoints.py | 1 + .../test_secret_managers_main.py | 84 ++++++++++++------- tests/test_litellm/test_constants.py | 10 ++- tests/test_litellm/test_utils.py | 1 + tests/test_litellm/test_video_generation.py | 7 +- ui/litellm-dashboard/package.json | 1 + 24 files changed, 293 insertions(+), 75 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 39182e4c6f..34c3f05cd2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3802,7 +3802,6 @@ jobs: - run: name: Get new version command: | - cd litellm-proxy-extras NEW_VERSION=$(python -c "import toml; print(toml.load('pyproject.toml')['tool']['poetry']['version'])") echo "export NEW_VERSION=$NEW_VERSION" >> $BASH_ENV @@ -3827,7 +3826,6 @@ jobs: - run: name: Publish to PyPI command: | - cd litellm-proxy-extras echo -e "[pypi]\nusername = $PYPI_PUBLISH_USERNAME\npassword = $PYPI_PUBLISH_PASSWORD" > ~/.pypirc python -m pip install --upgrade pip build twine setuptools wheel rm -rf build dist diff --git a/ci_cd/.grype.yaml b/ci_cd/.grype.yaml index 642e2dd9d0..b9bc9db58f 100644 --- a/ci_cd/.grype.yaml +++ b/ci_cd/.grype.yaml @@ -1,3 +1,36 @@ ignore: - vulnerability: CVE-2026-22184 reason: no fixed zlib package is available yet in the Wolfi repositories, so this is ignored temporarily until an upstream release exists + # Wolfi base image: Python 3.13 and Node from apk have no fixed builds in Wolfi yet / not applicable + - vulnerability: CVE-2025-55130 + reason: Node in Wolfi apk; only used for Admin UI build/prisma + - vulnerability: CVE-2025-59465 + reason: Node in Wolfi apk; only used for Admin UI build/prisma + - vulnerability: CVE-2025-55131 + reason: Node in Wolfi apk; only used for Admin UI build/prisma + - vulnerability: CVE-2025-59466 + reason: Node in Wolfi apk; only used for Admin UI build/prisma + - vulnerability: CVE-2026-21637 + reason: Node in Wolfi apk; only used for Admin UI build/prisma + - vulnerability: CVE-2025-55132 + reason: Node in Wolfi apk; only used for Admin UI build/prisma + - vulnerability: GHSA-hx9q-6w63-j58v + reason: orjson dumps recursion; allowlisted + - vulnerability: GHSA-73rr-hh4g-fpgx + reason: diff npm transitive dep; override in package.json, allowlisted + - vulnerability: CVE-2026-0865 + reason: Python 3.13 in Wolfi base; no fixed apk build yet + - vulnerability: CVE-2025-15282 + reason: Python 3.13 in Wolfi base; no fixed apk build yet + - vulnerability: CVE-2026-0672 + reason: Python 3.13 in Wolfi base; no fixed apk build yet + - vulnerability: CVE-2025-15366 + reason: Python 3.13 in Wolfi base; no fixed apk build yet + - vulnerability: CVE-2025-15367 + reason: Python 3.13 in Wolfi base; no fixed apk build yet + - vulnerability: CVE-2025-11468 + reason: Python 3.13 in Wolfi base; no fixed apk build yet + - vulnerability: CVE-2025-12781 + reason: Python 3.13 in Wolfi base; no fixed apk build yet + - vulnerability: CVE-2026-1299 + reason: Python 3.13 in Wolfi base; no fixed apk build yet diff --git a/ci_cd/security_scans.sh b/ci_cd/security_scans.sh index 3ffa13c444..2db72ae5c6 100755 --- a/ci_cd/security_scans.sh +++ b/ci_cd/security_scans.sh @@ -140,12 +140,14 @@ run_grype_scans() { "GHSA-34x7-hfp2-rc4v" # node-tar hardlink path traversal - not applicable, tar CLI not exposed in application code "GHSA-r6q2-hw4h-h46w" # node-tar not used by application runtime, Linux-only container, not affect by macOS APFS-specific exploit "GHSA-8rrh-rw8j-w5fx" # wheel is from chainguard and will be handled by then TODO: Remove this after Chainguard updates the wheel - "CVE-2025-59465" # We do not use Node in application runtime, only used for building Admin UI - "CVE-2025-55131" # We do not use Node in application runtime, only used for building Admin UI - "CVE-2025-59466" # We do not use Node in application runtime, only used for building Admin UI - "CVE-2025-55130" # We do not use Node in application runtime, only used for building Admin UI - "CVE-2025-59467" # We do not use Node in application runtime, only used for building Admin UI - "CVE-2026-21637" # We do not use Node in application runtime, only used for building Admin UI + "CVE-2025-59465" # Node only used for Admin UI build/prisma + "CVE-2025-55131" # Node only used for Admin UI build/prisma + "CVE-2025-59466" # Node only used for Admin UI build/prisma + "CVE-2025-55130" # Node only used for Admin UI build/prisma + "CVE-2025-59467" # Node only used for Admin UI build/prisma + "CVE-2026-21637" # Node only used for Admin UI build/prisma + "CVE-2025-55132" # Node only used for Admin UI build/prisma + "GHSA-hx9q-6w63-j58v" # orjson dumps recursion; allowlisted "CVE-2025-15281" # No fix available yet "CVE-2026-0865" # No fix available yet "CVE-2025-15282" # No fix available yet diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index c78a5c9243..5b6c6669b9 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -548,6 +548,10 @@ router_settings: | DEFAULT_MCP_SEMANTIC_FILTER_EMBEDDING_MODEL | Default embedding model for MCP semantic tool filtering. Default is "text-embedding-3-small" | DEFAULT_MCP_SEMANTIC_FILTER_SIMILARITY_THRESHOLD | Default similarity threshold for MCP semantic tool filtering. Default is 0.3 | DEFAULT_MCP_SEMANTIC_FILTER_TOP_K | Default number of top results to return for MCP semantic tool filtering. Default is 10 +| MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL | Default TTL in seconds for MCP OAuth2 token cache. Default is 3600 +| MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE | Maximum number of entries in MCP OAuth2 token cache. Default is 200 +| MCP_OAUTH2_TOKEN_CACHE_MIN_TTL | Minimum TTL in seconds for MCP OAuth2 token cache. Default is 10 +| MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS | Seconds to subtract from token expiry when computing cache TTL. Default is 60 | DEFAULT_MOCK_RESPONSE_COMPLETION_TOKEN_COUNT | Default token count for mock response completions. Default is 20 | DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT | Default token count for mock response prompts. Default is 10 | DEFAULT_MODEL_CREATED_AT_TIME | Default creation timestamp for models. Default is 1677610602 diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index 051b957ed1..ebe9af9d85 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -1039,10 +1039,6 @@ _LLM_CONFIGS_IMPORT_MAP = { ".llms.perplexity.chat.transformation", "PerplexityChatConfig", ), - "PerplexityResponsesConfig": ( - ".llms.perplexity.responses.transformation", - "PerplexityResponsesConfig", - ), "AzureOpenAIO1Config": ( ".llms.azure.chat.o_series_transformation", "AzureOpenAIO1Config", diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index 296ae97aea..c6caaddf98 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -64,7 +64,10 @@ class LiteLLMMessagesToCompletionTransformationHandler: model = completion_kwargs.get("model") if isinstance(model, str) and model and not model.startswith("responses/"): - reasoning_effort = completion_kwargs.get("reasoning_effort") + # Prefix model with "responses/" to route to OpenAI Responses API + completion_kwargs["model"] = f"responses/{model}" + + reasoning_effort = completion_kwargs.get("reasoning_effort") if isinstance(reasoning_effort, str) and reasoning_effort: completion_kwargs["reasoning_effort"] = { "effort": reasoning_effort, diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 532aea249b..24eae430d8 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -341,7 +341,7 @@ class MCPServerManager: verbose_logger.info( f"Loading OpenAPI spec from {spec_path} for server {server_name}" ) - self._register_openapi_tools( + await self._register_openapi_tools( spec_path=spec_path, server=new_server, base_url=server_config.get("url", ""), @@ -353,7 +353,9 @@ class MCPServerManager: self.initialize_tool_name_to_mcp_server_name_mapping() - def _register_openapi_tools(self, spec_path: str, server: MCPServer, base_url: str): + async def _register_openapi_tools( + self, spec_path: str, server: MCPServer, base_url: str + ): """ Register tools from an OpenAPI specification for a given server. @@ -375,15 +377,15 @@ class MCPServerManager: get_base_url as get_openapi_base_url, ) from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( - load_openapi_spec, + load_openapi_spec_async, ) from litellm.proxy._experimental.mcp_server.tool_registry import ( global_mcp_tool_registry, ) try: - # Load OpenAPI spec - spec = load_openapi_spec(spec_path) + # Load OpenAPI spec (async to avoid "called from within a running event loop") + spec = await load_openapi_spec_async(spec_path) # Use base_url from config if provided, otherwise extract from spec if not base_url: diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index cb8b9ec039..2f392c48e9 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -641,7 +641,9 @@ def _sanitize_request_body_for_spend_logs_payload( return {k: _sanitize_value(v) for k, v in request_body.items()} -def _convert_to_json_serializable_dict(obj: Any) -> Any: +def _convert_to_json_serializable_dict( + obj: Any, visited: Optional[set] = None, max_depth: int = 20 +) -> Any: """ Convert object to JSON-serializable dict, handling Pydantic models safely. @@ -650,23 +652,55 @@ def _convert_to_json_serializable_dict(obj: Any) -> Any: Args: obj: Object to convert (dict, list, Pydantic model, or primitive) + visited: Set of object IDs to track circular references + max_depth: Maximum recursion depth to prevent infinite recursion Returns: JSON-serializable version of the object """ - if isinstance(obj, BaseModel): - # Use Pydantic's model_dump() instead of pickle - return obj.model_dump() - elif isinstance(obj, dict): - return {k: _convert_to_json_serializable_dict(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [_convert_to_json_serializable_dict(item) for item in obj] - elif hasattr(obj, "__dict__"): - # Handle objects with __dict__ attribute - return _convert_to_json_serializable_dict(obj.__dict__) - else: - # Primitives (str, int, float, bool, None) pass through - return obj + if max_depth <= 0: + # Return a placeholder if max depth is exceeded + return "" + + if visited is None: + visited = set() + + # Get the object's memory address to track visited objects + obj_id = id(obj) + if obj_id in visited: + # Circular reference detected, return placeholder + return "" + + # Only track mutable objects (dict, list, objects with __dict__) + if isinstance(obj, (dict, list)) or hasattr(obj, "__dict__"): + visited.add(obj_id) + + try: + if isinstance(obj, BaseModel): + # Use Pydantic's model_dump() instead of pickle + result = obj.model_dump() + # Recursively process the dumped dict + return _convert_to_json_serializable_dict(result, visited, max_depth - 1) + elif isinstance(obj, dict): + return { + k: _convert_to_json_serializable_dict(v, visited, max_depth - 1) + for k, v in obj.items() + } + elif isinstance(obj, list): + return [ + _convert_to_json_serializable_dict(item, visited, max_depth - 1) + for item in obj + ] + elif hasattr(obj, "__dict__"): + # Handle objects with __dict__ attribute + return _convert_to_json_serializable_dict(obj.__dict__, visited, max_depth - 1) + else: + # Primitives (str, int, float, bool, None) pass through + return obj + finally: + # Remove from visited set when done processing this object + if obj_id in visited: + visited.remove(obj_id) def _get_proxy_server_request_for_spend_logs_payload( diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index d794aa50d2..35538ab100 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -6115,6 +6115,17 @@ "supports_function_calling": true, "supports_reasoning": true }, + "bedrock/moonshotai.kimi-k2-thinking": { + "input_cost_per_token": 7.3e-07, + "litellm_provider": "bedrock", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 3.03e-06, + "supports_function_calling": true, + "supports_reasoning": true + }, "bedrock/moonshotai.kimi-k2.5": { "input_cost_per_token": 7.3e-07, "litellm_provider": "bedrock", diff --git a/pyproject.toml b/pyproject.toml index eb68cd2f4a..acb8bc2ada 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "litellm" -version = "1.81.9" +version = "1.81.10" description = "Library to easily interface with LLM API providers" authors = ["BerriAI"] license = "MIT" @@ -175,7 +175,7 @@ requires = ["poetry-core", "wheel"] build-backend = "poetry.core.masonry.api" [tool.commitizen] -version = "1.81.9" +version = "1.81.10" version_files = [ "pyproject.toml:^version" ] diff --git a/requirements.txt b/requirements.txt index be4c6e2f7e..bd313b105e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,7 +32,7 @@ async_generator==1.10.0 # for async ollama calls langfuse==2.59.7 # for langfuse self-hosted logging prometheus_client==0.20.0 # for /metrics endpoint on proxy ddtrace==2.19.0 # for advanced DD tracing / profiling -orjson==3.11.2 # fast /embedding responses +orjson==3.11.7 # fast /embedding responses polars==1.31.0 # for data processing apscheduler==3.10.4 # for resetting budget in background fastapi-sso==0.19.0 # admin UI, SSO diff --git a/tests/code_coverage_tests/check_licenses.py b/tests/code_coverage_tests/check_licenses.py index dc7cc7c54a..f49f6807a0 100644 --- a/tests/code_coverage_tests/check_licenses.py +++ b/tests/code_coverage_tests/check_licenses.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 import sys -import pkg_resources + import requests +from packaging.requirements import Requirement from pathlib import Path import json from typing import Dict, List, Optional, Set, Tuple @@ -213,7 +214,7 @@ class LicenseChecker: try: with open(requirements_file) as f: requirements = [ - pkg_resources.Requirement.parse(line) + Requirement(line.strip()) for line in f if line.strip() and not line.startswith("#") ] @@ -225,8 +226,10 @@ class LicenseChecker: for req in requirements: try: - version = next(iter(req.specs))[1] if req.specs else None - except Exception: + version = ( + next(iter(req.specifier)).version if req.specifier else None + ) + except StopIteration: version = None if not self.check_package(req.name, version): diff --git a/tests/code_coverage_tests/liccheck.ini b/tests/code_coverage_tests/liccheck.ini index dc46f83366..e6e9d761ad 100644 --- a/tests/code_coverage_tests/liccheck.ini +++ b/tests/code_coverage_tests/liccheck.ini @@ -111,6 +111,7 @@ ddtrace: >=2.19.0 # Unknown license orjson: >=3.10.12 # Unknown license apscheduler: >=3.10.4 # Unknown license fastapi-sso: >=0.16.0 # Unknown license +filelock: >=3.20.0 # Unlicense (public domain) - https://unlicense.org / https://github.com/tox-dev/filelock pyjwt: >=2.9.0 # Unknown license python-multipart: >=0.0.18 # Unknown license pillow: >=11.0.0 # Unknown license @@ -123,6 +124,7 @@ opentelemetry-exporter-otlp: >=1.25.0 # Unknown license sentry_sdk: >=2.21.0 # Unknown license cryptography: >=43.0.1 # Unknown license tzdata: >=2025.1 # Unknown license +urllib3: >=2.0.0 # MIT license - https://github.com/urllib3/urllib3 python-dotenv: >=1.0.0 # Unknown license tiktoken: >=0.8.0 # Unknown license click: >=8.1.7 # Unknown license diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index 71e7798b09..d6bf1941a0 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -42,6 +42,7 @@ IGNORE_FUNCTIONS = [ "_validate_inheritance_chain", # max depth set (default 100) to prevent infinite recursion in policy inheritance validation. "_basic_json_schema_validate", # max depth set. "extract_text_from_a2a_message", # max depth set (default 10) to prevent infinite recursion in A2A message parsing. + "_convert_to_json_serializable_dict", # max depth set (default 20) and circular reference protection to prevent infinite recursion. ] diff --git a/tests/image_gen_tests/test_image_generation.py b/tests/image_gen_tests/test_image_generation.py index 0567f60ecf..3b4abeeb82 100644 --- a/tests/image_gen_tests/test_image_generation.py +++ b/tests/image_gen_tests/test_image_generation.py @@ -5,7 +5,7 @@ import logging import os import sys import traceback -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch sys.path.insert( @@ -25,7 +25,7 @@ import pytest import litellm import json import tempfile -from base_image_generation_test import BaseImageGenTest +from base_image_generation_test import BaseImageGenTest, TestCustomLogger import logging from litellm._logging import verbose_logger @@ -182,6 +182,86 @@ class TestAimlImageGeneration(BaseImageGenTest): def get_base_image_generation_call_args(self) -> dict: return {"model": "aiml/flux-pro/v1.1"} + @pytest.mark.asyncio(scope="module") + @pytest.mark.flaky(retries=0) + async def test_basic_image_generation(self): + """Test basic image generation""" + from unittest.mock import AsyncMock, patch + + mock_aiml_response = { + "created": 1703658209, + "data": [{"url": "https://example.com/generated_image.png"}], + } + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_aiml_response + mock_response.text = json.dumps(mock_aiml_response) + mock_response.headers = {} + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new_callable=AsyncMock, + ) as mock_async_post, patch( + "litellm.llms.custom_httpx.http_handler.HTTPHandler.post", + ) as mock_sync_post: + mock_async_post.return_value = mock_response + mock_sync_post.return_value = mock_response + + try: + litellm._turn_on_debug() + custom_logger = TestCustomLogger() + litellm.logging_callback_manager._reset_all_callbacks() + litellm.callbacks = [custom_logger] + base_image_generation_call_args = self.get_base_image_generation_call_args() + litellm.set_verbose = True + # Pass dummy api_key so validate_environment passes; HTTP is mocked + response = await litellm.aimage_generation( + **base_image_generation_call_args, + prompt="A image of a otter", + api_key="test-key-mocked-no-credits-needed", + ) + print("FAL AI RESPONSE: ", response) + + await asyncio.sleep(1) + + # assert response._hidden_params["response_cost"] is not None + # assert response._hidden_params["response_cost"] > 0 + # print("response_cost", response._hidden_params["response_cost"]) + + logged_standard_logging_payload = custom_logger.standard_logging_payload + print("logged_standard_logging_payload", logged_standard_logging_payload) + assert logged_standard_logging_payload is not None + assert logged_standard_logging_payload["response_cost"] is not None + assert logged_standard_logging_payload["response_cost"] > 0 + import openai + from openai.types.images_response import ImagesResponse + + # print openai version + print("openai version=", openai.__version__) + + response_dict = dict(response) + if "usage" in response_dict: + response_dict["usage"] = dict(response_dict["usage"]) + print("response usage=", response_dict.get("usage")) + + assert response.data is not None # type guard for iteration (base fails here if None) + for d in response.data: + assert isinstance(d, Image) + print("data in response.data", d) + assert d.b64_json is not None or d.url is not None + except litellm.RateLimitError as e: + pass + except litellm.ContentPolicyViolationError: + pass # Azure randomly raises these errors - skip when they occur + except litellm.InternalServerError: + pass + except Exception as e: + if "Your task failed as a result of our safety system." in str(e): + pass + else: + pytest.fail(f"An exception occurred - {str(e)}") + + class TestGoogleImageGen(BaseImageGenTest): def get_base_image_generation_call_args(self) -> dict: return {"model": "gemini/imagen-4.0-generate-001"} diff --git a/tests/local_testing/test_get_model_info.py b/tests/local_testing/test_get_model_info.py index b84fc22af0..d46a087eb7 100644 --- a/tests/local_testing/test_get_model_info.py +++ b/tests/local_testing/test_get_model_info.py @@ -311,7 +311,15 @@ def test_get_model_info_bedrock_models(): for commitment in potential_commitments: k = k.replace(f"{commitment}/", "") base_model = BedrockModelInfo.get_base_model(k) - base_model_info = litellm.model_cost[base_model] + # get_base_model() returns model id without "bedrock/" prefix; cost map keys use "bedrock/" + base_model_key = ( + base_model + if base_model in litellm.model_cost + else f"bedrock/{base_model}" + ) + if base_model_key not in litellm.model_cost: + continue + base_model_info = litellm.model_cost[base_model_key] for base_model_key, base_model_value in base_model_info.items(): if "invoke/" in k: continue 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 abb8dd4915..b4b5811666 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 @@ -1043,7 +1043,7 @@ class TestMCPServerManager: "litellm.proxy._experimental.mcp_server.tool_registry.global_mcp_tool_registry.register_tool", return_value=None, ): - manager._register_openapi_tools( + await manager._register_openapi_tools( spec_path=str(spec_path), server=server, base_url="https://example.com", diff --git a/tests/test_litellm/proxy/management_endpoints/test_budget_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_budget_endpoints.py index d8c505223d..b15b9d622e 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_budget_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_budget_endpoints.py @@ -21,6 +21,7 @@ sys.path.insert( def client_and_mocks(monkeypatch): # Setup MagicMock Prisma mock_prisma = MagicMock() + mock_table = MagicMock() mock_table.create = AsyncMock(side_effect=lambda *, data: data) mock_table.update = AsyncMock(side_effect=lambda *, where, data: {**where, **data}) diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index dd34cb47da..08205cd2d9 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -206,6 +206,7 @@ ignored_keys = [ "metadata.additional_usage_values.cache_creation_input_tokens", "metadata.additional_usage_values.cache_read_input_tokens", "metadata.additional_usage_values.inference_geo", + "metadata.additional_usage_values.speed", "metadata.litellm_overhead_time_ms", "metadata.cost_breakdown", ] diff --git a/tests/test_litellm/secret_managers/test_secret_managers_main.py b/tests/test_litellm/secret_managers/test_secret_managers_main.py index eaef6956cd..4a6e303586 100644 --- a/tests/test_litellm/secret_managers/test_secret_managers_main.py +++ b/tests/test_litellm/secret_managers/test_secret_managers_main.py @@ -46,15 +46,24 @@ def mock_env(): yield os.environ -@patch("litellm.secret_managers.main.oidc_cache") -@patch("litellm.secret_managers.main._get_oidc_http_handler") -@patch("httpx.Client") # Prevent any real HTTP connections -def test_oidc_google_success(mock_httpx_client, mock_get_http_handler, mock_oidc_cache): - mock_oidc_cache.get_cache.return_value = None - mock_handler = MockHTTPHandler(timeout=600.0) - mock_get_http_handler.return_value = mock_handler +def test_oidc_google_success(): + """Test Google OIDC token fetch with mocked handler (no real network calls).""" secret_name = "oidc/google/[invalid url, do not cite]" - result = get_secret(secret_name) + mock_handler = MockHTTPHandler(timeout=600.0) + mock_get_http_handler = Mock(return_value=mock_handler) + mock_oidc_cache = Mock() + mock_oidc_cache.get_cache.return_value = None + + with patch("litellm.secret_managers.main.oidc_cache", mock_oidc_cache): + with patch( + "litellm.secret_managers.main._get_oidc_http_handler", + mock_get_http_handler, + ): + with patch( + "litellm.secret_managers.main.HTTPHandler", + side_effect=lambda timeout=None: mock_handler, + ): + result = get_secret(secret_name) assert result == "mocked_token" assert mock_handler.last_params == {"audience": "[invalid url, do not cite]"} @@ -63,32 +72,49 @@ def test_oidc_google_success(mock_httpx_client, mock_get_http_handler, mock_oidc ) -@patch("litellm.secret_managers.main.oidc_cache") -@patch("litellm.secret_managers.main._get_oidc_http_handler") -def test_oidc_google_cached(mock_get_http_handler, mock_oidc_cache): +def test_oidc_google_cached(): + """Test Google OIDC uses cache and does not call HTTP (no real network calls).""" + secret_name = "oidc/google/[invalid url, do not cite]" + mock_get_http_handler = Mock() + mock_oidc_cache = Mock() mock_oidc_cache.get_cache.return_value = "cached_token" - secret_name = "oidc/google/[invalid url, do not cite]" - result = get_secret(secret_name) + with patch("litellm.secret_managers.main.oidc_cache", mock_oidc_cache): + with patch( + "litellm.secret_managers.main._get_oidc_http_handler", + mock_get_http_handler, + ): + with patch( + "litellm.secret_managers.main.HTTPHandler", + Mock(side_effect=AssertionError("HTTPHandler should not be used")), + ): + result = get_secret(secret_name) assert result == "cached_token", f"Expected cached token, got {result}" mock_oidc_cache.get_cache.assert_called_with(key=secret_name) - # Verify HTTP handler was never called since we had a cached token mock_get_http_handler.assert_not_called() -@patch("litellm.secret_managers.main.oidc_cache") -@patch("litellm.secret_managers.main._get_oidc_http_handler") -def test_oidc_google_failure(mock_get_http_handler, mock_oidc_cache): +def test_oidc_google_failure(): + """Test Google OIDC raises when provider returns error (no real network calls).""" + secret_name = "oidc/google/https://example.com/api" mock_handler = MockHTTPHandler(timeout=600.0) mock_handler.status_code = 400 - mock_get_http_handler.return_value = mock_handler + mock_get_http_handler = Mock(return_value=mock_handler) + mock_oidc_cache = Mock() mock_oidc_cache.get_cache.return_value = None - - secret_name = "oidc/google/https://example.com/api" - with pytest.raises(ValueError, match="Google OIDC provider failed"): - get_secret(secret_name) + with patch("litellm.secret_managers.main.oidc_cache", mock_oidc_cache): + with patch( + "litellm.secret_managers.main._get_oidc_http_handler", + mock_get_http_handler, + ): + with patch( + "litellm.secret_managers.main.HTTPHandler", + side_effect=lambda timeout=None: mock_handler, + ): + with pytest.raises(ValueError, match="Google OIDC provider failed"): + get_secret(secret_name) def test_oidc_circleci_success(monkeypatch): @@ -151,20 +177,18 @@ def test_oidc_azure_file_success(mock_env, tmp_path): @patch("litellm.secret_managers.main.get_azure_ad_token_provider") -@patch.dict(os.environ, {}, clear=False) # Ensure AZURE_FEDERATED_TOKEN_FILE is not set -def test_oidc_azure_ad_token_success(mock_get_azure_ad_token_provider): - # Ensure the env var is not set so it falls through to Azure AD token provider - if "AZURE_FEDERATED_TOKEN_FILE" in os.environ: - del os.environ["AZURE_FEDERATED_TOKEN_FILE"] - +def test_oidc_azure_ad_token_success(mock_get_azure_ad_token_provider, monkeypatch): + # Force-unset so we always hit the Azure AD token provider path (CI may set AZURE_FEDERATED_TOKEN_FILE) + monkeypatch.delenv("AZURE_FEDERATED_TOKEN_FILE", raising=False) + # Mock the token provider function that gets returned and called mock_token_provider = Mock(return_value="azure_ad_token") mock_get_azure_ad_token_provider.return_value = mock_token_provider - + # Also mock the Azure Identity SDK to prevent any real Azure calls with patch("azure.identity.get_bearer_token_provider") as mock_bearer: mock_bearer.return_value = mock_token_provider - + secret_name = "oidc/azure/api://azure-audience" result = get_secret(secret_name) diff --git a/tests/test_litellm/test_constants.py b/tests/test_litellm/test_constants.py index 77f2f308f8..23447a02e0 100644 --- a/tests/test_litellm/test_constants.py +++ b/tests/test_litellm/test_constants.py @@ -38,6 +38,11 @@ def test_all_numeric_constants_can_be_overridden(): print("all numeric constants", json.dumps(numeric_constants, indent=4)) + # Constants that use a different env var name than the constant name + constant_to_env_var = { + "MAX_CALLBACKS": "LITELLM_MAX_CALLBACKS", + } + # Verify all numeric constants have environment variable support for name, value in numeric_constants: # Skip constants that are not meant to be overridden (if any) @@ -47,8 +52,11 @@ def test_all_numeric_constants_can_be_overridden(): # Create a test value that's different from the default test_value = value + 1 if isinstance(value, int) else value + 0.1 + # Use the env var name that the constants module actually reads + env_var_name = constant_to_env_var.get(name, name) + # Set the environment variable - with mock.patch.dict(os.environ, {name: str(test_value)}): + with mock.patch.dict(os.environ, {env_var_name: str(test_value)}): print("overriding", name, "with", test_value) importlib.reload(constants) diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 352125d16c..794b3b8718 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -661,6 +661,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "supports_url_context": {"type": "boolean"}, "supports_reasoning": {"type": "boolean"}, "supports_service_tier": {"type": "boolean"}, + "supports_preset": {"type": "boolean"}, "tool_use_system_prompt_tokens": {"type": "number"}, "tpm": {"type": "number"}, "supported_endpoints": { diff --git a/tests/test_litellm/test_video_generation.py b/tests/test_litellm/test_video_generation.py index 5446a0a7b3..c8cc292519 100644 --- a/tests/test_litellm/test_video_generation.py +++ b/tests/test_litellm/test_video_generation.py @@ -798,7 +798,7 @@ def test_openai_transform_video_content_request_empty_params(): def test_video_content_handler_uses_get_for_openai(): """HTTP handler must use GET (not POST) for OpenAI content download.""" from litellm.types.router import GenericLiteLLMParams - + handler = BaseLLMHTTPHandler() config = OpenAIVideoConfig() @@ -807,7 +807,12 @@ def test_video_content_handler_uses_get_for_openai(): mock_response.content = b"mp4-bytes" mock_client.get.return_value = mock_response + # Patch both where _get_httpx_client is used and where it is defined so the mock + # is used regardless of import order / CI environment with patch( + "litellm.llms.custom_httpx.http_handler._get_httpx_client", + return_value=mock_client, + ), patch( "litellm.llms.custom_httpx.llm_http_handler._get_httpx_client", return_value=mock_client, ): diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 74caf14a59..164368eb6b 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -79,6 +79,7 @@ "vitest": "^3.2.4" }, "overrides": { + "diff": ">=8.0.3", "prismjs": ">=1.30.0", "webpack-dev-server": ">=5.2.1", "mermaid": ">=11.10.0",