From 9a3d9b263241c97e58115bf7a0260ebb8166fe0f Mon Sep 17 00:00:00 2001 From: Vinh Pham Huu Date: Wed, 15 Apr 2026 17:11:12 +0700 Subject: [PATCH 01/48] feat: Enhance support for video metadata across all Gemini models in transformation logic and tests --- docs/my-website/docs/providers/vertex.md | 11 ++- .../llms/vertex_ai/gemini/transformation.py | 22 ++--- .../test_vertex_ai_gemini_transformation.py | 88 +++++++++++++++++++ ...test_vertex_and_google_ai_studio_gemini.py | 46 +++------- 4 files changed, 117 insertions(+), 50 deletions(-) diff --git a/docs/my-website/docs/providers/vertex.md b/docs/my-website/docs/providers/vertex.md index 0079bd2f57..835e3bbcc2 100644 --- a/docs/my-website/docs/providers/vertex.md +++ b/docs/my-website/docs/providers/vertex.md @@ -2061,7 +2061,7 @@ assert isinstance( ## Media Resolution Control (Images & Videos) -For Gemini 3+ models, LiteLLM supports per-part media resolution control using OpenAI's `detail` parameter. This allows you to specify different resolution levels for individual images and videos in your request, whether using `image_url` or `file` content types. +LiteLLM supports per-part media resolution control using OpenAI's `detail` parameter for all Gemini models. This allows you to specify different resolution levels for individual images and videos in your request, whether using `image_url` or `file` content types. **Supported `detail` values:** - `"low"` - Maps to `media_resolution: "low"` (280 tokens for images, 70 tokens per frame for videos) @@ -2146,12 +2146,12 @@ response = completion( :::info -**Per-Part Resolution:** Each image or video in your request can have its own `detail` setting, allowing mixed-resolution requests (e.g., a high-res chart alongside a low-res icon). This feature works with both `image_url` and `file` content types, and is only available for Gemini 3+ models. +**Per-Part Resolution:** Each image or video in your request can have its own `detail` setting, allowing mixed-resolution requests (e.g., a high-res chart alongside a low-res icon). This feature works with both `image_url` and `file` content types across all Gemini models. ::: ## Video Metadata Control -For Gemini 3+ models, LiteLLM supports fine-grained video processing control through the `video_metadata` field. This allows you to specify frame extraction rates and time ranges for video analysis. +LiteLLM supports fine-grained video processing control through the `video_metadata` field for all Gemini models (1.x, 2.x, 3+). This allows you to specify frame extraction rates and time ranges for video analysis. **Supported `video_metadata` parameters:** @@ -2168,8 +2168,11 @@ For Gemini 3+ models, LiteLLM supports fine-grained video processing control thr - `fps` remains unchanged ::: +:::tip +Video clipping (`start_offset`/`end_offset`) and frame rate control (`fps`) are supported by all Gemini models, but analysis quality is significantly higher with the **Gemini 2.5 series** (e.g., `gemini-2.5-flash`, `gemini-2.5-pro`). +::: + :::warning -- **Gemini 3+ Only:** This feature is only available for Gemini 3.0 and newer models - **Video Files Recommended:** While `video_metadata` is designed for video files, error handling for other media types is delegated to the Vertex AI API - **File Formats Supported:** Works with `gs://`, `https://`, and base64-encoded video files ::: diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index 6157a384dc..d49a16fa90 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -131,23 +131,19 @@ def _extract_max_media_resolution_from_messages( return max_resolution -def _apply_gemini_3_metadata( +def _apply_gemini_metadata( part: PartType, model: Optional[str], media_resolution_enum: Optional[Dict[str, str]], video_metadata: Optional[Dict[str, Any]], ) -> PartType: """ - Apply the unique media_resolution and video_metadata parameters of Gemini 3+ + Apply media_resolution and video_metadata parameters to a Gemini part. + Both are supported across all Gemini models (1.x, 2.x, 3+). """ if model is None: return part - from .vertex_and_google_ai_studio_gemini import VertexGeminiConfig - - if not VertexGeminiConfig._is_gemini_3_or_newer(model): - return part - part_dict = dict(part) if media_resolution_enum is not None: @@ -205,7 +201,7 @@ def _process_gemini_media( mime_type = format file_data = FileDataType(mime_type=mime_type, file_uri=image_url) part: PartType = {"file_data": file_data} - return _apply_gemini_3_metadata( + return _apply_gemini_metadata( part, model, media_resolution_enum, video_metadata ) elif ( @@ -215,14 +211,14 @@ def _process_gemini_media( ): file_data = FileDataType(mime_type=image_type, file_uri=image_url) part = {"file_data": file_data} - return _apply_gemini_3_metadata( + return _apply_gemini_metadata( part, model, media_resolution_enum, video_metadata ) elif "http://" in image_url or "https://" in image_url or "base64" in image_url: image = convert_to_anthropic_image_obj(image_url, format=format) _blob: BlobType = {"data": image["data"], "mime_type": image["media_type"]} part = {"inline_data": cast(BlobType, _blob)} - return _apply_gemini_3_metadata( + return _apply_gemini_metadata( part, model, media_resolution_enum, video_metadata ) raise Exception("Invalid image received - {}".format(image_url)) @@ -732,9 +728,9 @@ def _transform_request_body( # noqa: PLR0915 **filtered_params ) - # For Gemini 2.x models, add media_resolution to generation_config (global) - # Gemini 3+ supports per-part media_resolution, but 2.x only supports global - # Gemini 1.x does not support mediaResolution at all + # For Gemini 2.x models, also add media_resolution to generation_config (global) + # as a fallback, since some 2.x versions may not support per-part media_resolution. + # Gemini 1.x does not support mediaResolution at all. if "gemini-2" in model: max_media_resolution = _extract_max_media_resolution_from_messages(messages) if max_media_resolution: diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py index 98cdf83030..ab9ae4c167 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py @@ -930,6 +930,94 @@ class TestMediaResolution: assert "mediaResolution" not in result["generationConfig"] +# Tests for VideoMetadata support across all Gemini models (Issue #25474) +class TestVideoMetadataAllGeminiModels: + """Tests that video_metadata (fps, start_offset, end_offset) works for all Gemini models""" + + def _make_video_messages(self, video_metadata: dict) -> list: + return [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Analyze this video"}, + { + "type": "file", + "file": { + "file_id": "gs://bucket/video.mp4", + "format": "video/mp4", + "video_metadata": video_metadata, + }, + }, + ], + } + ] + + def _get_file_part(self, contents: list) -> dict: + for part in contents[0]["parts"]: + if "file_data" in part: + return part + raise AssertionError("No file part found in contents") + + def test_video_metadata_fps_gemini_2_5_flash(self): + """Gemini 2.5 Flash: fps in video_metadata should be forwarded (Issue #25474)""" + messages = self._make_video_messages({"fps": 5}) + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-2.5-flash" + ) + file_part = self._get_file_part(contents) + assert "video_metadata" in file_part + assert file_part["video_metadata"]["fps"] == 5 + + def test_video_metadata_fps_gemini_2_5_pro(self): + """Gemini 2.5 Pro: fps in video_metadata should be forwarded (Issue #25474)""" + messages = self._make_video_messages({"fps": 10}) + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-2.5-pro" + ) + file_part = self._get_file_part(contents) + assert "video_metadata" in file_part + assert file_part["video_metadata"]["fps"] == 10 + + def test_video_metadata_offsets_gemini_2_5_flash(self): + """Gemini 2.5 Flash: start_offset/end_offset converted to camelCase (Issue #25474)""" + messages = self._make_video_messages( + {"start_offset": "5s", "end_offset": "30s"} + ) + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-2.5-flash" + ) + file_part = self._get_file_part(contents) + assert "video_metadata" in file_part + vm = file_part["video_metadata"] + assert vm["startOffset"] == "5s" + assert vm["endOffset"] == "30s" + + def test_video_metadata_all_fields_gemini_2_5_flash(self): + """Gemini 2.5 Flash: all video_metadata fields forwarded correctly (Issue #25474)""" + messages = self._make_video_messages( + {"fps": 5, "start_offset": "10s", "end_offset": "60s"} + ) + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-2.5-flash" + ) + file_part = self._get_file_part(contents) + assert "video_metadata" in file_part + vm = file_part["video_metadata"] + assert vm["fps"] == 5 + assert vm["startOffset"] == "10s" + assert vm["endOffset"] == "60s" + + def test_video_metadata_gemini_1_5_pro(self): + """Gemini 1.5 Pro: video_metadata should also be forwarded (Issue #25474)""" + messages = self._make_video_messages({"fps": 2}) + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-1.5-pro" + ) + file_part = self._get_file_part(contents) + assert "video_metadata" in file_part + assert file_part["video_metadata"]["fps"] == 2 + + def test_convert_tool_response_with_base64_image(): """Test tool response with base64 data URI image.""" # Create a small test image (1x1 red pixel PNG) 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 a097966494..f77bba56cb 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 @@ -3476,8 +3476,8 @@ def test_new_detail_levels(): assert file_part["media_resolution"] == {"level": "MEDIA_RESOLUTION_MEDIUM"} -def test_video_metadata_only_for_gemini_3(): - """Test that video_metadata is only applied for Gemini 3+ models (Issue #19026)""" +def test_video_metadata_supported_for_all_gemini_models(): + """Test that video_metadata is applied for all Gemini models (Issue #25474)""" from litellm.llms.vertex_ai.gemini.transformation import ( _gemini_convert_messages_with_history, ) @@ -3499,39 +3499,19 @@ def test_video_metadata_only_for_gemini_3(): } ] - # Test with Gemini 1.5 (should not have video_metadata or media_resolution) - contents_1_5 = _gemini_convert_messages_with_history( - messages=messages, model="gemini-1.5-pro" - ) + for model in ["gemini-1.5-pro", "gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-pro-preview"]: + contents = _gemini_convert_messages_with_history(messages=messages, model=model) - file_part_1_5 = None - for part in contents_1_5[0]["parts"]: - if "file_data" in part: - file_part_1_5 = part - break + file_part = None + for part in contents[0]["parts"]: + if "file_data" in part: + file_part = part + break - assert file_part_1_5 is not None - assert ( - "media_resolution" not in file_part_1_5 - ), "Gemini 1.5 should not have media_resolution" - assert ( - "video_metadata" not in file_part_1_5 - ), "Gemini 1.5 should not have video_metadata" - - # Test with Gemini 3 (should have both) - contents_3 = _gemini_convert_messages_with_history( - messages=messages, model="gemini-3-pro-preview" - ) - - file_part_3 = None - for part in contents_3[0]["parts"]: - if "file_data" in part: - file_part_3 = part - break - - assert file_part_3 is not None - assert "media_resolution" in file_part_3, "Gemini 3 should have media_resolution" - assert "video_metadata" in file_part_3, "Gemini 3 should have video_metadata" + assert file_part is not None, f"{model}: file part should exist" + assert "video_metadata" in file_part, f"{model}: video_metadata should be present" + assert file_part["video_metadata"]["fps"] == 5, f"{model}: fps should be 5" + assert "media_resolution" in file_part, f"{model}: media_resolution should be present" def test_chunk_parser_handles_prompt_feedback_block(): From 61aee29b41fa55ca039ca6a9eb64a525939afe9b Mon Sep 17 00:00:00 2001 From: Vinh Pham Huu Date: Wed, 15 Apr 2026 17:56:15 +0700 Subject: [PATCH 02/48] feat: Update video metadata handling and media resolution checks for Gemini models --- litellm/llms/vertex_ai/gemini/transformation.py | 10 ++++++++-- .../gemini/test_vertex_and_google_ai_studio_gemini.py | 10 ++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index d49a16fa90..5eaac5e48c 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -139,14 +139,20 @@ def _apply_gemini_metadata( ) -> PartType: """ Apply media_resolution and video_metadata parameters to a Gemini part. - Both are supported across all Gemini models (1.x, 2.x, 3+). + + - Per-part media_resolution: Gemini 3+ only (2.x uses generation_config global). + - video_metadata (fps, startOffset, endOffset): all Gemini models (1.x, 2.x, 3+). """ if model is None: return part + from .vertex_and_google_ai_studio_gemini import VertexGeminiConfig + part_dict = dict(part) - if media_resolution_enum is not None: + if media_resolution_enum is not None and VertexGeminiConfig._is_gemini_3_or_newer( + model + ): part_dict["media_resolution"] = media_resolution_enum if video_metadata is not None: 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 f77bba56cb..1ea7486a51 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 @@ -3511,8 +3511,18 @@ def test_video_metadata_supported_for_all_gemini_models(): assert file_part is not None, f"{model}: file part should exist" assert "video_metadata" in file_part, f"{model}: video_metadata should be present" assert file_part["video_metadata"]["fps"] == 5, f"{model}: fps should be 5" + + # Per-part media_resolution is Gemini 3+ only; 2.x uses generation_config global + for model in ["gemini-3-pro-preview"]: + contents = _gemini_convert_messages_with_history(messages=messages, model=model) + file_part = next(p for p in contents[0]["parts"] if "file_data" in p) assert "media_resolution" in file_part, f"{model}: media_resolution should be present" + for model in ["gemini-1.5-pro", "gemini-2.5-flash", "gemini-2.5-pro"]: + contents = _gemini_convert_messages_with_history(messages=messages, model=model) + file_part = next(p for p in contents[0]["parts"] if "file_data" in p) + assert "media_resolution" not in file_part, f"{model}: per-part media_resolution should not be set" + def test_chunk_parser_handles_prompt_feedback_block(): """Test chunk_parser correctly handles promptFeedback.blockReason""" From dff4bfd735946e1006d33adef0286e557b6f0b62 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 21 Apr 2026 14:54:18 +0530 Subject: [PATCH 03/48] fix(image_edit): forward litellm_params to validate_environment for Vertex AI credentials When aimage_edit or image_edit was called with Vertex AI Gemini/Imagen models via YAML-style config (vertex_project / vertex_credentials in proxy YAML), the credentials were dropped during handler-to-config plumbing, causing fallback to Application Default Credentials and DefaultCredentialsError. Root cause: image_edit_handler and async_image_edit_handler did not pass litellm_params to validate_environment, unlike image_generation_handler. Fixes: 1. Widen BaseImageEditConfig.validate_environment signature to accept litellm_params and api_base (optional kwargs). 2. Forward dict(litellm_params) and litellm_params.api_base from both sync and async image_edit handlers to validate_environment. 3. Update VertexAIImagenImageEditConfig.validate_environment to read vertex_ai_project/vertex_ai_credentials from litellm_params first, matching Gemini config pattern (secondary latent bug fix). 4. Widen all image-edit config override signatures to match base. Made-with: Cursor --- .../llms/azure/image_edit/transformation.py | 2 ++ .../image_edit/flux2_transformation.py | 2 ++ .../llms/azure_ai/image_edit/transformation.py | 2 ++ .../llms/base_llm/image_edit/transformation.py | 2 ++ ...on_nova_canvas_image_edit_transformation.py | 2 ++ .../image_edit/stability_transformation.py | 2 ++ .../image_edit/transformation.py | 2 ++ litellm/llms/custom_httpx/llm_http_handler.py | 4 ++++ .../llms/gemini/image_edit/transformation.py | 2 ++ .../litellm_proxy/image_edit/transformation.py | 7 ++++++- .../llms/openai/image_edit/transformation.py | 2 ++ .../openrouter/image_edit/transformation.py | 2 ++ .../llms/recraft/image_edit/transformation.py | 2 ++ .../stability/image_edit/transformations.py | 2 ++ .../image_edit/vertex_imagen_transformation.py | 18 ++++++++++++++++-- .../images/test_image_edit_utils.py | 9 +++++++-- 16 files changed, 57 insertions(+), 5 deletions(-) diff --git a/litellm/llms/azure/image_edit/transformation.py b/litellm/llms/azure/image_edit/transformation.py index f476d6a94e..dffa1c9eea 100644 --- a/litellm/llms/azure/image_edit/transformation.py +++ b/litellm/llms/azure/image_edit/transformation.py @@ -14,6 +14,8 @@ class AzureImageEditConfig(OpenAIImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: api_key = ( api_key diff --git a/litellm/llms/azure_ai/image_edit/flux2_transformation.py b/litellm/llms/azure_ai/image_edit/flux2_transformation.py index 0de163a771..1bc3bdcddc 100644 --- a/litellm/llms/azure_ai/image_edit/flux2_transformation.py +++ b/litellm/llms/azure_ai/image_edit/flux2_transformation.py @@ -65,6 +65,8 @@ class AzureFoundryFlux2ImageEditConfig(OpenAIImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: """ Validate Azure AI Foundry environment and set up authentication diff --git a/litellm/llms/azure_ai/image_edit/transformation.py b/litellm/llms/azure_ai/image_edit/transformation.py index 930b6d4db9..e778348c75 100644 --- a/litellm/llms/azure_ai/image_edit/transformation.py +++ b/litellm/llms/azure_ai/image_edit/transformation.py @@ -25,6 +25,8 @@ class AzureFoundryFluxImageEditConfig(OpenAIImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: """ Validate Azure AI Foundry environment and set up authentication diff --git a/litellm/llms/base_llm/image_edit/transformation.py b/litellm/llms/base_llm/image_edit/transformation.py index b088cdf37f..cea96bde74 100644 --- a/litellm/llms/base_llm/image_edit/transformation.py +++ b/litellm/llms/base_llm/image_edit/transformation.py @@ -67,6 +67,8 @@ class BaseImageEditConfig(ABC): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: return {} diff --git a/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py b/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py index f806cd2a81..836a3c606e 100644 --- a/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py +++ b/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py @@ -483,6 +483,8 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: if headers is None: headers = {} diff --git a/litellm/llms/bedrock/image_edit/stability_transformation.py b/litellm/llms/bedrock/image_edit/stability_transformation.py index 6a8b95e7e3..2d73e47003 100644 --- a/litellm/llms/bedrock/image_edit/stability_transformation.py +++ b/litellm/llms/bedrock/image_edit/stability_transformation.py @@ -372,6 +372,8 @@ class BedrockStabilityImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: """ Validate environment for Bedrock Stability image edit. diff --git a/litellm/llms/black_forest_labs/image_edit/transformation.py b/litellm/llms/black_forest_labs/image_edit/transformation.py index c6d8e8298e..4d19885aac 100644 --- a/litellm/llms/black_forest_labs/image_edit/transformation.py +++ b/litellm/llms/black_forest_labs/image_edit/transformation.py @@ -123,6 +123,8 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: """ Validate environment and set up headers for Black Forest Labs. diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index ea0c05e765..de215b9ae5 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -5216,6 +5216,8 @@ class BaseLLMHTTPHandler: api_key=litellm_params.api_key, headers=image_edit_optional_request_params.get("extra_headers", {}) or {}, model=model, + litellm_params=dict(litellm_params), + api_base=litellm_params.api_base, ) if extra_headers: @@ -5312,6 +5314,8 @@ class BaseLLMHTTPHandler: api_key=litellm_params.api_key, headers=image_edit_optional_request_params.get("extra_headers", {}) or {}, model=model, + litellm_params=dict(litellm_params), + api_base=litellm_params.api_base, ) if extra_headers: diff --git a/litellm/llms/gemini/image_edit/transformation.py b/litellm/llms/gemini/image_edit/transformation.py index d46733e04b..c8aaab0e14 100644 --- a/litellm/llms/gemini/image_edit/transformation.py +++ b/litellm/llms/gemini/image_edit/transformation.py @@ -54,6 +54,8 @@ class GeminiImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: final_api_key: Optional[str] = api_key or get_secret_str("GEMINI_API_KEY") if not final_api_key: diff --git a/litellm/llms/litellm_proxy/image_edit/transformation.py b/litellm/llms/litellm_proxy/image_edit/transformation.py index 5f5e2bdb24..79cd6e15c6 100644 --- a/litellm/llms/litellm_proxy/image_edit/transformation.py +++ b/litellm/llms/litellm_proxy/image_edit/transformation.py @@ -8,7 +8,12 @@ class LiteLLMProxyImageEditConfig(OpenAIImageEditConfig): """Configuration for image edit requests routed through LiteLLM Proxy.""" def validate_environment( - self, headers: dict, model: str, api_key: Optional[str] = None + self, + headers: dict, + model: str, + api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: api_key = api_key or get_secret_str("LITELLM_PROXY_API_KEY") headers.update({"Authorization": f"Bearer {api_key}"}) diff --git a/litellm/llms/openai/image_edit/transformation.py b/litellm/llms/openai/image_edit/transformation.py index 6917e8d799..9c0daca802 100644 --- a/litellm/llms/openai/image_edit/transformation.py +++ b/litellm/llms/openai/image_edit/transformation.py @@ -165,6 +165,8 @@ class OpenAIImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: api_key = ( api_key diff --git a/litellm/llms/openrouter/image_edit/transformation.py b/litellm/llms/openrouter/image_edit/transformation.py index fcf066dd5a..0d96b62425 100644 --- a/litellm/llms/openrouter/image_edit/transformation.py +++ b/litellm/llms/openrouter/image_edit/transformation.py @@ -116,6 +116,8 @@ class OpenRouterImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: api_key = api_key or litellm.api_key or get_secret_str("OPENROUTER_API_KEY") if not api_key: diff --git a/litellm/llms/recraft/image_edit/transformation.py b/litellm/llms/recraft/image_edit/transformation.py index 4c199bc78d..1dccd40605 100644 --- a/litellm/llms/recraft/image_edit/transformation.py +++ b/litellm/llms/recraft/image_edit/transformation.py @@ -81,6 +81,8 @@ class RecraftImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: final_api_key: Optional[str] = api_key or get_secret_str("RECRAFT_API_KEY") if not final_api_key: diff --git a/litellm/llms/stability/image_edit/transformations.py b/litellm/llms/stability/image_edit/transformations.py index eb400a2526..522858b8c2 100644 --- a/litellm/llms/stability/image_edit/transformations.py +++ b/litellm/llms/stability/image_edit/transformations.py @@ -149,6 +149,8 @@ class StabilityImageEditConfig(BaseImageEditConfig): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: """ Validate environment and set up headers for Stability AI. diff --git a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py index 7979e0e790..11126b2a3e 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py @@ -103,10 +103,24 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM): headers: dict, model: str, api_key: Optional[str] = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: headers = headers or {} - vertex_project = self._resolve_vertex_project() - vertex_credentials = self._resolve_vertex_credentials() + litellm_params = litellm_params or {} + + _api_base = litellm_params.get("api_base") or api_base + if _api_base is not None: + return headers + + vertex_project = ( + self.safe_get_vertex_ai_project(litellm_params) + or self._resolve_vertex_project() + ) + vertex_credentials = ( + self.safe_get_vertex_ai_credentials(litellm_params) + or self._resolve_vertex_credentials() + ) access_token, _ = self._ensure_access_token( credentials=vertex_credentials, project_id=vertex_project, diff --git a/tests/test_litellm/images/test_image_edit_utils.py b/tests/test_litellm/images/test_image_edit_utils.py index e0584afb81..186a085bdc 100644 --- a/tests/test_litellm/images/test_image_edit_utils.py +++ b/tests/test_litellm/images/test_image_edit_utils.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from unittest.mock import MagicMock, patch import pytest @@ -26,7 +26,12 @@ class MockImageEditConfig(BaseImageEditConfig): return "https://example.com/api" def validate_environment( - self, headers: dict, model: str, api_key: str = None + self, + headers: dict, + model: str, + api_key: str = None, + litellm_params: Optional[dict] = None, + api_base: Optional[str] = None, ) -> dict: return headers From a7512764af462bf2ed95135074df2819be8ca2de Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 21 Apr 2026 14:58:47 +0530 Subject: [PATCH 04/48] test(image_edit): add regression tests for credentials forwarding Adds three test cases to prevent regression of the Vertex AI image_edit credentials bug: 1. test_validate_environment_signature_includes_litellm_params: ensures all image-edit configs accept litellm_params (contract for the handler) 2. test_vertex_gemini_image_edit_reads_credentials_from_litellm_params: verifies Gemini config reads from litellm_params first 3. test_vertex_imagen_image_edit_reads_credentials_from_litellm_params: verifies Imagen config reads from litellm_params first These tests catch if the fix is accidentally reverted or if new image-edit configs are added without the litellm_params parameter. Made-with: Cursor --- .../images/test_image_edit_utils.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/test_litellm/images/test_image_edit_utils.py b/tests/test_litellm/images/test_image_edit_utils.py index 186a085bdc..1a13dd0671 100644 --- a/tests/test_litellm/images/test_image_edit_utils.py +++ b/tests/test_litellm/images/test_image_edit_utils.py @@ -267,3 +267,113 @@ class TestImageEditCustomPricing: def test_custom_pricing_not_detected_without_model_info(self): litellm_params = {"litellm_call_id": "test-call-id"} assert use_custom_pricing_for_model(litellm_params) is False + + +class TestImageEditHandlerCredentialsForwarding: + """ + Regression tests for Vertex AI image_edit credentials bug. + + image_edit handler must forward litellm_params to validate_environment, + so that credentials passed via YAML config (vertex_ai_project, + vertex_ai_credentials, etc.) reach the auth layer instead of falling + through to Application Default Credentials. + """ + + def test_vertex_gemini_image_edit_reads_credentials_from_litellm_params(self): + """ + VertexAIGeminiImageEditConfig.validate_environment should read + vertex_ai_project/vertex_ai_credentials from litellm_params first. + """ + from litellm.llms.vertex_ai.image_edit.vertex_gemini_transformation import ( + VertexAIGeminiImageEditConfig, + ) + + config = VertexAIGeminiImageEditConfig() + + litellm_params = { + "vertex_ai_project": "test-project-from-params", + "vertex_ai_credentials": "/path/to/creds.json", + } + + with patch.object( + config, "_ensure_access_token", return_value=("token", "project") + ) as mock_ensure: + config.validate_environment( + headers={}, + model="test-model", + litellm_params=litellm_params, + ) + + mock_ensure.assert_called_once() + call_kwargs = mock_ensure.call_args[1] + + assert call_kwargs["credentials"] == "/path/to/creds.json" + assert call_kwargs["project_id"] == "test-project-from-params" + + def test_vertex_imagen_image_edit_reads_credentials_from_litellm_params(self): + """ + VertexAIImagenImageEditConfig.validate_environment should read + vertex_ai_project/vertex_ai_credentials from litellm_params first. + """ + from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import ( + VertexAIImagenImageEditConfig, + ) + + config = VertexAIImagenImageEditConfig() + + litellm_params = { + "vertex_ai_project": "test-project-from-params", + "vertex_ai_credentials": "/path/to/creds.json", + } + + with patch.object( + config, "_ensure_access_token", return_value=("token", "project") + ) as mock_ensure: + config.validate_environment( + headers={}, + model="test-model", + litellm_params=litellm_params, + ) + + mock_ensure.assert_called_once() + call_kwargs = mock_ensure.call_args[1] + + assert call_kwargs["credentials"] == "/path/to/creds.json" + assert call_kwargs["project_id"] == "test-project-from-params" + + def test_validate_environment_signature_includes_litellm_params(self): + """ + All image_edit config validate_environment methods should accept + litellm_params to allow credentials to be forwarded from the handler. + """ + import inspect + + from litellm.llms.vertex_ai.image_edit.vertex_gemini_transformation import ( + VertexAIGeminiImageEditConfig, + ) + from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import ( + VertexAIImagenImageEditConfig, + ) + from litellm.llms.openai.image_edit.transformation import ( + OpenAIImageEditConfig, + ) + + configs = [ + VertexAIGeminiImageEditConfig(), + VertexAIImagenImageEditConfig(), + OpenAIImageEditConfig(), + MockImageEditConfig(), + ] + + for config in configs: + sig = inspect.signature(config.validate_environment) + params = list(sig.parameters.keys()) + + assert "litellm_params" in params, ( + f"{config.__class__.__name__}.validate_environment " + "missing litellm_params parameter" + ) + assert "api_base" in params, ( + f"{config.__class__.__name__}.validate_environment " + "missing api_base parameter" + ) From 447502b409ebd68c10f4c46f3dcafa0c8763683d Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 21 Apr 2026 15:03:40 +0530 Subject: [PATCH 05/48] fix(image_edit): read vertex_project/location from litellm_params in Imagen get_complete_url VertexAIImagenImageEditConfig.get_complete_url was resolving vertex_project and vertex_location only from env vars and global settings, ignoring litellm_params. Users supplying project/location exclusively via YAML config would get a ValueError or wrong URL even after auth headers were fixed. Mirrors the pattern already used by VertexAIGeminiImageEditConfig and image_generation counterpart (safe_get_vertex_ai_project/location). Also fixes api_key type hint in MockImageEditConfig (str -> Optional[str]) and adds a test covering get_complete_url credential resolution. Made-with: Cursor --- .../vertex_imagen_transformation.py | 10 +++++-- .../images/test_image_edit_utils.py | 30 ++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py index 11126b2a3e..9c0b07b827 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py @@ -137,8 +137,14 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM): """ Get the complete URL for Vertex AI Imagen predict API """ - vertex_project = self._resolve_vertex_project() - vertex_location = self._resolve_vertex_location() + vertex_project = ( + self.safe_get_vertex_ai_project(litellm_params) + or self._resolve_vertex_project() + ) + vertex_location = ( + self.safe_get_vertex_ai_location(litellm_params) + or self._resolve_vertex_location() + ) if not vertex_project or not vertex_location: raise ValueError( diff --git a/tests/test_litellm/images/test_image_edit_utils.py b/tests/test_litellm/images/test_image_edit_utils.py index 1a13dd0671..2146c1fab0 100644 --- a/tests/test_litellm/images/test_image_edit_utils.py +++ b/tests/test_litellm/images/test_image_edit_utils.py @@ -29,7 +29,7 @@ class MockImageEditConfig(BaseImageEditConfig): self, headers: dict, model: str, - api_key: str = None, + api_key: Optional[str] = None, litellm_params: Optional[dict] = None, api_base: Optional[str] = None, ) -> dict: @@ -341,6 +341,34 @@ class TestImageEditHandlerCredentialsForwarding: assert call_kwargs["credentials"] == "/path/to/creds.json" assert call_kwargs["project_id"] == "test-project-from-params" + def test_vertex_imagen_get_complete_url_reads_project_and_location_from_litellm_params( + self, + ): + """ + VertexAIImagenImageEditConfig.get_complete_url should read + vertex_ai_project and vertex_ai_location from litellm_params, + not only from env vars / global settings. + """ + from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import ( + VertexAIImagenImageEditConfig, + ) + + config = VertexAIImagenImageEditConfig() + + litellm_params = { + "vertex_ai_project": "param-project", + "vertex_ai_location": "us-east1", + } + + url = config.get_complete_url( + model="vertex_ai/imagegeneration@002", + api_base=None, + litellm_params=litellm_params, + ) + + assert "param-project" in url + assert "us-east1" in url + def test_validate_environment_signature_includes_litellm_params(self): """ All image_edit config validate_environment methods should accept From 0bd49ecb8b497efd8f7ddfb56a07a48ac7c5a017 Mon Sep 17 00:00:00 2001 From: Michael Riad Zaky Date: Fri, 17 Apr 2026 16:58:26 -0700 Subject: [PATCH 06/48] Fix bug that bypasses per-team member budget limit --- litellm/proxy/auth/auth_checks.py | 82 +++++++- .../management_endpoints/team_endpoints.py | 30 ++- litellm/proxy/proxy_server.py | 100 +++++++-- .../proxy/auth/test_auth_checks.py | 189 ++++++++++++++++++ .../test_team_endpoints.py | 52 +++++ tests/test_litellm/proxy/test_proxy_server.py | 120 +++++++++++ 6 files changed, 545 insertions(+), 28 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 2c8299e77a..1c89b0bfc0 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -905,6 +905,63 @@ async def get_default_end_user_budget( return None +@log_db_metrics +async def get_team_member_default_budget( + budget_id: str, + prisma_client: Optional[PrismaClient], + user_api_key_cache: DualCache, +) -> Optional[LiteLLM_BudgetTable]: + """ + Fetches the team-level default per-member budget referenced by team.metadata["team_member_budget_id"]. + + This budget is applied to team members whose TeamMembership row has no + linked budget. Results are cached for performance. + + Args: + budget_id: The budget_id pulled from team.metadata["team_member_budget_id"] + prisma_client: Database client instance + user_api_key_cache: Cache for storing/retrieving budget data + + Returns: + LiteLLM_BudgetTable if found, None otherwise + """ + if prisma_client is None: + return None + + cache_key = f"team_member_default_budget:{budget_id}" + + cached_budget = await user_api_key_cache.async_get_cache(key=cache_key) + if isinstance(cached_budget, LiteLLM_BudgetTable): + return cached_budget + if isinstance(cached_budget, dict): + return LiteLLM_BudgetTable(**cached_budget) + + try: + budget_record = await prisma_client.db.litellm_budgettable.find_unique( + where={"budget_id": budget_id} + ) + + if budget_record is None: + verbose_proxy_logger.warning( + f"Team-default member budget not found in database: {budget_id}" + ) + return None + + await user_api_key_cache.async_set_cache( + key=cache_key, + value=budget_record.dict(), + ttl=DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL, + ) + + return LiteLLM_BudgetTable(**budget_record.dict()) + + except Exception: + verbose_proxy_logger.exception( + f"Error fetching team-default member budget {budget_id}" + ) + return None + + async def _apply_default_budget_to_end_user( end_user_obj: LiteLLM_EndUserTable, prisma_client: PrismaClient, @@ -3230,13 +3287,26 @@ async def _check_team_member_budget( proxy_logging_obj=proxy_logging_obj, ) - if ( - team_membership is not None - and team_membership.litellm_budget_table is not None - and team_membership.litellm_budget_table.max_budget is not None - ): + # Per-member override wins; otherwise fall back to the team-level + # default configured via team.metadata["team_member_budget_id"]. + team_member_budget: Optional[float] = None + if team_membership is not None and team_membership.litellm_budget_table is not None: team_member_budget = team_membership.litellm_budget_table.max_budget - team_member_spend = team_membership.spend or 0.0 + else: + default_budget_id = (team_object.metadata or {}).get("team_member_budget_id") + if isinstance(default_budget_id, str): + default_budget = await get_team_member_default_budget( + budget_id=default_budget_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + ) + if default_budget is not None: + team_member_budget = default_budget.max_budget + + if team_member_budget is not None: + team_member_spend = ( + team_membership.spend if team_membership is not None else 0.0 + ) or 0.0 # Read from cross-pod counter (Redis-first) if available from litellm.proxy.proxy_server import get_current_spend diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index bf912fba4f..8357b1c0fe 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -302,14 +302,15 @@ class TeamMemberBudgetHandler: prisma_client: PrismaClient, ) -> None: """ - Create team_memberships entries for existing members that don't have one. + Ensure every team member has a TeamMembership row linked to the + team_member_budget. - Called after team_member_budget is set/updated on a team to ensure - members who joined before the budget was configured also get budget - enforcement. - - Only creates missing entries — does not touch existing memberships - (which may carry individual per-member budgets). + Called after team_member_budget is set/updated on a team. Creates + rows for members who don't have one, and populates budget_id on + existing rows where it is NULL. Rows with a non-NULL budget_id + are left untouched, which preserves per-member overrides but also + means rows pointing to a prior team-default budget_id are not + migrated to the new one. """ if not members_with_roles: return @@ -347,6 +348,21 @@ class TeamMemberBudgetHandler: _sanitize_for_log(team_member_budget_id), ) + # Heal existing membership rows that predate the team_member_budget + # configuration: populate budget_id where it is currently NULL. + # Rows with an explicit budget_id (per-member override) are left alone. + updated = await prisma_client.db.litellm_teammembership.update_many( + where={"team_id": team_id, "budget_id": None}, + data={"budget_id": team_member_budget_id}, + ) + if updated: + verbose_proxy_logger.info( + "Populated budget_id on %d existing team_memberships for team %s with budget %s", + updated, + _sanitize_for_log(team_id), + _sanitize_for_log(team_member_budget_id), + ) + def _get_default_team_param(field: str) -> Any: """ diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index aa8122d8fd..546d8df14c 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1908,40 +1908,110 @@ async def increment_spend_counters( ) +async def _reseed_spend_from_db(counter_key: str) -> float: + """ + Read the authoritative spend for a missing counter from the DB. The + counter_key prefix encodes the table to query: + + spend:key:{token} -> LiteLLM_VerificationToken.spend + spend:team:{team_id} -> LiteLLM_TeamTable.spend + spend:team_member:{uid}:{tid} -> LiteLLM_TeamMembership.spend + spend:user:{user_id} -> LiteLLM_UserTable.spend + spend:org:{org_id} -> LiteLLM_OrganizationTable.spend + + Returns 0.0 if prisma is unavailable, the row is missing, or the + key format is unrecognized. On failure, logs and returns 0.0 rather + than raising so the caller can still record the current increment. + """ + if prisma_client is None: + return 0.0 + # Per-window counters (spend:*:window:{duration}) share prefixes with + # primary counters but don't correspond to a DB row; their ambiguity + # would otherwise be silently parsed as a regular counter and miss. + if ":window:" in counter_key: + return 0.0 + try: + if counter_key.startswith("spend:key:"): + token = counter_key[len("spend:key:") :] + row = await prisma_client.db.litellm_verificationtoken.find_unique( + where={"token": token} + ) + elif counter_key.startswith("spend:team_member:"): + suffix = counter_key[len("spend:team_member:") :] + if ":" not in suffix: + return 0.0 + user_id, team_id = suffix.rsplit(":", 1) + row = await prisma_client.db.litellm_teammembership.find_unique( + where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}} + ) + elif counter_key.startswith("spend:team:"): + team_id = counter_key[len("spend:team:") :] + row = await prisma_client.db.litellm_teamtable.find_unique( + where={"team_id": team_id} + ) + elif counter_key.startswith("spend:user:"): + user_id = counter_key[len("spend:user:") :] + row = await prisma_client.db.litellm_usertable.find_unique( + where={"user_id": user_id} + ) + elif counter_key.startswith("spend:org:"): + org_id = counter_key[len("spend:org:") :] + row = await prisma_client.db.litellm_organizationtable.find_unique( + where={"organization_id": org_id} + ) + else: + return 0.0 + except Exception: + verbose_proxy_logger.exception( + "Failed to reseed spend counter %s from DB", counter_key + ) + return 0.0 + if row is None: + return 0.0 + return float(getattr(row, "spend", 0.0) or 0.0) + + async def _init_and_increment_spend_counter( counter_key: str, source_cache_key: str, increment: float, ): """ - Initialize counter from cached object's DB-loaded spend if not yet set, - then atomically increment in both in-memory and Redis. + Initialize counter from the authoritative DB spend value if not yet + set, then atomically increment in both in-memory and Redis. On first access per pod: - 1. Check spend_counter_cache (in-memory -> Redis via DualCache for init check) - 2. If not found anywhere, read base spend from user_api_key_cache (DB-loaded object) + 1. Check spend_counter_cache (in-memory -> Redis via DualCache) + 2. If not found, reseed from the DB (`_reseed_spend_from_db`). Falls + back to the cached object's `.spend` via user_api_key_cache only + if prisma is unavailable, since that value can lag the flusher. 3. Seed counter via async_increment_cache (not async_set_cache) to avoid a check-then-set race: if two pods cold-start simultaneously, both may see - the counter as absent and seed it. Using increment instead of set means - the worst case is over-counting (conservative — blocks slightly early) - rather than under-counting (would allow overspend). + the counter as absent and seed it. Using increment means the worst case + is over-counting (conservative, blocks slightly early) rather than + under-counting (would allow overspend). 4. Increment atomically (both in-memory + Redis) """ current = await spend_counter_cache.async_get_cache(key=counter_key) if current is None: - source = await user_api_key_cache.async_get_cache(key=source_cache_key) - base_spend = 0.0 - if source is not None: - if isinstance(source, dict): - base_spend = source.get("spend", 0.0) or 0.0 - else: - base_spend = getattr(source, "spend", 0.0) or 0.0 + base_spend = await _reseed_spend_from_db(counter_key) + if prisma_client is None: + # Best-effort fallback when prisma is unavailable (tests or + # early-startup paths). May be stale but avoids resetting to 0. + source = await user_api_key_cache.async_get_cache(key=source_cache_key) + if source is not None: + if isinstance(source, dict): + base_spend = source.get("spend", 0.0) or 0.0 + else: + base_spend = getattr(source, "spend", 0.0) or 0.0 if base_spend > 0: await spend_counter_cache.async_increment_cache( key=counter_key, value=base_spend ) - await spend_counter_cache.async_increment_cache(key=counter_key, value=increment) + await spend_counter_cache.async_increment_cache( + key=counter_key, value=increment + ) async def update_cache( # noqa: PLR0915 diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 19fffffc65..8612d243c4 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -2126,3 +2126,192 @@ class TestGuardrailModificationCheck: """Unparseable strings should not trigger a 403 — they have no keys.""" self._call({"metadata": "not-json"}) self._call({"metadata": '"just a string"'}) + + +@pytest.mark.asyncio +async def test_team_member_budget_check_falls_back_to_team_default_budget_id(): + """When a member's TeamMembership has no linked budget row, the check + should fall back to team.metadata["team_member_budget_id"] and still + enforce the cap. Pre-fix, this path silently skipped enforcement.""" + from litellm.caching.dual_cache import DualCache + from litellm.proxy._types import LiteLLM_TeamMembership + from litellm.proxy.utils import ProxyLogging + + team_object = LiteLLM_TeamTable( + team_id="test-team", + metadata={"team_member_budget_id": "budget-default"}, + ) + user_object = LiteLLM_UserTable(user_id="test-user") + valid_token = UserAPIKeyAuth( + token="test-token", + user_id="test-user", + team_id="test-team", + ) + + # Membership row without an attached budget. + team_membership = LiteLLM_TeamMembership( + user_id="test-user", + team_id="test-team", + spend=0.0, + budget_id=None, + litellm_budget_table=None, + ) + + proxy_logging_obj = ProxyLogging(user_api_key_cache=None) + + fake_budget_row = MagicMock() + fake_budget_row.max_budget = 50.0 + fake_budget_row.dict = MagicMock( + return_value={"budget_id": "budget-default", "max_budget": 50.0} + ) + + prisma_client = MagicMock() + prisma_client.db.litellm_budgettable.find_unique = AsyncMock( + return_value=fake_budget_row + ) + + async def mock_get_current_spend(counter_key, fallback_spend): + if counter_key == "spend:team_member:test-user:test-team": + return 70.0 + return fallback_spend + + user_api_key_cache = DualCache() + + with ( + patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend), + patch( + "litellm.proxy.auth.auth_checks.get_team_membership", + new_callable=AsyncMock, + return_value=team_membership, + ), + ): + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await _check_team_member_budget( + team_object=team_object, + user_object=user_object, + valid_token=valid_token, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + assert exc_info.value.current_cost == 70.0 + assert exc_info.value.max_budget == 50.0 + + # First call did perform the fallback DB lookup. + prisma_client.db.litellm_budgettable.find_unique.assert_awaited_once() + + # Second call hits the cached budget row, no additional prisma read. + prisma_client.db.litellm_budgettable.find_unique.reset_mock() + with ( + patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend), + patch( + "litellm.proxy.auth.auth_checks.get_team_membership", + new_callable=AsyncMock, + return_value=team_membership, + ), + ): + with pytest.raises(litellm.BudgetExceededError) as second_exc_info: + await _check_team_member_budget( + team_object=team_object, + user_object=user_object, + valid_token=valid_token, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + # The cached $50 cap is still being applied (not a coincidental skip) + assert second_exc_info.value.current_cost == 70.0 + assert second_exc_info.value.max_budget == 50.0 + prisma_client.db.litellm_budgettable.find_unique.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_team_member_budget_check_per_member_override_wins_over_team_default(): + """If a member has a per-member budget AND the team carries a + team_member_budget_id default, the per-member value wins and the + fallback prisma lookup is never performed.""" + from litellm.caching.dual_cache import DualCache + from litellm.proxy._types import LiteLLM_BudgetTable, LiteLLM_TeamMembership + from litellm.proxy.utils import ProxyLogging + + team_object = LiteLLM_TeamTable( + team_id="test-team", + metadata={"team_member_budget_id": "budget-default"}, + ) + user_object = LiteLLM_UserTable(user_id="test-user") + valid_token = UserAPIKeyAuth( + token="test-token", + user_id="test-user", + team_id="test-team", + ) + + team_membership = LiteLLM_TeamMembership( + user_id="test-user", + team_id="test-team", + spend=0.0, + budget_id="budget-override", + litellm_budget_table=LiteLLM_BudgetTable(max_budget=200.0), + ) + + proxy_logging_obj = ProxyLogging(user_api_key_cache=None) + + # Team-default row resolves to $50. If the fallback fired (it must + # not here), spend $70 would exceed that $50 cap and raise. + fake_budget_row = MagicMock() + fake_budget_row.max_budget = 50.0 + + prisma_client = MagicMock() + prisma_client.db.litellm_budgettable.find_unique = AsyncMock( + return_value=fake_budget_row + ) + + mocked_spend = 70.0 + + async def mock_get_current_spend(counter_key, fallback_spend): + if counter_key == "spend:team_member:test-user:test-team": + return mocked_spend + return fallback_spend + + # 1. spend ($70) < per-member cap ($200) → no raise, no fallback lookup. + with ( + patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend), + patch( + "litellm.proxy.auth.auth_checks.get_team_membership", + new_callable=AsyncMock, + return_value=team_membership, + ), + ): + await _check_team_member_budget( + team_object=team_object, + user_object=user_object, + valid_token=valid_token, + prisma_client=prisma_client, + user_api_key_cache=DualCache(), + proxy_logging_obj=proxy_logging_obj, + ) + + prisma_client.db.litellm_budgettable.find_unique.assert_not_awaited() + + # 2. Now push spend above the per-member cap ($200). Must raise with + # max_budget=200 to prove the per-member cap is the value being + # enforced (not just that enforcement silently skipped). + mocked_spend = 250.0 + with ( + patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend), + patch( + "litellm.proxy.auth.auth_checks.get_team_membership", + new_callable=AsyncMock, + return_value=team_membership, + ), + ): + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await _check_team_member_budget( + team_object=team_object, + user_object=user_object, + valid_token=valid_token, + prisma_client=prisma_client, + user_api_key_cache=DualCache(), + proxy_logging_obj=proxy_logging_obj, + ) + assert exc_info.value.current_cost == 250.0 + assert exc_info.value.max_budget == 200.0 diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 8da0ef19f8..65187fb52d 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -1795,6 +1795,7 @@ async def test_backfill_team_member_budget_entries_creates_missing_memberships() return_value=[existing_membership] ) mock_prisma.db.litellm_teammembership.create_many = AsyncMock(return_value=None) + mock_prisma.db.litellm_teammembership.update_many = AsyncMock(return_value=0) # Test with Member instances members = [ @@ -1823,6 +1824,7 @@ async def test_backfill_team_member_budget_entries_creates_missing_memberships() # Also test with raw dicts (members_with_roles may be dicts when deserialized from DB) mock_prisma.db.litellm_teammembership.find_many.reset_mock() mock_prisma.db.litellm_teammembership.create_many.reset_mock() + mock_prisma.db.litellm_teammembership.update_many.reset_mock() members_as_dicts = [ {"user_id": "user-A", "role": "user"}, @@ -1868,6 +1870,7 @@ async def test_backfill_team_member_budget_entries_no_op_when_all_exist(): return_value=[existing_a, existing_b] ) mock_prisma.db.litellm_teammembership.create_many = AsyncMock(return_value=None) + mock_prisma.db.litellm_teammembership.update_many = AsyncMock(return_value=0) members = [ Member(user_id="user-A", role="user"), @@ -1884,6 +1887,55 @@ async def test_backfill_team_member_budget_entries_no_op_when_all_exist(): mock_prisma.db.litellm_teammembership.create_many.assert_not_awaited() +@pytest.mark.asyncio +async def test_backfill_team_member_budget_entries_populates_null_budget_id_on_existing_rows(): + """ + backfill_team_member_budget_entries should populate budget_id on + existing TeamMembership rows where it is currently NULL, so admins + can configure a team member budget after members have already joined + and have enforcement apply to those pre-existing members. + """ + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy._types import Member + from litellm.proxy.management_endpoints.team_endpoints import ( + TeamMemberBudgetHandler, + ) + + team_id = "team-abc" + budget_id = "budget-xyz" + + # Both members already have rows, so create_many must not fire; + # update_many must fire with the NULL-budget_id filter. + existing_a = MagicMock() + existing_a.user_id = "user-A" + existing_b = MagicMock() + existing_b.user_id = "user-B" + + mock_prisma = MagicMock() + mock_prisma.db.litellm_teammembership.find_many = AsyncMock( + return_value=[existing_a, existing_b] + ) + mock_prisma.db.litellm_teammembership.create_many = AsyncMock(return_value=None) + mock_prisma.db.litellm_teammembership.update_many = AsyncMock(return_value=2) + + await TeamMemberBudgetHandler.backfill_team_member_budget_entries( + team_id=team_id, + members_with_roles=[ + Member(user_id="user-A", role="user"), + Member(user_id="user-B", role="user"), + ], + team_member_budget_id=budget_id, + prisma_client=mock_prisma, + ) + + mock_prisma.db.litellm_teammembership.create_many.assert_not_awaited() + mock_prisma.db.litellm_teammembership.update_many.assert_awaited_once_with( + where={"team_id": team_id, "budget_id": None}, + data={"budget_id": budget_id}, + ) + + @pytest.mark.asyncio async def test_backfill_team_member_budget_entries_empty_members(): """ diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 79eba81dc4..efd1abbb38 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -4965,3 +4965,123 @@ async def test_increment_spend_counters_team_and_member(): finally: ps.user_api_key_cache = original_key_cache ps.spend_counter_cache = original_counter_cache + + +@pytest.mark.asyncio +async def test_init_and_increment_spend_counter_reseeds_from_db_on_counter_miss(): + """When the Redis counter is missing, the reseed path reads the + authoritative spend from the DB (not a stale cache), so the next + increment continues from the correct base value.""" + from litellm.caching.dual_cache import DualCache + + counter_cache = DualCache() + recorded_increments: list = [] + + async def record_increment(key, value, ttl=None, **kwargs): + recorded_increments.append({"key": key, "value": value, "ttl": ttl}) + return value + + fake_redis = AsyncMock() + fake_redis.async_increment = AsyncMock(side_effect=record_increment) + fake_redis.async_get_cache = AsyncMock(return_value=None) # counter missing + counter_cache.redis_cache = fake_redis + + # Prisma returns spend=42.0 (authoritative) while the stale cached + # value (would be read only if prisma is None) is 10.0. The counter + # must seed from 42, not 10. + db_row = MagicMock() + db_row.spend = 42.0 + fake_prisma = MagicMock() + fake_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=db_row) + + stale_cache = DualCache() + stale_team = MagicMock() + stale_team.spend = 10.0 + stale_cache.in_memory_cache.set_cache(key="team_id:team-9", value=stale_team) + + import litellm.proxy.proxy_server as ps + from litellm.proxy.proxy_server import _init_and_increment_spend_counter + + orig_user, orig_counter, orig_prisma = ( + ps.user_api_key_cache, + ps.spend_counter_cache, + ps.prisma_client, + ) + ps.user_api_key_cache = stale_cache + ps.spend_counter_cache = counter_cache + ps.prisma_client = fake_prisma + try: + await _init_and_increment_spend_counter( + counter_key="spend:team:team-9", + source_cache_key="team_id:team-9", + increment=1.5, + ) + + fake_prisma.db.litellm_teamtable.find_unique.assert_awaited_once_with( + where={"team_id": "team-9"} + ) + # Two increments keyed on the counter: seed ($42) then request ($1.50). + writes = [(c["key"], c["value"]) for c in recorded_increments] + assert ("spend:team:team-9", 42.0) in writes + assert ("spend:team:team-9", 1.5) in writes + finally: + ps.user_api_key_cache = orig_user + ps.spend_counter_cache = orig_counter + ps.prisma_client = orig_prisma + + +@pytest.mark.asyncio +async def test_reseed_spend_from_db_user_and_org_prefixes(): + """User and org counters must reseed from their own DB tables, not + fall through to 0.0 like the other counters do today.""" + import litellm.proxy.proxy_server as ps + from litellm.proxy.proxy_server import _reseed_spend_from_db + + user_row = MagicMock() + user_row.spend = 17.0 + org_row = MagicMock() + org_row.spend = 305.0 + + fake_prisma = MagicMock() + fake_prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=user_row) + fake_prisma.db.litellm_organizationtable.find_unique = AsyncMock( + return_value=org_row + ) + + orig_prisma = ps.prisma_client + ps.prisma_client = fake_prisma + try: + assert await _reseed_spend_from_db("spend:user:alice") == 17.0 + fake_prisma.db.litellm_usertable.find_unique.assert_awaited_once_with( + where={"user_id": "alice"} + ) + + assert await _reseed_spend_from_db("spend:org:acme") == 305.0 + fake_prisma.db.litellm_organizationtable.find_unique.assert_awaited_once_with( + where={"organization_id": "acme"} + ) + finally: + ps.prisma_client = orig_prisma + + +@pytest.mark.asyncio +async def test_reseed_spend_from_db_skips_window_variant_keys(): + """Window counters (spend:*:window:{duration}) share prefixes with + primary counters but don't correspond to a DB row. The guard must + short-circuit without querying the DB.""" + import litellm.proxy.proxy_server as ps + from litellm.proxy.proxy_server import _reseed_spend_from_db + + fake_prisma = MagicMock() + fake_prisma.db.litellm_verificationtoken.find_unique = AsyncMock() + fake_prisma.db.litellm_teamtable.find_unique = AsyncMock() + + orig_prisma = ps.prisma_client + ps.prisma_client = fake_prisma + try: + assert await _reseed_spend_from_db("spend:key:sk-abc:window:1h") == 0.0 + assert await _reseed_spend_from_db("spend:team:team-1:window:1d") == 0.0 + fake_prisma.db.litellm_verificationtoken.find_unique.assert_not_awaited() + fake_prisma.db.litellm_teamtable.find_unique.assert_not_awaited() + finally: + ps.prisma_client = orig_prisma From 051d49f2fbd239ea78113613b99ac95db2a342a7 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 12:38:25 -0700 Subject: [PATCH 07/48] fix: extend request body parameter restrictions to cloud provider auth fields --- litellm/proxy/auth/auth_utils.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 18aea48e96..448c975d12 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -151,7 +151,15 @@ def is_request_body_safe( A malicious user can set the api_base to their own domain and invoke POST /chat/completions to intercept and steal the OpenAI API key. Relevant issue: https://huntr.com/bounties/4001e1a2-7b7a-4776-a3ae-e6692ec3d997 """ - banned_params = ["api_base", "base_url", "user_config"] + banned_params = [ + "api_base", + "base_url", + "user_config", + "aws_sts_endpoint", + "aws_web_identity_token", + "aws_role_name", + "vertex_credentials", + ] for param in banned_params: if ( From 699b820c22d03fb2180165fa276d0eb5b74e5194 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 13:45:18 -0700 Subject: [PATCH 08/48] fix: align image URL fetch with validated client in bedrock and token counter paths --- litellm/litellm_core_utils/prompt_templates/factory.py | 7 ++++--- litellm/litellm_core_utils/token_counter.py | 7 +++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index bf950357ba..5a19c224aa 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -15,6 +15,7 @@ import litellm.types import litellm.types.llms from litellm import verbose_logger from litellm._uuid import uuid +from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_async_httpx_client from litellm.types.files import get_file_extension_from_mime_type from litellm.types.llms.anthropic import * @@ -3324,7 +3325,7 @@ def _load_image_from_url(image_url): try: # Send a GET request to the image URL client = HTTPHandler(concurrent_limit=1) - response = client.get(image_url) + response = safe_get(client, image_url) response.raise_for_status() # Raise an exception for HTTP errors # Check the response's content type to ensure it is an image @@ -3562,7 +3563,7 @@ class BedrockImageProcessor: params={"concurrent_limit": 1}, ) # Send a GET request to the image URL - response = await client.get(image_url, follow_redirects=True) + response = await async_safe_get(client, image_url) response.raise_for_status() # Raise an exception for HTTP errors return BedrockImageProcessor._post_call_image_processing( @@ -3577,7 +3578,7 @@ class BedrockImageProcessor: try: client = HTTPHandler(concurrent_limit=1) # Send a GET request to the image URL - response = client.get(image_url, follow_redirects=True) + response = safe_get(client, image_url) response.raise_for_status() # Raise an exception for HTTP errors return BedrockImageProcessor._post_call_image_processing( diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index 01e5dc39a3..d893b98078 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -23,6 +23,7 @@ from litellm.constants import ( DEFAULT_IMAGE_HEIGHT, DEFAULT_IMAGE_TOKEN_COUNT, DEFAULT_IMAGE_WIDTH, + MAX_IMAGE_URL_DOWNLOAD_SIZE_MB, MAX_LONG_SIDE_FOR_IMAGE_HIGH_RES, MAX_SHORT_SIDE_FOR_IMAGE_HIGH_RES, MAX_TILE_HEIGHT, @@ -215,7 +216,13 @@ def get_image_dimensions( try: client = _get_httpx_client() response = safe_get(client, data) + max_bytes = int(MAX_IMAGE_URL_DOWNLOAD_SIZE_MB * 1024 * 1024) + content_length = response.headers.get("Content-Length") + if content_length is not None and int(content_length) > max_bytes: + raise ValueError("Image response exceeds size limit") img_data = response.read() + if len(img_data) > max_bytes: + raise ValueError("Image response exceeds size limit") except Exception: pass if img_data is None: From 28e1d2f1a638b9f5dfc92b22620834e101f2d70f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 16:14:24 -0700 Subject: [PATCH 09/48] [Infra] CCI: unify uv cache key and cache only ~/.cache/uv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate 6 distinct cache-key prefixes (v2-dependencies-, v1-router-testing-deps-, v1-router-unit-deps-, v1-llm-translation-deps-, v1-llm-responses-deps-, v3-litellm-uv-deps-, ui-e2e-py-deps-v2-) onto a single v1-uv-cache- key shared across all Python jobs. Cache only ~/.cache/uv (the content-addressed uv download cache, hash-verified against uv.lock at install time). Drop ./.venv, ~/.local/{bin,lib}, and /home/circleci/.{pyenv,local} from cache paths. ~/.cache/uv is the only path uv sync needs to avoid re-downloading from PyPI; everything else is rebuilt each run from that verified cache. Remove partial-prefix restore-keys fallbacks — cache either hits exactly on the uv.lock hash or rebuilds cleanly. First run after merge will cold-miss on the new key; subsequent runs hit the unified cache. --- .circleci/config.yml | 90 ++++++++++++++++++-------------------------- 1 file changed, 37 insertions(+), 53 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0a59b7ef0d..3a2d6348ba 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -112,11 +112,10 @@ commands: steps: - checkout - setup_google_dns + - install_uv - restore_cache: keys: - - v3-litellm-uv-deps-{{ checksum "uv.lock" }} - - v3-litellm-uv-deps- - - install_uv + - v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Install Dependencies command: | @@ -124,10 +123,8 @@ commands: - setup_litellm_enterprise_pip - save_cache: paths: - - ~/.local/lib - - ~/.local/bin - ~/.cache/uv - key: v3-litellm-uv-deps-{{ checksum "uv.lock" }} + key: v1-uv-cache-{{ checksum "uv.lock" }} jobs: # Add Windows testing job @@ -182,8 +179,7 @@ jobs: - restore_cache: keys: - - v2-dependencies-{{ checksum "uv.lock" }} - - v2-dependencies- + - v1-uv-cache-{{ checksum "uv.lock" }} - install_uv - run: name: Install Dependencies @@ -192,8 +188,8 @@ jobs: - setup_litellm_enterprise_pip - save_cache: paths: - - ./.venv - key: v2-dependencies-{{ checksum "uv.lock" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Run prisma ./docker/entrypoint.sh command: | @@ -263,8 +259,7 @@ jobs: - restore_cache: keys: - - v2-dependencies-{{ checksum "uv.lock" }} - - v2-dependencies- + - v1-uv-cache-{{ checksum "uv.lock" }} - install_uv - run: name: Install Dependencies @@ -273,8 +268,8 @@ jobs: - setup_litellm_enterprise_pip - save_cache: paths: - - ./.venv - key: v2-dependencies-{{ checksum "uv.lock" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Run prisma ./docker/entrypoint.sh command: | @@ -345,8 +340,7 @@ jobs: - restore_cache: keys: - - v2-dependencies-{{ checksum "uv.lock" }} - - v2-dependencies- + - v1-uv-cache-{{ checksum "uv.lock" }} - install_uv - run: name: Install Dependencies @@ -355,8 +349,8 @@ jobs: - setup_litellm_enterprise_pip - save_cache: paths: - - ./.venv - key: v2-dependencies-{{ checksum "uv.lock" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Run prisma ./docker/entrypoint.sh command: | @@ -401,8 +395,8 @@ jobs: uv sync --frozen --all-groups --all-extras --python 3.12 - save_cache: paths: - - ./.venv - key: v2-dependencies-{{ checksum "uv.lock" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} - wait_for_service: url: tcp://localhost:5432 timeout: "60" @@ -440,20 +434,18 @@ jobs: steps: - checkout - setup_google_dns + - install_uv - restore_cache: keys: - - v1-router-testing-deps-{{ checksum "uv.lock" }} - - v1-router-testing-deps- - - install_uv + - v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Install Dependencies command: | uv sync --frozen --all-groups --all-extras --python 3.12 - save_cache: paths: - - /home/circleci/.pyenv - - /home/circleci/.local - key: v1-router-testing-deps-{{ checksum "uv.lock" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} # Run pytest and generate JUnit XML report - setup_litellm_enterprise_pip - run: @@ -490,20 +482,18 @@ jobs: steps: - checkout - setup_google_dns + - install_uv - restore_cache: keys: - - v1-router-unit-deps-{{ checksum "uv.lock" }} - - v1-router-unit-deps- - - install_uv + - v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Install Dependencies command: | uv sync --frozen --all-groups --all-extras --python 3.12 - save_cache: paths: - - /home/circleci/.pyenv - - /home/circleci/.local - key: v1-router-unit-deps-{{ checksum "uv.lock" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} # Run pytest and generate JUnit XML report - setup_litellm_enterprise_pip - run: @@ -557,20 +547,18 @@ jobs: steps: - checkout - setup_google_dns + - install_uv - restore_cache: keys: - - v1-llm-translation-deps-{{ checksum "uv.lock" }} - - v1-llm-translation-deps- - - install_uv + - v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Install Dependencies command: | uv sync --frozen --all-groups --all-extras --python 3.12 - save_cache: paths: - - /home/circleci/.pyenv - - /home/circleci/.local - key: v1-llm-translation-deps-{{ checksum "uv.lock" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} # Run pytest and generate JUnit XML report - run: name: Run tests @@ -799,20 +787,18 @@ jobs: steps: - checkout - setup_google_dns + - install_uv - restore_cache: keys: - - v1-llm-responses-deps-{{ checksum "uv.lock" }} - - v1-llm-responses-deps- - - install_uv + - v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Install Dependencies command: | uv sync --frozen --all-groups --all-extras --python 3.12 - save_cache: paths: - - /home/circleci/.pyenv - - /home/circleci/.local - key: v1-llm-responses-deps-{{ checksum "uv.lock" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} # Run pytest and generate JUnit XML report - run: name: Run tests @@ -1202,8 +1188,7 @@ jobs: - setup_google_dns - restore_cache: keys: - - v2-dependencies-{{ checksum "uv.lock" }} - - v2-dependencies- + - v1-uv-cache-{{ checksum "uv.lock" }} - install_uv - run: name: Install Dependencies @@ -1211,8 +1196,8 @@ jobs: uv sync --frozen --all-groups --all-extras --python 3.12 - save_cache: paths: - - ./.venv - key: v2-dependencies-{{ checksum "uv.lock" }} + - ~/.cache/uv + key: v1-uv-cache-{{ checksum "uv.lock" }} # Run pytest and generate JUnit XML report - run: name: Run tests @@ -2364,20 +2349,19 @@ jobs: steps: - checkout - setup_google_dns + - install_uv - restore_cache: keys: - - ui-e2e-py-deps-v2-{{ checksum "uv.lock" }} - - ui-e2e-py-deps-v2- - - install_uv + - v1-uv-cache-{{ checksum "uv.lock" }} - run: name: Install Python dependencies command: | uv sync --frozen --all-groups --all-extras --python 3.12 uv run --no-sync python -m prisma generate --schema litellm/proxy/schema.prisma - save_cache: - key: ui-e2e-py-deps-v2-{{ checksum "uv.lock" }} + key: v1-uv-cache-{{ checksum "uv.lock" }} paths: - - ./.venv + - ~/.cache/uv - restore_cache: keys: - ui-e2e-node-deps-v1-{{ checksum "ui/litellm-dashboard/package-lock.json" }} From df93941cd7124bf20ed5a4e2493abb0f9f799c39 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 16:28:51 -0700 Subject: [PATCH 10/48] fix: enforce format constraints on provider-specific URL parameters Brings the Snowflake, S3 Vectors, Vertex AI, and Bedrock URL construction paths in line with the existing pattern of validating interpolated values before use. --- litellm/llms/bedrock/batches/transformation.py | 4 +++- litellm/llms/s3_vectors/vector_stores/transformation.py | 3 +++ litellm/llms/snowflake/utils.py | 3 +++ litellm/llms/vertex_ai/common_utils.py | 7 +++++-- .../pass_through_endpoints/llm_passthrough_endpoints.py | 5 +++++ 5 files changed, 19 insertions(+), 3 deletions(-) diff --git a/litellm/llms/bedrock/batches/transformation.py b/litellm/llms/bedrock/batches/transformation.py index 5d008038ca..0602b1c2f6 100644 --- a/litellm/llms/bedrock/batches/transformation.py +++ b/litellm/llms/bedrock/batches/transformation.py @@ -1,4 +1,5 @@ import os +import re import time from typing import Any, Dict, List, Literal, Optional, Union, cast @@ -294,7 +295,8 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig): raise ValueError(f"Invalid ARN format: {batch_id}") region = arn_parts[3] - # arn_parts[5] contains "model-invocation-job/{jobId}" + if not re.match(r"^[a-z][a-z0-9-]*$", region): + raise ValueError(f"Invalid region in ARN: {batch_id}") # Build the endpoint URL for GetModelInvocationJob # AWS API format: GET /model-invocation-job/{jobIdentifier} diff --git a/litellm/llms/s3_vectors/vector_stores/transformation.py b/litellm/llms/s3_vectors/vector_stores/transformation.py index 11836e361e..19b5976986 100644 --- a/litellm/llms/s3_vectors/vector_stores/transformation.py +++ b/litellm/llms/s3_vectors/vector_stores/transformation.py @@ -1,3 +1,4 @@ +import re from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union import httpx @@ -66,6 +67,8 @@ class S3VectorsVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM): aws_region_name = litellm_params.get("aws_region_name") if not aws_region_name: raise ValueError("aws_region_name is required for S3 Vectors") + if not re.match(r"^[a-z][a-z0-9-]*$", aws_region_name): + raise ValueError("Invalid aws_region_name format") return f"https://s3vectors.{aws_region_name}.api.aws" def transform_search_vector_store_request( diff --git a/litellm/llms/snowflake/utils.py b/litellm/llms/snowflake/utils.py index 9d458f6ece..d84efdd9fc 100644 --- a/litellm/llms/snowflake/utils.py +++ b/litellm/llms/snowflake/utils.py @@ -1,3 +1,4 @@ +import re from typing import TYPE_CHECKING, Any, List, Optional, Tuple from litellm.secret_managers.main import get_secret_str @@ -61,6 +62,8 @@ class SnowflakeBaseConfig: account_id = get_secret_str("SNOWFLAKE_ACCOUNT_ID") if account_id is None: raise ValueError("Missing snowflake account_id") + if not re.match(r"^[a-zA-Z0-9_-]+$", account_id): + raise ValueError("Invalid account_id format") api_base = f"https://{account_id}.snowflakecomputing.com/api/v2" api_base = api_base.rstrip("/") diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 43e77f4fb7..c13f6a86f8 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -232,8 +232,11 @@ def get_vertex_base_url( """ if vertex_location == "global": return "https://aiplatform.googleapis.com" - else: - return f"https://{vertex_location}-aiplatform.googleapis.com" + if vertex_location is not None and not re.match( + r"^[a-z][a-z0-9-]*$", vertex_location + ): + raise ValueError("Invalid vertex_location format") + return f"https://{vertex_location}-aiplatform.googleapis.com" def _get_embedding_url( diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 1ef866486e..3cf155739c 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -8,6 +8,7 @@ Use litellm with Anthropic SDK, Vertex AI SDK, Cohere SDK, etc. import json import os +import re from typing import Any, Optional, Tuple, Union, cast import httpx @@ -1500,6 +1501,10 @@ def get_vertex_base_url(vertex_location: Optional[str]) -> str: """ if vertex_location == "global": return "https://aiplatform.googleapis.com/" + if vertex_location is not None and not re.match( + r"^[a-z][a-z0-9-]*$", vertex_location + ): + raise ValueError("Invalid vertex_location format") return f"https://{vertex_location}-aiplatform.googleapis.com/" From a292845dcf7d4929b5b842171b659ed31a86b4c8 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 17:40:42 -0700 Subject: [PATCH 11/48] [Fix] Harden spend accuracy test against transient aiohttp connection errors Two changes, both test-only: - Configure the aiohttp session with TCPConnector(force_close=True) and an explicit ClientTimeout(total=30, connect=10). Prevents reuse of idle TCP connections that the proxy/kernel may have closed during the long window between setup POSTs and the later poll loop, and surfaces a blocked proxy event loop quickly instead of hanging on aiohttp's 5-minute default. - In poll_key_spend_until, catch aiohttp.ClientError and asyncio.TimeoutError around the single /key/info call. A transient transport hiccup now logs and retries on the next tick instead of failing the entire polling loop. Addresses the ConnectionTimeoutError observed on the first /key/info call after the 20 chat completions. --- .../test_spend_accuracy_tests.py | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/tests/spend_tracking_tests/test_spend_accuracy_tests.py b/tests/spend_tracking_tests/test_spend_accuracy_tests.py index 8c523e9191..15e00d9335 100644 --- a/tests/spend_tracking_tests/test_spend_accuracy_tests.py +++ b/tests/spend_tracking_tests/test_spend_accuracy_tests.py @@ -48,6 +48,22 @@ POLL_TIMEOUT_SECONDS = 60 TOLERANCE = 1e-10 +def _make_test_session() -> aiohttp.ClientSession: + """ + Session tuned for CI reliability: + - force_close: avoid aiohttp reusing a TCP connection that the proxy/kernel + silently closed during the long idle window between setup POSTs and the + later poll loop (observed failure mode: ConnectionTimeoutError on the + first /key/info call after 20 chat completions). + - explicit connect timeout: surface a blocked proxy event loop quickly + instead of hanging on aiohttp's 5-minute default total timeout. + """ + return aiohttp.ClientSession( + connector=aiohttp.TCPConnector(force_close=True), + timeout=aiohttp.ClientTimeout(total=30, connect=10), + ) + + async def create_organization(session, organization_alias: str): """Helper function to create a new organization""" url = "http://0.0.0.0:4000/organization/new" @@ -156,7 +172,16 @@ async def poll_key_spend_until(session, key: str, expected: float) -> float: start = time.time() last_spend = 0.0 while time.time() - start < POLL_TIMEOUT_SECONDS: - key_info = await get_spend_info(session, "key", key) + try: + key_info = await get_spend_info(session, "key", key) + except (aiohttp.ClientError, asyncio.TimeoutError) as exc: + print( + f"Transient transport error during spend poll: " + f"{type(exc).__name__}: {exc}. Retrying... " + f"({time.time() - start:.1f}s elapsed)" + ) + await asyncio.sleep(POLL_INTERVAL_SECONDS) + continue last_spend = key_info["info"]["spend"] if abs(last_spend - expected) < TOLERANCE: print( @@ -193,7 +218,7 @@ async def test_basic_spend_accuracy(): """ NUM_LLM_REQUESTS = 20 - async with aiohttp.ClientSession() as session: + async with _make_test_session() as session: await assert_proxy_healthy(session) org_response = await create_organization( @@ -278,7 +303,7 @@ async def test_long_term_spend_accuracy_with_bursts(): BURST_1_REQUESTS = 22 BURST_2_REQUESTS = 12 - async with aiohttp.ClientSession() as session: + async with _make_test_session() as session: await assert_proxy_healthy(session) org_response = await create_organization( From 375bf4d7d67a6f9a1ead0512e90d68c05b12219f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 18:04:39 -0700 Subject: [PATCH 12/48] fix: tighten file input handling in image edit endpoints Bring string input handling for image/mask parameters in line with the multipart-only contract expected by the image edit endpoint. --- .../image_edit/transformation.py | 12 +++++++----- .../image_edit/vertex_imagen_transformation.py | 17 ++++++++++------- litellm/proxy/image_endpoints/endpoints.py | 7 +++++++ 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/litellm/llms/black_forest_labs/image_edit/transformation.py b/litellm/llms/black_forest_labs/image_edit/transformation.py index c6d8e8298e..d05a802d23 100644 --- a/litellm/llms/black_forest_labs/image_edit/transformation.py +++ b/litellm/llms/black_forest_labs/image_edit/transformation.py @@ -14,7 +14,9 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union import httpx from httpx._types import RequestFiles +import litellm from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH +from litellm.litellm_core_utils.url_utils import safe_get from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from litellm.secret_managers.main import get_secret_str from litellm.types.images.main import ImageEditOptionalRequestParams @@ -206,14 +208,14 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig): ) elif isinstance(image, str): if image.startswith(("http://", "https://")): - # Download image from URL - response = httpx.get(image, timeout=60.0) + response = safe_get(litellm.module_level_client, image, timeout=60.0) response.raise_for_status() return response.content else: - # Assume it's a file path - with open(image, "rb") as f: - return f.read() + raise ValueError( + f"Unsupported image input: plain string values that are not URLs are not accepted. " + "Provide image bytes or a file-like object." + ) elif hasattr(image, "read"): # File-like object pos = getattr(image, "tell", lambda: 0)() diff --git a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py index 7979e0e790..e35b340f0c 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py @@ -348,13 +348,16 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM): if stream_pos is not None: image.seek(stream_pos) return data - if isinstance(image, (str, Path)): - path_obj = Path(image) - if not path_obj.exists(): - raise ValueError( - f"Mask/image path does not exist for Vertex AI Imagen image edit: {path_obj}" - ) - return path_obj.read_bytes() + if isinstance(image, str): + raise ValueError( + "Unsupported image input: plain string values are not accepted for " + "Vertex AI Imagen image edit. Provide image bytes or a file-like object." + ) + if isinstance(image, Path): + raise ValueError( + "Unsupported image input: filesystem paths are not accepted for " + "Vertex AI Imagen image edit. Provide image bytes or a file-like object." + ) if hasattr(image, "read"): data = image.read() if isinstance(data, str): diff --git a/litellm/proxy/image_endpoints/endpoints.py b/litellm/proxy/image_endpoints/endpoints.py index 4f994b87f5..fe8b7c6fdc 100644 --- a/litellm/proxy/image_endpoints/endpoints.py +++ b/litellm/proxy/image_endpoints/endpoints.py @@ -285,6 +285,13 @@ async def image_edit_api( if mask_files: data["mask"] = mask_files + for _field in ("image", "mask"): + if _field in data and isinstance(data[_field], str): + raise HTTPException( + status_code=422, + detail=f"'{_field}' must be provided as a multipart file upload, not a string.", + ) + # Ensure prompt exists in data (default to None for models that don't require it) if "prompt" not in data: data["prompt"] = None From 42342d35fd13814f5a2add22cfe0ebb91589f227 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 18:08:17 -0700 Subject: [PATCH 13/48] fix: remove extraneous f-prefix in ValueError message --- litellm/llms/black_forest_labs/image_edit/transformation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/black_forest_labs/image_edit/transformation.py b/litellm/llms/black_forest_labs/image_edit/transformation.py index d05a802d23..eb48b0be80 100644 --- a/litellm/llms/black_forest_labs/image_edit/transformation.py +++ b/litellm/llms/black_forest_labs/image_edit/transformation.py @@ -213,7 +213,7 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig): return response.content else: raise ValueError( - f"Unsupported image input: plain string values that are not URLs are not accepted. " + "Unsupported image input: plain string values that are not URLs are not accepted. " "Provide image bytes or a file-like object." ) elif hasattr(image, "read"): From 3ddb3cbdf61071506b2289e1604ace38816a632e Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 18:20:21 -0700 Subject: [PATCH 14/48] =?UTF-8?q?bump:=20version=200.4.67=20=E2=86=92=200.?= =?UTF-8?q?4.68?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- litellm-proxy-extras/pyproject.toml | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index 959f9519a7..65f95dbde7 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-proxy-extras" -version = "0.4.67" +version = "0.4.68" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." readme = "README.md" requires-python = ">=3.9" @@ -25,7 +25,7 @@ required-version = "==0.10.9" module-root = "" [tool.commitizen] -version = "0.4.67" +version = "0.4.68" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-proxy-extras==", diff --git a/pyproject.toml b/pyproject.toml index 75aec08c99..be0fe36335 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ proxy = [ "azure-identity==1.25.2", "azure-storage-blob==12.28.0", "mcp==1.26.0", - "litellm-proxy-extras==0.4.67", + "litellm-proxy-extras==0.4.68", "litellm-enterprise==0.1.38", "RestrictedPython==8.1", "rich==13.9.4", From 9f46d838fd348146add15ba12dd3d2a68bbb0c13 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 18:21:47 -0700 Subject: [PATCH 15/48] =?UTF-8?q?bump:=20version=201.83.11=20=E2=86=92=201?= =?UTF-8?q?.83.12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index be0fe36335..41334f830f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm" -version = "1.83.11" +version = "1.83.12" description = "Library to easily interface with LLM API providers" readme = "README.md" requires-python = ">=3.10, <3.14" @@ -236,7 +236,7 @@ source-exclude = [ profile = "black" [tool.commitizen] -version = "1.83.11" +version = "1.83.12" version_files = [ "pyproject.toml:^version", ] From 95fa7678afb9d960d4b134fdf0d655491734f67b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 18:25:37 -0700 Subject: [PATCH 16/48] uv lock --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 1d449012d9..20f519ca70 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-04-19T01:10:36.69677Z" +exclude-newer = "2026-04-20T01:21:50.985363Z" exclude-newer-span = "P3D" [manifest] @@ -3085,7 +3085,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.83.11" +version = "1.83.12" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -3418,7 +3418,7 @@ source = { editable = "enterprise" } [[package]] name = "litellm-proxy-extras" -version = "0.4.67" +version = "0.4.68" source = { editable = "litellm-proxy-extras" } [[package]] From 034f4fdef20fb9b8ab5c63787e2ba764ad4661cc Mon Sep 17 00:00:00 2001 From: sakenuGOD Date: Thu, 23 Apr 2026 05:06:34 +0300 Subject: [PATCH 17/48] fix(mcp_semantic_tool_filter): match tools with client-side namespace prefix (#26078) (#26117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp_semantic_tool_filter): match canonical tools that arrive with a client-side namespace prefix. `SemanticMCPToolFilter._get_tools_by_names` matched by exact equality between the canonical name stored in the router (``) and the name in the incoming `tools[]` list. MCP clients such as opencode wrap every tool name with their own additive alias prefix (`_`), so the two never matched, the filter dropped every tool to zero, and the proxy forwarded `tools: []` with `tool_choice: auto` — which strict upstream providers reject with a 400. The fix adds anchored suffix matching with a separator check: the canonical must form the complete tail of the incoming name and be preceded by `_` or `-`. Exact matches still win over suffix matches, incoming tools are returned at most once, and the original tool object is passed through unchanged so the client-facing name survives for tool-call round-trips. Seven unit tests in a new TestGetToolsByNames class cover exact match, underscore- and dash-prefixed variants, non-separator-anchored suffixes (which must not match), exact-wins-over-prefixed precedence, deduplication when two canonicals suffix-match the same incoming tool, and ordering-follows-router-output. Fixes #26078 * review: strengthen the suffix-fallback tie-breaker and the deduplication regression test (Greptile comments on #26117) - test_same_tool_not_returned_twice now passes two distinct canonicals ("read_file" and "file") that both suffix-match the same incoming tool, rather than the same canonical twice, so the assertion actually exercises the used_ids dedup path instead of the duplicate-input-list path. - The suffix fallback in _get_tools_by_names now prefers the shortest incoming name that still qualifies under the separator-anchored match. In the one-prefix-per-client opencode scenario this is a no-op, but in multi-namespace configurations the shortest qualifying name is the least-wrapped one and is the most defensible deterministic choice, replacing the dict-insertion-order fallback. - Adds test_suffix_fallback_prefers_shortest_candidate covering the new tie-breaker directly. Still 15 tests passing locally (was 14). * review(#26117): gate suffix-matching on canonical containing MCP_TOOL_PREFIX_SEPARATOR @krrish-berri-2 flagged a possible collision in the suffix fallback: a local user function whose name happens to end in a bare canonical substring (e.g. my_firecrawl_scrape vs canonical firecrawl_scrape) would be spuriously selected. Server-registered MCP tools are always emitted as via add_server_prefix_to_name, so a canonical without the separator is not a namespaced MCP tool and does not warrant suffix matching. Added that guard to _name_matches_canonical with a regression test (test_does_not_collide_with_local_function_on_unprefixed_canonical) that reproduces the collision before the fix and is pinned after. Pre-existing TestGetToolsByNames fixtures that relied on bare canonicals (get_weather, search, read_file, write/delete/read) were switched to realistic server-prefixed ones so they continue to exercise the suffix-fallback path under the new guard. The opencode scenario (client prefix on already-server-prefixed canonical) is unchanged. --------- Co-authored-by: sakenuGOD Co-authored-by: Krrish Dholakia --- .../mcp_server/semantic_tool_filter.py | 90 ++++++++- .../mcp_server/test_semantic_tool_filter.py | 190 ++++++++++++++++++ 2 files changed, 270 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py index 0e32bfd702..a9c4d2ece4 100644 --- a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py +++ b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py @@ -7,6 +7,7 @@ Filters MCP tools semantically for /chat/completions and /responses endpoints. from typing import TYPE_CHECKING, Any, Dict, List, Optional from litellm._logging import verbose_logger +from litellm.proxy._experimental.mcp_server.utils import MCP_TOOL_PREFIX_SEPARATOR if TYPE_CHECKING: from semantic_router.routers import SemanticRouter @@ -214,20 +215,89 @@ class SemanticMCPToolFilter: return [] + @staticmethod + def _name_matches_canonical(client_name: str, canonical: str) -> bool: + """ + Return True if a client-side tool name refers to the given canonical + MCP tool name. + + MCP clients (e.g. opencode) commonly wrap the proxy's canonical tool + name with an additive namespace prefix of their own + (````). The prefix can use either a + dash or an underscore as separator regardless of what + ``MCP_TOOL_PREFIX_SEPARATOR`` is set to on the proxy, because the + client doesn't know the proxy's separator. + + The match is anchored: ``canonical`` must form the complete suffix + of ``client_name`` and be preceded by a separator character, so + ``rain_gear`` does not match canonical ``ear``. + + Suffix matching is additionally gated on ``canonical`` itself + containing ``MCP_TOOL_PREFIX_SEPARATOR``. Server-registered MCP + tools are always emitted as + ```` (see + ``add_server_prefix_to_name``), so a canonical without the + separator is not a namespaced MCP tool and falling back to + suffix matching would spuriously collide with unrelated local + user functions whose names end in the same characters. + """ + if client_name == canonical: + return True + if MCP_TOOL_PREFIX_SEPARATOR not in canonical: + return False + if len(client_name) <= len(canonical): + return False + if not client_name.endswith(canonical): + return False + separator = client_name[-len(canonical) - 1] + return separator in ("_", "-") + def _get_tools_by_names( self, tool_names: List[str], available_tools: List[Any] ) -> List[Any]: - """Get tools from available_tools by their names, preserving order.""" - # Match tools from available_tools (preserves format - dict or MCPTool) - matched_tools = [] - for tool in available_tools: - tool_name, _ = self._extract_tool_info(tool) - if tool_name in tool_names: - matched_tools.append(tool) + """ + Get tools from available_tools by their names, preserving the + semantic router's ordering. - # Reorder to match semantic router's ordering - tool_map = {self._extract_tool_info(t)[0]: t for t in matched_tools} - return [tool_map[name] for name in tool_names if name in tool_map] + Matching is tolerant of client-side namespace prefixes: if an + incoming tool arrived as ``_`` while the + router returned ```` (see + ``_name_matches_canonical``), that tool is still selected. The + returned tool object is the original from ``available_tools``, so + the client-facing name is preserved for tool-call round-trips. + """ + # Build an index of incoming tools by their client-facing name. + # Exact matches win over suffix matches when both are present, and + # each incoming tool is returned at most once even if two canonical + # names happen to be tail-compatible with the same incoming name. + available_by_name: Dict[str, Any] = {} + for tool in available_tools: + client_name, _ = self._extract_tool_info(tool) + if client_name and client_name not in available_by_name: + available_by_name[client_name] = tool + + matched: List[Any] = [] + used_ids: set = set() + for canonical in tool_names: + tool = available_by_name.get(canonical) + if tool is None: + # Prefer the shortest qualifying name. When several + # incoming tools suffix-match the same canonical (e.g. + # "my_search" and "my_tag_search" both end in "search"), + # the one closest in length to the canonical is the + # least-wrapped and most likely the intended target. + best_name: Optional[str] = None + for client_name in available_by_name: + if not self._name_matches_canonical(client_name, canonical): + continue + if best_name is None or len(client_name) < len(best_name): + best_name = client_name + if best_name is not None: + tool = available_by_name[best_name] + if tool is not None and id(tool) not in used_ids: + matched.append(tool) + used_ids.add(id(tool)) + return matched def extract_user_query(self, messages: List[Dict[str, Any]]) -> str: """ diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py index 3acd5c112e..2558df8533 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py @@ -450,3 +450,193 @@ async def test_semantic_filter_hook_skips_no_tools(): # Should return None (no modification) assert result is None, "Hook should skip requests without tools" print("✅ Hook correctly skips requests without tools") + + +class TestGetToolsByNames: + """ + Regression coverage for SemanticMCPToolFilter._get_tools_by_names + name-matching behavior (issue #26078). + + The canonical name stored in the router is what the proxy's MCP + registry emits (e.g. ``fc_web_search-firecrawl_scrape``). Some MCP + clients — notably opencode — wrap every tool name with their own + additive namespace prefix before sending it back in ``tools[]``, so + the incoming name is ``litellm_fc_web_search-firecrawl_scrape``. + + Exact-equality matching against the canonical dropped every such + tool, the proxy forwarded ``tools: []`` with ``tool_choice: auto``, + and strict upstream providers returned 400. + """ + + def _make_filter(self): + from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + SemanticMCPToolFilter, + ) + + return SemanticMCPToolFilter( + embedding_model="text-embedding-3-small", + litellm_router_instance=Mock(), + top_k=5, + similarity_threshold=0.3, + enabled=True, + ) + + def test_exact_match_unchanged(self): + """Incoming name equals canonical — the historical path still works.""" + filter_instance = self._make_filter() + available_tools = [ + {"name": "get_weather", "description": "fetch weather"}, + {"name": "send_email", "description": "send mail"}, + ] + + matched = filter_instance._get_tools_by_names( + ["send_email"], available_tools + ) + + assert len(matched) == 1 + assert matched[0]["name"] == "send_email" + + def test_client_prefix_with_underscore_separator(self): + """Client wraps canonical with ``_`` (opencode pattern).""" + filter_instance = self._make_filter() + canonical = "fc_web_search-firecrawl_scrape" + client_name = "litellm_" + canonical + available_tools = [{"name": client_name, "description": "scrape"}] + + matched = filter_instance._get_tools_by_names( + [canonical], available_tools + ) + + assert len(matched) == 1 + # Must return the incoming tool unchanged so the client-facing + # name survives, otherwise tool-call round-trips break client-side. + assert matched[0]["name"] == client_name + + def test_client_prefix_with_dash_separator(self): + """Some clients use dash as alias separator; accept that too.""" + filter_instance = self._make_filter() + canonical = "weather_svc-get_weather" + available_tools = [ + {"name": "mcp-" + canonical, "description": "weather"} + ] + + matched = filter_instance._get_tools_by_names( + [canonical], available_tools + ) + + assert len(matched) == 1 + assert matched[0]["name"] == "mcp-" + canonical + + def test_suffix_without_separator_does_not_match(self): + """ + A bare-substring suffix must not match — ``rain_gear`` is not a + namespaced version of canonical ``ear`` and the user would be + surprised to see it selected. + """ + filter_instance = self._make_filter() + available_tools = [{"name": "rain_gear", "description": "raincoat"}] + + matched = filter_instance._get_tools_by_names(["ear"], available_tools) + + assert matched == [] + + def test_exact_match_preferred_over_prefixed(self): + """ + When both a bare canonical and a client-prefixed variant are + present, the bare one wins so ordering is stable. + """ + filter_instance = self._make_filter() + canonical = "search" + available_tools = [ + {"name": canonical, "description": "plain"}, + {"name": "litellm_" + canonical, "description": "wrapped"}, + ] + + matched = filter_instance._get_tools_by_names( + [canonical], available_tools + ) + + assert len(matched) == 1 + assert matched[0]["name"] == canonical + + def test_same_tool_not_returned_twice(self): + """ + Two distinct canonicals that both suffix-match the same incoming + tool must not produce a duplicate in the output list. + ``fs-read_file`` and ``api-fs-read_file`` are both valid + separator-anchored suffixes of ``litellm_api-fs-read_file``. + """ + filter_instance = self._make_filter() + available_tools = [ + {"name": "litellm_api-fs-read_file", "description": "read"} + ] + + matched = filter_instance._get_tools_by_names( + ["fs-read_file", "api-fs-read_file"], available_tools + ) + + assert len(matched) == 1 + + def test_suffix_fallback_prefers_shortest_candidate(self): + """ + When no exact match exists and several incoming tools + suffix-match the same canonical, the one closest in length to + the canonical (i.e. the least-wrapped) should be chosen. + """ + filter_instance = self._make_filter() + canonical = "svc-search" + available_tools = [ + {"name": "my_tag_" + canonical, "description": "tag search"}, + {"name": "my_" + canonical, "description": "plain search"}, + ] + + matched = filter_instance._get_tools_by_names( + [canonical], available_tools + ) + + assert len(matched) == 1 + assert matched[0]["name"] == "my_" + canonical + + def test_ordering_follows_router_output(self): + """Returned tools follow the order the semantic router chose.""" + filter_instance = self._make_filter() + available_tools = [ + {"name": "litellm_fs-read", "description": "read"}, + {"name": "litellm_fs-write", "description": "write"}, + {"name": "litellm_fs-delete", "description": "delete"}, + ] + + matched = filter_instance._get_tools_by_names( + ["fs-write", "fs-delete", "fs-read"], available_tools + ) + + names = [t["name"] for t in matched] + assert names == [ + "litellm_fs-write", + "litellm_fs-delete", + "litellm_fs-read", + ] + + def test_does_not_collide_with_local_function_on_unprefixed_canonical(self): + """ + Guard against the collision @krrish-berri-2 flagged on #26117: + if the canonical name from the router is not server-prefixed + (i.e. does not contain ``MCP_TOOL_PREFIX_SEPARATOR``), suffix + matching must not kick in. Otherwise an unrelated local user + function whose name happens to end in the canonical substring + would be spuriously selected. + """ + filter_instance = self._make_filter() + available_tools = [ + { + "name": "my_firecrawl_scrape", + "description": "unrelated local function", + }, + ] + + matched = filter_instance._get_tools_by_names( + ["firecrawl_scrape"], # no MCP_TOOL_PREFIX_SEPARATOR in canonical + available_tools, + ) + + assert matched == [] From b42b86df7a428cc6b3d628eaf313a513c0fe4c34 Mon Sep 17 00:00:00 2001 From: Vigilans Date: Thu, 23 Apr 2026 10:19:54 +0800 Subject: [PATCH 18/48] fix(adapter): normalize reasoning effort with graceful degradation (#26111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(model-info): include reasoning effort support fields in get_model_info _get_model_info_helper constructs ModelInfoBase explicitly but never reads supports_xhigh/minimal/none_reasoning_effort from the cost map JSON. Add the three fields so get_model_info() returns them correctly. Also add supports_minimal_reasoning_effort to the ModelInfo TypedDict (xhigh and none were already declared, minimal was missing). * fix(model-registry): add missing reasoning effort fields for claude 4.6/4.7 Claude Opus 4.7 supports max reasoning effort (above xhigh). The field was present for Opus 4.6 but missing for all Opus 4.7 entries (base, dated, Bedrock, Vertex AI, Azure AI). All Claude 4.6/4.7 models (Opus 4.6, Sonnet 4.6, Opus 4.7) support minimal reasoning effort via adaptive thinking. Add the field to all provider variants. * fix(adapter): map output_config.effort to reasoning_effort (#25079) Anthropic's adaptive thinking (thinking.type="adaptive") and output_config.effort were silently dropped when translating to OpenAI format, resulting in no reasoning_effort on the outgoing request. Adapter changes (format translation): - adapters/transformation.py: add "adaptive" branch to translate_anthropic_thinking_to_reasoning_effort(); pass through output_config.effort as-is in _translate_thinking_to_openai(); add "output_config" to translatable_anthropic_params - adapters/handler.py: extract output_config from extra_kwargs into request_data so it reaches the translation layer - responses_adapters/transformation.py: add "adaptive" branch and output_config param to translate_thinking_to_reasoning() Handler changes (model-aware normalization): - utils.py: add normalize_reasoning_effort_value() that uses get_model_info() to map "max" → "xhigh"/"high" and "minimal" → "minimal"/"low" based on model capabilities - adapters/handler.py: call normalization before responses routing - responses_adapters/handler.py: call normalization after translation Relates to BerriAI/litellm#25079 * test(reasoning-effort): add tests for effort capability fields and normalize logic Test coverage for: - get_model_info returning supports_minimal/max_reasoning_effort fields - JSON registry entries for claude 4.6/4.7 across all providers - normalize_reasoning_effort_value degradation chains and exception fallback - Adapter translation of adaptive thinking + output_config.effort * fix: forward custom_llm_provider to normalize_reasoning_effort_value in responses adapter --- .../adapters/handler.py | 52 ++++ .../adapters/transformation.py | 12 + .../responses_adapters/handler.py | 17 ++ .../responses_adapters/transformation.py | 40 ++- .../experimental_pass_through/utils.py | 44 +++ ...odel_prices_and_context_window_backup.json | 106 +++++-- litellm/types/utils.py | 2 + litellm/utils.py | 6 + model_prices_and_context_window.json | 106 +++++-- .../test_reasoning_effort_fields.py | 287 ++++++++++++++++++ 10 files changed, 598 insertions(+), 74 deletions(-) create mode 100644 tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py index 897ca3bf89..d16f5afb45 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py @@ -106,6 +106,44 @@ class LiteLLMMessagesToCompletionTransformationHandler: updated_reasoning_effort["summary"] = effective_summary completion_kwargs["reasoning_effort"] = updated_reasoning_effort + @staticmethod + def _normalize_reasoning_effort( + completion_kwargs: Dict[str, Any], + ) -> None: + """ + Normalize reasoning_effort values based on target model capabilities. + + Handles both string ("max") and dict ({"effort": "max", "summary": ...}) + formats. Uses model registry to check supports_xhigh/supports_minimal. + """ + from litellm.llms.anthropic.experimental_pass_through.utils import ( + normalize_reasoning_effort_value, + ) + + reasoning_effort = completion_kwargs.get("reasoning_effort") + if reasoning_effort is None: + return + + model = cast(str, completion_kwargs.get("model", "")) + custom_llm_provider = completion_kwargs.get("custom_llm_provider") + + if isinstance(reasoning_effort, str): + normalized = normalize_reasoning_effort_value( + reasoning_effort, model=model, custom_llm_provider=custom_llm_provider + ) + if normalized != reasoning_effort: + completion_kwargs["reasoning_effort"] = normalized + elif isinstance(reasoning_effort, dict) and "effort" in reasoning_effort: + effort = reasoning_effort["effort"] + normalized = normalize_reasoning_effort_value( + effort, model=model, custom_llm_provider=custom_llm_provider + ) + if normalized != effort: + completion_kwargs["reasoning_effort"] = { + **reasoning_effort, + "effort": normalized, + } + @staticmethod def _prepare_completion_kwargs( *, @@ -163,6 +201,12 @@ class LiteLLMMessagesToCompletionTransformationHandler: if output_format: request_data["output_format"] = output_format + # Extract output_config from extra_kwargs so the translator can use it + # (e.g. output_config.effort for adaptive thinking → reasoning_effort) + extra_kwargs = extra_kwargs or {} + if "output_config" in extra_kwargs: + request_data["output_config"] = extra_kwargs["output_config"] + ( openai_request, tool_name_mapping, @@ -202,6 +246,14 @@ class LiteLLMMessagesToCompletionTransformationHandler: ): completion_kwargs[key] = value + # Normalize reasoning_effort based on model capabilities + # (e.g. "max" → "xhigh"/"high", "minimal" → "low" if unsupported) + # Must run BEFORE _route_openai_thinking, which prepends "responses/" + # to the model name and would break get_model_info() lookups. + LiteLLMMessagesToCompletionTransformationHandler._normalize_reasoning_effort( + completion_kwargs + ) + LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed( completion_kwargs, thinking=thinking, diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 072ae7c3bb..e5d2b4ce78 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -317,6 +317,7 @@ class LiteLLMAnthropicMessagesAdapter: "tools", "thinking", "output_format", + "output_config", ] def _is_web_search_tool(self, tool: Dict[str, Any]) -> bool: @@ -694,6 +695,11 @@ class LiteLLMAnthropicMessagesAdapter: return "low" else: return "minimal" + elif thinking_type == "adaptive": + # Adaptive thinking: effort is controlled by output_config.effort, + # not budget_tokens. Return a default; caller should override with + # output_config.effort when available. + return "medium" return None @@ -1041,6 +1047,12 @@ class LiteLLMAnthropicMessagesAdapter: if not reasoning_effort: return + # For adaptive thinking, override with output_config.effort if available + if isinstance(thinking, dict) and thinking.get("type") == "adaptive": + output_config = anthropic_message_request.get("output_config") + if isinstance(output_config, dict) and output_config.get("effort"): + reasoning_effort = output_config["effort"] + summary = thinking.get("summary") if isinstance(thinking, dict) else None auto_summary = is_reasoning_auto_summary_enabled() if summary: diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py index 198ebe1ff8..5be16dcbf1 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/handler.py @@ -72,6 +72,23 @@ def _build_responses_kwargs( anthropic_request = AnthropicMessagesRequest(**request_data) # type: ignore[typeddict-item] responses_kwargs = _ADAPTER.translate_request(anthropic_request) + # Normalize reasoning effort based on model capabilities + # (e.g. "max" → "xhigh"/"high", "minimal" → "low" if unsupported) + reasoning = responses_kwargs.get("reasoning") + if isinstance(reasoning, dict) and "effort" in reasoning: + from litellm.llms.anthropic.experimental_pass_through.utils import ( + normalize_reasoning_effort_value, + ) + + effort = reasoning["effort"] + normalized = normalize_reasoning_effort_value( + effort, + model=model, + custom_llm_provider=(extra_kwargs or {}).get("custom_llm_provider"), + ) + if normalized != effort: + responses_kwargs["reasoning"] = {**reasoning, "effort": normalized} + if stream: responses_kwargs["stream"] = True diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py index 913470e708..2badc2a327 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -251,25 +251,41 @@ class LiteLLMAnthropicToResponsesAPIAdapter: @staticmethod def translate_thinking_to_reasoning( - thinking: Dict[str, Any] + thinking: Dict[str, Any], + output_config: Optional[Dict[str, Any]] = None, ) -> Optional[Dict[str, Any]]: """ Convert Anthropic thinking param to Responses API reasoning param. thinking.budget_tokens maps to reasoning effort: >= 10000 -> high, >= 5000 -> medium, >= 2000 -> low, < 2000 -> minimal + + For adaptive thinking, uses output_config.effort if available, + otherwise defaults to medium. """ - if not isinstance(thinking, dict) or thinking.get("type") != "enabled": + if not isinstance(thinking, dict): return None - budget = thinking.get("budget_tokens", 0) - if budget >= 10000: - effort = "high" - elif budget >= 5000: + + thinking_type = thinking.get("type") + + if thinking_type == "adaptive": + # Use output_config.effort if available effort = "medium" - elif budget >= 2000: - effort = "low" + if isinstance(output_config, dict) and output_config.get("effort"): + effort = output_config["effort"] + elif thinking_type == "enabled": + budget = thinking.get("budget_tokens", 0) + if budget >= 10000: + effort = "high" + elif budget >= 5000: + effort = "medium" + elif budget >= 2000: + effort = "low" + else: + effort = "minimal" else: - effort = "minimal" + return None + auto_summary = is_reasoning_auto_summary_enabled() result: Dict[str, Any] = {"effort": effort} summary = thinking.get("summary") @@ -346,7 +362,11 @@ class LiteLLMAnthropicToResponsesAPIAdapter: # thinking -> reasoning thinking = anthropic_request.get("thinking") if isinstance(thinking, dict): - reasoning = self.translate_thinking_to_reasoning(thinking) + output_config = anthropic_request.get("output_config") + reasoning = self.translate_thinking_to_reasoning( + thinking, + output_config=cast(Optional[Dict[str, Any]], output_config), + ) if reasoning: responses_kwargs["reasoning"] = reasoning diff --git a/litellm/llms/anthropic/experimental_pass_through/utils.py b/litellm/llms/anthropic/experimental_pass_through/utils.py index 6c1db6017b..d975bee0bc 100644 --- a/litellm/llms/anthropic/experimental_pass_through/utils.py +++ b/litellm/llms/anthropic/experimental_pass_through/utils.py @@ -1,4 +1,5 @@ import os +from typing import Optional import litellm @@ -9,3 +10,46 @@ def is_reasoning_auto_summary_enabled() -> bool: litellm.reasoning_auto_summary or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true" ) + + +def normalize_reasoning_effort_value( + effort: str, + model: str, + custom_llm_provider: Optional[str] = None, +) -> str: + """ + Normalize a reasoning effort value based on model capabilities. + + Degradation chains: + - "max" → max / xhigh / high + - "xhigh" → xhigh / high + - "minimal" → minimal / low + - other values pass through unchanged + """ + if effort not in ("max", "xhigh", "minimal"): + return effort + + from litellm.utils import get_model_info + + try: + model_info = get_model_info( + model=model, custom_llm_provider=custom_llm_provider + ) + except Exception: + model_info = {} + + if effort == "max": + if model_info.get("supports_max_reasoning_effort"): + return "max" + if model_info.get("supports_xhigh_reasoning_effort"): + return "xhigh" + return "high" + elif effort == "xhigh": + if model_info.get("supports_xhigh_reasoning_effort"): + return "xhigh" + return "high" + elif effort == "minimal": + if model_info.get("supports_minimal_reasoning_effort"): + return "minimal" + return "low" + return "medium" diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 04b68b8f4e..05b59d45f9 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1006,7 +1006,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.25e-06, @@ -1034,7 +1035,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1062,7 +1064,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1090,7 +1093,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1118,7 +1122,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -1146,7 +1151,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -1174,7 +1181,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1202,7 +1211,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1230,7 +1241,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1258,7 +1271,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 3.75e-06, @@ -1285,7 +1300,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 3.75e-06, @@ -1312,7 +1328,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1339,7 +1356,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1366,7 +1384,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1393,7 +1412,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -1911,7 +1931,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "azure_ai/claude-opus-4-7": { "input_cost_per_token": 5e-06, @@ -1939,7 +1960,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "azure_ai/claude-opus-4-1": { "cache_creation_input_token_cost": 1.875e-05, @@ -2003,7 +2026,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "azure/computer-use-preview": { "input_cost_per_token": 3e-06, @@ -8909,7 +8933,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -9103,7 +9128,8 @@ "us": 1.1, "fast": 6.0 }, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-6-20260205": { "cache_creation_input_token_cost": 6.25e-06, @@ -9135,7 +9161,8 @@ "us": 1.1, "fast": 6.0 }, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -9167,7 +9194,9 @@ "provider_specific_entry": { "us": 1.1, "fast": 6.0 - } + }, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-7-20260416": { "cache_creation_input_token_cost": 6.25e-06, @@ -9199,7 +9228,9 @@ "provider_specific_entry": { "us": 1.1, "fast": 6.0 - } + }, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-20250514": { "deprecation_date": "2026-05-14", @@ -25052,7 +25083,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_minimal_reasoning_effort": true }, "openrouter/anthropic/claude-opus-4.5": { "cache_creation_input_token_cost": 6.25e-06, @@ -25090,7 +25122,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "openrouter/anthropic/claude-sonnet-4.5": { "input_cost_per_image": 0.0048, @@ -30118,7 +30151,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_minimal_reasoning_effort": true }, "vercel_ai_gateway/anthropic/claude-sonnet-4": { "cache_creation_input_token_cost": 3.75e-06, @@ -31345,7 +31379,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-6@default": { "cache_creation_input_token_cost": 6.25e-06, @@ -31372,7 +31407,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -31399,7 +31435,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-7@default": { "cache_creation_input_token_cost": 6.25e-06, @@ -31426,7 +31464,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-sonnet-4-5": { "cache_creation_input_token_cost": 3.75e-06, @@ -31478,7 +31518,8 @@ "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 - } + }, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-sonnet-4-5@20250929": { "cache_creation_input_token_cost": 3.75e-06, @@ -38345,7 +38386,8 @@ "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 - } + }, + "supports_minimal_reasoning_effort": true }, "duckduckgo/search": { "litellm_provider": "duckduckgo", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index e3058d106a..c347956cba 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -139,7 +139,9 @@ class ProviderSpecificModelInfo(TypedDict, total=False): supports_reasoning: Optional[bool] supports_url_context: Optional[bool] supports_none_reasoning_effort: Optional[bool] + supports_minimal_reasoning_effort: Optional[bool] supports_xhigh_reasoning_effort: Optional[bool] + supports_max_reasoning_effort: Optional[bool] class SearchContextCostPerQuery(TypedDict, total=False): diff --git a/litellm/utils.py b/litellm/utils.py index c4aee79297..7a9f62afa0 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5893,9 +5893,15 @@ def _get_model_info_helper( # noqa: PLR0915 supports_none_reasoning_effort=_model_info.get( "supports_none_reasoning_effort", None ), + supports_minimal_reasoning_effort=_model_info.get( + "supports_minimal_reasoning_effort", None + ), supports_xhigh_reasoning_effort=_model_info.get( "supports_xhigh_reasoning_effort", None ), + supports_max_reasoning_effort=_model_info.get( + "supports_max_reasoning_effort", None + ), supports_computer_use=_model_info.get("supports_computer_use", None), search_context_cost_per_query=_model_info.get( "search_context_cost_per_query", None diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 386532f07a..8a28235f98 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1006,7 +1006,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.25e-06, @@ -1034,7 +1035,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1062,7 +1064,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1090,7 +1093,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, @@ -1118,7 +1122,8 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -1146,7 +1151,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-mythos-preview": { "input_cost_per_token": 0, @@ -1188,7 +1195,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1216,7 +1225,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1244,7 +1255,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 6.875e-06, @@ -1272,7 +1285,9 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 3.75e-06, @@ -1299,7 +1314,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "global.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 3.75e-06, @@ -1326,7 +1342,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "us.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1353,7 +1370,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "eu.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1380,7 +1398,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "au.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 4.125e-06, @@ -1407,7 +1426,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_minimal_reasoning_effort": true }, "anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -1925,7 +1945,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "azure_ai/claude-opus-4-7": { "input_cost_per_token": 5e-06, @@ -1953,7 +1974,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "azure_ai/claude-opus-4-1": { "cache_creation_input_token_cost": 1.875e-05, @@ -2017,7 +2040,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "azure/computer-use-preview": { "input_cost_per_token": 3e-06, @@ -8923,7 +8947,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -9117,7 +9142,8 @@ "us": 1.1, "fast": 6.0 }, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-6-20260205": { "cache_creation_input_token_cost": 6.25e-06, @@ -9149,7 +9175,8 @@ "us": 1.1, "fast": 6.0 }, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -9181,7 +9208,9 @@ "provider_specific_entry": { "us": 1.1, "fast": 6.0 - } + }, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-opus-4-7-20260416": { "cache_creation_input_token_cost": 6.25e-06, @@ -9213,7 +9242,9 @@ "provider_specific_entry": { "us": 1.1, "fast": 6.0 - } + }, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-20250514": { "deprecation_date": "2026-05-14", @@ -25066,7 +25097,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "tool_use_system_prompt_tokens": 159, + "supports_minimal_reasoning_effort": true }, "openrouter/anthropic/claude-opus-4.5": { "cache_creation_input_token_cost": 6.25e-06, @@ -25104,7 +25136,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_minimal_reasoning_effort": true }, "openrouter/anthropic/claude-sonnet-4.5": { "input_cost_per_image": 0.0048, @@ -30132,7 +30165,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_minimal_reasoning_effort": true }, "vercel_ai_gateway/anthropic/claude-sonnet-4": { "cache_creation_input_token_cost": 3.75e-06, @@ -31359,7 +31393,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-6@default": { "cache_creation_input_token_cost": 6.25e-06, @@ -31386,7 +31421,8 @@ "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-7": { "cache_creation_input_token_cost": 6.25e-06, @@ -31413,7 +31449,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-opus-4-7@default": { "cache_creation_input_token_cost": 6.25e-06, @@ -31440,7 +31478,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-sonnet-4-5": { "cache_creation_input_token_cost": 3.75e-06, @@ -31492,7 +31532,8 @@ "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 - } + }, + "supports_minimal_reasoning_effort": true }, "vertex_ai/claude-sonnet-4-5@20250929": { "cache_creation_input_token_cost": 3.75e-06, @@ -38386,7 +38427,8 @@ "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 - } + }, + "supports_minimal_reasoning_effort": true }, "duckduckgo/search": { "litellm_provider": "duckduckgo", diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py new file mode 100644 index 0000000000..d42d109f21 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/test_reasoning_effort_fields.py @@ -0,0 +1,287 @@ +""" +Tests for reasoning effort capability fields and normalize_reasoning_effort_value. + +Covers: +- Commit 1: get_model_info returns supports_minimal/supports_max fields +- Commit 2: Model registry entries have correct reasoning effort fields +- Commit 3: normalize_reasoning_effort_value degradation chains + adapter translation +""" + +import json +import os +from typing import Any, Dict, Optional +from unittest.mock import patch + +import pytest + +from litellm.llms.anthropic.experimental_pass_through.utils import ( + normalize_reasoning_effort_value, +) +from litellm.utils import get_model_info + + +def _load_model_registry() -> Dict[str, Any]: + """Load the root model_prices_and_context_window.json.""" + json_path = os.path.join( + os.path.dirname(__file__), + "../../../../../model_prices_and_context_window.json", + ) + with open(json_path) as f: + return json.load(f) + + +# --------------------------------------------------------------------------- +# Commit 1: get_model_info returns supports_minimal and supports_max fields +# --------------------------------------------------------------------------- + + +class TestGetModelInfoReasoningEffortFields: + """get_model_info should expose supports_minimal_reasoning_effort and + supports_max_reasoning_effort from the model registry.""" + + def test_opus_4_6_has_supports_minimal(self): + info = get_model_info("claude-opus-4-6") + assert "supports_minimal_reasoning_effort" in info + + def test_opus_4_6_has_supports_max(self): + info = get_model_info("claude-opus-4-6") + assert "supports_max_reasoning_effort" in info + + def test_opus_4_7_has_supports_minimal(self): + info = get_model_info("claude-opus-4-7") + assert "supports_minimal_reasoning_effort" in info + + def test_opus_4_7_has_supports_max(self): + info = get_model_info("claude-opus-4-7") + assert "supports_max_reasoning_effort" in info + + +# --------------------------------------------------------------------------- +# Commit 2: JSON registry has correct reasoning effort fields +# --------------------------------------------------------------------------- + + +class TestModelRegistryReasoningEffortFields: + """Verify specific models have the expected reasoning effort capability + values in the JSON registry file.""" + + @pytest.fixture(autouse=True) + def _load_registry(self): + self.registry = _load_model_registry() + + def test_opus_4_7_supports_max(self): + entry = self.registry["claude-opus-4-7"] + assert entry.get("supports_max_reasoning_effort") is True + + def test_opus_4_6_supports_max(self): + entry = self.registry["claude-opus-4-6"] + assert entry.get("supports_max_reasoning_effort") is True + + def test_opus_4_7_supports_minimal(self): + entry = self.registry["claude-opus-4-7"] + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_opus_4_6_supports_minimal(self): + entry = self.registry["claude-opus-4-6"] + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_sonnet_4_6_supports_minimal(self): + entry = self.registry["anthropic.claude-sonnet-4-6"] + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_bedrock_opus_4_7_supports_max(self): + entry = self.registry["anthropic.claude-opus-4-7"] + assert entry.get("supports_max_reasoning_effort") is True + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_vertex_opus_4_7_supports_max(self): + entry = self.registry["vertex_ai/claude-opus-4-7"] + assert entry.get("supports_max_reasoning_effort") is True + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_vertex_opus_4_6_supports_max(self): + entry = self.registry["vertex_ai/claude-opus-4-6"] + assert entry.get("supports_max_reasoning_effort") is True + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_azure_ai_opus_4_6_supports_minimal(self): + entry = self.registry["azure_ai/claude-opus-4-6"] + assert entry.get("supports_minimal_reasoning_effort") is True + + def test_azure_ai_opus_4_7_supports_max(self): + entry = self.registry["azure_ai/claude-opus-4-7"] + assert entry.get("supports_max_reasoning_effort") is True + assert entry.get("supports_minimal_reasoning_effort") is True + + +# --------------------------------------------------------------------------- +# Commit 3: normalize_reasoning_effort_value +# --------------------------------------------------------------------------- + + +def _mock_model_info(**flags): + """Return a mock model_info dict with given capability flags.""" + return flags + + +class TestNormalizeReasoningEffortValue: + """Test degradation chains for normalize_reasoning_effort_value.""" + + # --- "max" degradation chain --- + + def test_max_stays_max_when_supported(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info( + supports_max_reasoning_effort=True, + supports_xhigh_reasoning_effort=True, + ), + ): + assert normalize_reasoning_effort_value("max", model="test") == "max" + + def test_max_degrades_to_xhigh(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info( + supports_max_reasoning_effort=False, + supports_xhigh_reasoning_effort=True, + ), + ): + assert normalize_reasoning_effort_value("max", model="test") == "xhigh" + + def test_max_degrades_to_high(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info( + supports_max_reasoning_effort=False, + supports_xhigh_reasoning_effort=False, + ), + ): + assert normalize_reasoning_effort_value("max", model="test") == "high" + + # --- "xhigh" degradation chain --- + + def test_xhigh_stays_xhigh_when_supported(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info(supports_xhigh_reasoning_effort=True), + ): + assert normalize_reasoning_effort_value("xhigh", model="test") == "xhigh" + + def test_xhigh_degrades_to_high(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info(supports_xhigh_reasoning_effort=False), + ): + assert normalize_reasoning_effort_value("xhigh", model="test") == "high" + + # --- "minimal" degradation chain --- + + def test_minimal_stays_minimal_when_supported(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info(supports_minimal_reasoning_effort=True), + ): + assert ( + normalize_reasoning_effort_value("minimal", model="test") == "minimal" + ) + + def test_minimal_degrades_to_low(self): + with patch( + "litellm.utils.get_model_info", + return_value=_mock_model_info(supports_minimal_reasoning_effort=False), + ): + assert normalize_reasoning_effort_value("minimal", model="test") == "low" + + # --- passthrough values --- + + def test_high_passes_through(self): + assert normalize_reasoning_effort_value("high", model="test") == "high" + + def test_medium_passes_through(self): + assert normalize_reasoning_effort_value("medium", model="test") == "medium" + + def test_low_passes_through(self): + assert normalize_reasoning_effort_value("low", model="test") == "low" + + # --- exception fallback --- + + def test_exception_fallback_uses_empty_model_info(self): + """When get_model_info raises, treat model_info as {} (no capabilities).""" + with patch( + "litellm.utils.get_model_info", + side_effect=Exception("model not found"), + ): + # "max" with no capabilities -> "high" + assert normalize_reasoning_effort_value("max", model="unknown") == "high" + # "minimal" with no capabilities -> "low" + assert normalize_reasoning_effort_value("minimal", model="unknown") == "low" + + +# --------------------------------------------------------------------------- +# Commit 3: Adapter translation — adaptive thinking + output_config.effort +# --------------------------------------------------------------------------- + + +class TestAdapterAdaptiveThinking: + """Test that adaptive thinking type maps correctly through the adapters.""" + + def test_messages_adapter_adaptive_returns_medium_default(self): + """Adaptive thinking returns 'medium' as default reasoning_effort.""" + from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import ( + LiteLLMAnthropicMessagesAdapter, + ) + + adapter = LiteLLMAnthropicMessagesAdapter() + result = adapter.translate_anthropic_thinking_to_reasoning_effort( + {"type": "adaptive"} + ) + assert result == "medium" + + def test_messages_adapter_adaptive_overridden_by_output_config(self): + """For adaptive thinking, output_config.effort overrides reasoning_effort.""" + from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import ( + LiteLLMAnthropicMessagesAdapter, + ) + from litellm.types.llms.anthropic import AnthropicMessagesRequest + + adapter = LiteLLMAnthropicMessagesAdapter() + request = AnthropicMessagesRequest( + model="test-model", + messages=[{"role": "user", "content": "hello"}], + max_tokens=1024, + thinking={"type": "adaptive"}, + output_config={"effort": "high"}, + ) + openai_kwargs, _ = adapter.translate_anthropic_to_openai(request) + # reasoning_effort should be set (either as string or dict with effort) + re = openai_kwargs.get("reasoning_effort") + if isinstance(re, dict): + assert re["effort"] == "high" + else: + assert re == "high" + + def test_responses_adapter_adaptive_with_output_config(self): + """Responses adapter: adaptive thinking + output_config.effort.""" + from litellm.llms.anthropic.experimental_pass_through.responses_adapters.transformation import ( + LiteLLMAnthropicToResponsesAPIAdapter, + ) + + result = LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning( + thinking={"type": "adaptive"}, + output_config={"effort": "xhigh"}, + ) + assert result is not None + assert result["effort"] == "xhigh" + + def test_responses_adapter_adaptive_default_medium(self): + """Responses adapter: adaptive thinking without output_config defaults to medium.""" + from litellm.llms.anthropic.experimental_pass_through.responses_adapters.transformation import ( + LiteLLMAnthropicToResponsesAPIAdapter, + ) + + result = LiteLLMAnthropicToResponsesAPIAdapter.translate_thinking_to_reasoning( + thinking={"type": "adaptive"}, + ) + assert result is not None + assert result["effort"] == "medium" From 0e23aa739097e11ce8c5e5bc5cb79bbba07a3c95 Mon Sep 17 00:00:00 2001 From: Anmol Jaiswal <68013660+anmolg1997@users.noreply.github.com> Date: Thu, 23 Apr 2026 07:52:38 +0530 Subject: [PATCH 19/48] fix(anthropic): tolerate non-OpenAI file content blocks in file-id discovery (#26228) `get_file_ids_from_messages` and `update_messages_with_model_file_ids` assume every content block with `type: "file"` has a nested `file` dict in the OpenAI Chat Completions shape. That assumption is too strong: `type: "file"` is a public content-block discriminator and several real producers emit blocks that use it without the OpenAI `file` sub-dict. For example, LangChain v1's `_normalize_messages` rewrites OpenAI file blocks into `{"type":"file","id":"...","base64":"...","mime_type":"...","extras":{}}` before they reach LiteLLM. `AnthropicConfig.validate_environment` calls both helpers unconditionally on every Anthropic (and Anthropic-via-Vertex) request, so any such block raises `KeyError: 'file'` which the Vertex partner layer then wraps as a `500 InternalServerError` before the LLM is even contacted. This patch switches both helpers from `c["file"]` to a defensive `c.get("file")` + dict check. When the block does not match the OpenAI shape there is no file_id to extract or remap, so we skip it and leave the block untouched for the downstream provider transformer to handle. Adds 5 regression tests covering the LangChain v1 shape, the OpenAI happy path, mixed shapes in one message, `file` set to a non-dict value, and the remap path for non-OpenAI blocks. Related to #24503, which proposed raising `BadRequestError` in the same spots. For these two discovery functions specifically, the skip semantics is strictly more permissive: well-formed OpenAI blocks still yield their file_id, and legitimate non-OpenAI blocks stop crashing the request. --- .../prompt_templates/common_utils.py | 16 ++- ...ore_utils_prompt_templates_common_utils.py | 113 ++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 46e60c24d3..b234e6c8f7 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -452,7 +452,14 @@ def update_messages_with_model_file_ids( for c in content: if c["type"] == "file": file_object = cast(ChatCompletionFileObject, c) - file_object_file_field = file_object["file"] + file_object_file_field = file_object.get("file") + if not isinstance(file_object_file_field, dict): + # Content block has `type: "file"` but not the + # OpenAI Chat Completions shape (e.g. a LangChain + # v1 standardized file block, or a provider-native + # shape that also uses `type: "file"`). Nothing to + # remap here, so skip instead of crashing. + continue file_id = file_object_file_field.get("file_id") format = file_object_file_field.get( "format", get_format_from_file_id(file_id) @@ -1060,7 +1067,12 @@ def get_file_ids_from_messages(messages: List[AllMessageValues]) -> List[str]: for c in content: if c["type"] == "file": file_object = cast(ChatCompletionFileObject, c) - file_object_file_field = file_object["file"] + file_object_file_field = file_object.get("file") + if not isinstance(file_object_file_field, dict): + # Content block has `type: "file"` but not the + # OpenAI Chat Completions shape. No file_id to + # extract, so skip instead of raising KeyError. + continue file_id = file_object_file_field.get("file_id") if file_id: file_ids.append(file_id) diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index 8c34a50c4f..22d2610eec 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -11,6 +11,7 @@ sys.path.insert( from litellm.litellm_core_utils.prompt_templates.common_utils import ( add_system_prompt_to_messages, + get_file_ids_from_messages, get_format_from_file_id, handle_any_messages_to_chat_completion_str_messages_conversion, split_concatenated_json_objects, @@ -254,3 +255,115 @@ def test_split_concatenated_json_invalid_raises(): """Completely invalid JSON raises JSONDecodeError.""" with pytest.raises(json.JSONDecodeError): split_concatenated_json_objects("not json at all") + + +# --------------------------------------------------------------------------- +# Regression tests for non-OpenAI file content blocks. +# +# `type: "file"` is a public content-block discriminator. Several producers +# (LangChain v1, provider-native shapes, custom user code) emit blocks with +# `type: "file"` but without the OpenAI Chat Completions `file` sub-dict. +# The discovery helpers below are used unconditionally inside +# `AnthropicConfig.validate_environment`, so any crash there surfaces as a +# `500 InternalServerError` before the request is even dispatched. +# --------------------------------------------------------------------------- + + +def test_get_file_ids_from_messages_skips_langchain_v1_file_block(): + """A LangChain v1 standardized file block must not crash file-id discovery.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "summarise this PDF"}, + # LangChain v1 shape produced by `_normalize_messages`. + # No `file` sub-dict: the discriminator is `type: "file"` but + # the payload lives on `base64`/`mime_type` siblings. + { + "type": "file", + "id": "lc_1", + "base64": "JVBERi0xLjQK", + "mime_type": "application/pdf", + "extras": {"file_format": "application/pdf"}, + }, + ], + } + ] + + assert get_file_ids_from_messages(messages) == [] + + +def test_get_file_ids_from_messages_still_extracts_from_openai_shape(): + """Well-formed OpenAI file blocks still yield their file_id.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "what is this?"}, + {"type": "file", "file": {"file_id": "file-abc"}}, + ], + } + ] + + assert get_file_ids_from_messages(messages) == ["file-abc"] + + +def test_get_file_ids_from_messages_mixed_shapes(): + """Mixed OpenAI and non-OpenAI file blocks: extract from the former, + ignore the latter.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "file", "file": {"file_id": "file-keep"}}, + { + "type": "file", + "id": "lc_2", + "base64": "AAA", + "mime_type": "application/pdf", + }, + ], + } + ] + + assert get_file_ids_from_messages(messages) == ["file-keep"] + + +def test_get_file_ids_from_messages_file_field_not_dict(): + """`file` set to a non-dict value (e.g. stringified payload) must not crash.""" + messages = [ + { + "role": "user", + "content": [ + {"type": "file", "file": "unexpectedly-a-string"}, + ], + } + ] + + assert get_file_ids_from_messages(messages) == [] + + +def test_update_messages_with_model_file_ids_skips_non_openai_file_blocks(): + """`update_messages_with_model_file_ids` is also called on user content + before provider dispatch. It must tolerate non-OpenAI file blocks the same + way.""" + langchain_v1_block = { + "type": "file", + "id": "lc_3", + "base64": "AAA", + "mime_type": "application/pdf", + } + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hello"}, + langchain_v1_block, + ], + } + ] + + updated = update_messages_with_model_file_ids(messages, "model-1", {}) + + # Messages pass through unchanged when there is no `file` sub-dict to remap. + assert updated == messages From c0c7048903f98dc16af1167212395113a8f1c982 Mon Sep 17 00:00:00 2001 From: Vigilans Date: Thu, 23 Apr 2026 10:29:57 +0800 Subject: [PATCH 20/48] feat(messages): map reasoning_auto_summary to thinking.display for native /v1/messages (#25883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When reasoning_auto_summary is enabled (via litellm_settings or env var), automatically set thinking.display="summarized" on native /v1/messages requests. This ensures thinking content is returned in the response instead of being omitted (the default on Claude 4.7+). Only applies when thinking is enabled (type != "disabled"). The existing reasoning_auto_summary flag already handles the /v1/responses path (summary="detailed") and the chat/completions adapter path — this extends coverage to the native messages handler. --- .../messages/handler.py | 13 ++ .../test_reasoning_auto_summary_messages.py | 173 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_auto_summary_messages.py diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index c400d82b7c..0c59e812e0 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -24,6 +24,8 @@ from litellm.types.llms.anthropic_messages.anthropic_response import ( from litellm.types.router import GenericLiteLLMParams from litellm.utils import ProviderConfigManager, client +from ..utils import is_reasoning_auto_summary_enabled + from ..adapters.handler import LiteLLMMessagesToCompletionTransformationHandler from ..responses_adapters.handler import LiteLLMMessagesToResponsesAPIHandler from .interceptors import get_messages_interceptors @@ -441,6 +443,17 @@ def anthropic_messages_handler( params=local_vars ) ) + if is_reasoning_auto_summary_enabled(): + thinking_param = anthropic_messages_optional_request_params.get("thinking") + if ( + isinstance(thinking_param, dict) + and thinking_param.get("type") != "disabled" + ): + anthropic_messages_optional_request_params["thinking"] = { + **thinking_param, + "display": "summarized", + } + return base_llm_http_handler.anthropic_messages_handler( model=model, messages=messages, diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_auto_summary_messages.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_auto_summary_messages.py new file mode 100644 index 0000000000..07c0012b04 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_auto_summary_messages.py @@ -0,0 +1,173 @@ +""" +Tests for reasoning_auto_summary support on the native /v1/messages handler. + +When reasoning_auto_summary is enabled (via litellm.reasoning_auto_summary or +LITELLM_REASONING_AUTO_SUMMARY env var), the handler injects +thinking.display = "summarized" into the request params for active thinking +modes (type="enabled" or type="adaptive"). +""" + +import os +import sys + +import pytest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath("../../../../..")) + +import litellm +from litellm.llms.anthropic.experimental_pass_through.messages.handler import ( + anthropic_messages_handler, +) + + +def _call_handler_and_capture_optional_params(thinking=None, **extra_kwargs): + """ + Call anthropic_messages_handler with an Anthropic model and capture the + anthropic_messages_optional_request_params dict passed to + base_llm_http_handler.anthropic_messages_handler. + + Returns the captured dict. + """ + captured = {} + + with patch( + "litellm.llms.anthropic.experimental_pass_through.messages.handler." + "base_llm_http_handler" + ) as mock_handler, patch( + "litellm.llms.anthropic.experimental_pass_through.messages.handler." + "ProviderConfigManager" + ) as mock_pcm: + # Make get_provider_anthropic_messages_config return a non-None config + # so the handler takes the native Anthropic path + mock_pcm.get_provider_anthropic_messages_config.return_value = MagicMock() + mock_handler.anthropic_messages_handler.return_value = MagicMock() + + kwargs = dict(extra_kwargs) + if thinking is not None: + kwargs["thinking"] = thinking + + try: + anthropic_messages_handler( + max_tokens=1024, + messages=[{"role": "user", "content": "Hello"}], + model="claude-sonnet-4-20250514", + custom_llm_provider="anthropic", + api_key="test-key", + **kwargs, + ) + except (ValueError, TypeError, AttributeError): + pass + + if mock_handler.anthropic_messages_handler.called: + captured = mock_handler.anthropic_messages_handler.call_args.kwargs.get( + "anthropic_messages_optional_request_params", {} + ) + + return captured + + +class TestReasoningAutoSummaryMessages: + """Tests for thinking.display injection on native /v1/messages handler.""" + + def test_adaptive_thinking_gets_display_summarized(self): + """reasoning_auto_summary=True + thinking.type='adaptive' -> display='summarized'.""" + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params( + thinking={"type": "adaptive", "budget_tokens": 5000} + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" + assert thinking.get("type") == "adaptive" + assert thinking.get("budget_tokens") == 5000 + + def test_enabled_thinking_gets_display_summarized(self): + """reasoning_auto_summary=True + thinking.type='enabled' -> display='summarized'.""" + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params( + thinking={"type": "enabled", "budget_tokens": 10000} + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" + assert thinking.get("type") == "enabled" + + def test_disabled_thinking_no_display(self): + """reasoning_auto_summary=True + thinking.type='disabled' -> display NOT set.""" + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params( + thinking={"type": "disabled"} + ) + thinking = params.get("thinking", {}) + assert "display" not in thinking + + def test_no_injection_when_flag_false(self): + """reasoning_auto_summary=False + active thinking -> display NOT set.""" + with patch.object(litellm, "reasoning_auto_summary", False): + params = _call_handler_and_capture_optional_params( + thinking={"type": "enabled", "budget_tokens": 10000} + ) + thinking = params.get("thinking", {}) + assert "display" not in thinking + + def test_no_thinking_param_no_crash(self): + """reasoning_auto_summary=True but no thinking param -> nothing changes.""" + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params() + thinking = params.get("thinking") + if thinking is not None: + assert "display" not in thinking + + def test_env_var_enables_auto_summary(self): + """LITELLM_REASONING_AUTO_SUMMARY=true env var enables the feature.""" + with patch.object(litellm, "reasoning_auto_summary", False), patch.dict( + os.environ, {"LITELLM_REASONING_AUTO_SUMMARY": "true"} + ): + params = _call_handler_and_capture_optional_params( + thinking={"type": "adaptive", "budget_tokens": 5000} + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" + + def test_existing_display_summarized_preserved(self): + """User already passes display='summarized' -> preserved as-is.""" + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params( + thinking={ + "type": "enabled", + "budget_tokens": 10000, + "display": "summarized", + } + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" + + def test_existing_display_summarized_without_flag(self): + """User passes display='summarized' + flag=False -> preserved as-is.""" + with patch.object(litellm, "reasoning_auto_summary", False): + params = _call_handler_and_capture_optional_params( + thinking={ + "type": "enabled", + "budget_tokens": 10000, + "display": "summarized", + } + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" + + def test_omitted_overridden_to_summarized(self): + """User passes display='omitted' + reasoning_auto_summary=True -> overridden. + + Documents current behavior: the code unconditionally sets + display='summarized' when auto_summary is enabled and thinking is active, + regardless of any pre-existing display value. + """ + with patch.object(litellm, "reasoning_auto_summary", True): + params = _call_handler_and_capture_optional_params( + thinking={ + "type": "enabled", + "budget_tokens": 10000, + "display": "omitted", + } + ) + thinking = params.get("thinking", {}) + assert thinking.get("display") == "summarized" From bd145d18e17176b2328ddf18739cfc4bab17058f Mon Sep 17 00:00:00 2001 From: Elias <55650958+eliasto@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:33:58 -0400 Subject: [PATCH 21/48] fix(ovhcloud): Fix tool calling not working (#25948) * fix(ovhcloud): fix tool calling * fix import order --- litellm/llms/ovhcloud/chat/transformation.py | 31 +---------------- .../test_ovhcloud_chat_transformation.py | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/litellm/llms/ovhcloud/chat/transformation.py b/litellm/llms/ovhcloud/chat/transformation.py index 342ad700e0..ae9271ddb1 100644 --- a/litellm/llms/ovhcloud/chat/transformation.py +++ b/litellm/llms/ovhcloud/chat/transformation.py @@ -8,9 +8,8 @@ More information on our website: https://endpoints.ai.cloud.ovh.net from typing import Optional, Union, List import httpx -from litellm.utils import ModelResponseStream, _get_model_info_helper +from litellm.utils import ModelResponseStream from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig -from litellm._logging import verbose_logger from litellm.llms.ovhcloud.utils import OVHCloudException from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -22,34 +21,6 @@ class OVHCloudChatConfig(OpenAIGPTConfig): def custom_llm_provider(self) -> Optional[str]: return "ovhcloud" - def get_supported_openai_params(self, model: str) -> list: - """ - Details about function calling support can be found here: - https://help.ovhcloud.com/csm/en-gb-public-cloud-ai-endpoints-function-calling?id=kb_article_view&sysparm_article=KB0071907 - """ - supports_function_calling: Optional[bool] = None - try: - model_info = _get_model_info_helper(model, custom_llm_provider="ovhcloud") - supports_function_calling = model_info.get( - "supports_function_calling", None - ) - if supports_function_calling is None: - supports_function_calling = False - except Exception as e: - verbose_logger.debug(f"Error getting supported OpenAI params: {e}") - supports_function_calling = False - - optional_params = super().get_supported_openai_params(model) - if supports_function_calling is not True: - verbose_logger.debug( - "You can see our models supporting function_calling in our catalog: https://endpoints.ai.cloud.ovh.net/catalog " - ) - optional_params.remove("tools") - optional_params.remove("tool_choice") - optional_params.remove("function_call") - optional_params.remove("response_format") - return optional_params - def get_complete_url( self, api_base: Optional[str], diff --git a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py index c2d597a28e..a1b3b31f78 100644 --- a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py +++ b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py @@ -8,6 +8,7 @@ import sys import pytest from litellm.llms.ovhcloud.utils import OVHCloudException +from litellm.utils import get_optional_params sys.path.insert( 0, os.path.abspath("../../../../..") @@ -144,6 +145,38 @@ class TestOVHCloudConfig: assert error.message == "Test error" assert error.status_code == 400 + @pytest.mark.parametrize( + "model", + [ + "Meta-Llama-3_3-70B-Instruct", + "Meta-Llama-3_1-70B-Instruct", + "Mixtral-8x7B-Instruct-v0.1", + "gpt-oss-120b", + "some-model-not-in-the-cost-map", + ], + ) + def test_tools_not_filtered_by_static_model_map(self, model): + """ + OVHCloud AI Endpoints are OpenAI-compatible; tools/tool_choice must pass + through for any model. The server is responsible for rejecting unsupported + tool calls — LiteLLM must not strip them based on a stale static catalog. + """ + + params = get_optional_params( + model=model, + custom_llm_provider="ovhcloud", + tools=[ + { + "type": "function", + "function": {"name": "x", "parameters": {}}, + } + ], + tool_choice="auto", + ) + + assert "tools" in params + assert "tool_choice" in params + def test_ovhcloud_integration(): import os From 947931858eb7ba0ce4cc0e952912ddae54b98586 Mon Sep 17 00:00:00 2001 From: BillionToken Date: Thu, 23 Apr 2026 10:36:18 +0800 Subject: [PATCH 22/48] fix(anthropic): handle tool_choice type 'none' in messages API (#24457) * fix(anthropic): handle tool_choice type 'none' in messages API * test(anthropic): add regression test for tool_choice type 'none' --------- Co-authored-by: BillionClaw <267901332+BillionClaw@users.noreply.github.com> Co-authored-by: Krrish Dholakia --- .../adapters/transformation.py | 2 ++ ...al_pass_through_adapters_transformation.py | 29 ++++++++++--------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index e5d2b4ce78..20fa4f125d 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -782,6 +782,8 @@ class LiteLLMAnthropicMessagesAdapter: return ChatCompletionToolChoiceObjectParam( type="function", function=tc_function_param ) + elif tool_choice["type"] == "none": + return "none" else: raise ValueError( "Incompatible tool choice param submitted - {}".format(tool_choice) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index e6e96868f3..670388b7c0 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -2162,16 +2162,19 @@ class TestTranslateAnthropicOutputFormatToOpenAI: assert sorted(schema["required"]) == ["age", "email", "name"] def test_invalid_output_format_returns_none(self): - assert ( - self.adapter.translate_anthropic_output_format_to_openai("invalid") is None - ) - assert ( - self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) - is None - ) - assert ( - self.adapter.translate_anthropic_output_format_to_openai( - {"type": "json_schema"} - ) - is None - ) + assert self.adapter.translate_anthropic_output_format_to_openai("invalid") is None + assert self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) is None + assert self.adapter.translate_anthropic_output_format_to_openai({"type": "json_schema"}) is None + + +def test_translate_anthropic_tool_choice_none(): + """ + Regression test for issue #24443. + + tool_choice={"type": "none"} should be translated to "none" for OpenAI format, + not raise a ValueError. + """ + adapter = LiteLLMAnthropicMessagesAdapter() + + result = adapter.translate_anthropic_tool_choice_to_openai({"type": "none"}) + assert result == "none" From 4b2fd870ca3d2df8dc4e104d10496ece5ce62e10 Mon Sep 17 00:00:00 2001 From: Rick <26716961+Bytechoreographer@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:39:24 +0800 Subject: [PATCH 23/48] fix(ui): Fetch button ignores active filters on Request Logs page (#25788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When backend filters (e.g. Key Alias) are active on the Request Logs page, the manual Fetch button called logs.refetch() which re-runs the main TanStack Query. That query does not carry backend-only filter params such as key_alias, so the button had two problems: 1. It fired a redundant API request without the active filters. 2. It did not refresh the filtered result set — backendFilteredLogs stayed frozen at the last debounce-triggered fetch. Fix: expose refetchWithFilters() from useLogFilterLogic and route the Fetch button through it when hasBackendFilters is true. This cancels any in-flight debounce and calls performSearch with the current filter state, keeping all active filters intact. Co-authored-by: Bytechoreographer Co-authored-by: Claude Sonnet 4 (1M context) --- .../src/components/view_logs/index.tsx | 10 +++++++++- .../src/components/view_logs/log_filter_logic.tsx | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 97e24cb516..8205c0b4b8 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -243,6 +243,7 @@ export default function SpendLogsTable({ allTeams, handleFilterChange, handleFilterReset: handleFilterResetFromHook, + refetchWithFilters, } = useLogFilterLogic({ logs: logsData, accessToken, @@ -363,7 +364,14 @@ export default function SpendLogsTable({ // Add this function to handle manual refresh const handleRefresh = () => { - logs.refetch(); + if (hasBackendFilters) { + // When backend filters (e.g. Key Alias) are active the main TanStack Query + // is disabled and its params do not include filter values like key_alias. + // Route through the filter-aware refetch so all active filters are preserved. + refetchWithFilters(); + } else { + logs.refetch(); + } }; const handleRowClick = (log: LogEntry) => { diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index 8c88de49d0..efe0eca3da 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -299,6 +299,20 @@ export function useLogFilterLogic({ setCurrentPage(1); }; + // Expose a filter-aware refetch so callers (e.g. the manual Fetch button) can + // refresh results while keeping all active backend filters intact. The plain + // `logs.refetch()` in the parent only re-runs the main TanStack Query, which + // does not carry key_alias or other backend-only filter params. + const refetchWithFilters = useCallback( + (page = currentPage) => { + if (hasBackendFilters && accessToken) { + debouncedSearch.cancel(); + performSearch(filters, page); + } + }, + [hasBackendFilters, accessToken, filters, currentPage, performSearch, debouncedSearch], + ); + return { filters, filteredLogs, @@ -306,5 +320,6 @@ export function useLogFilterLogic({ allTeams, handleFilterChange, handleFilterReset, + refetchWithFilters, }; } From c26e304abc0315606a0577d1f7bcb8b1d9c2def2 Mon Sep 17 00:00:00 2001 From: Rick <26716961+Bytechoreographer@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:41:34 +0800 Subject: [PATCH 24/48] fix(ui): stale filters applied after sort/page/time change on Request Logs (#25789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The useEffect that re-fetches logs on sort/page/time changes: useEffect(() => { if (hasBackendFilters && accessToken) { performSearch(filters, currentPage); } }, [sortBy, sortOrder, currentPage, startTime, endTime, isCustomDate]); intentionally omits `filters` and `hasBackendFilters` from its dep array to avoid double-fetches when a filter is applied. The side-effect is a stale-closure bug: the effect captures `filters` and `hasBackendFilters` from the render where its deps last changed, not from the render where the user selected, e.g., a Key Alias. Reproduce: set Key Alias → results appear correctly → change page or sort → the effect fires with the OLD `filters` snapshot (no key_alias) → API request is sent without the filter → table shows unfiltered data. Fix: store the latest `filters` and `hasBackendFilters` in refs that are kept in sync on every render. The sort/page/time effect reads from the refs instead of the closure so it always uses the current filter state without altering the dep array. Co-authored-by: Bytechoreographer Co-authored-by: Claude Sonnet 4 (1M context) --- .../components/view_logs/log_filter_logic.tsx | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index efe0eca3da..a538872bfd 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -71,6 +71,14 @@ export function useLogFilterLogic({ const [filters, setFilters] = useState(defaultFilters); const [backendFilteredLogs, setBackendFilteredLogs] = useState(null); const lastSearchTimestamp = useRef(0); + + // Refs that always hold the latest filters and hasBackendFilters values. + // The sort/page/time effect below intentionally omits these from its dep array + // to avoid double-fetches when a filter changes; reading from refs instead of + // the closure prevents stale-closure bugs (e.g. the effect using a snapshot of + // filters taken before the user selected Key Alias). + const filtersRef = useRef(filters); + const hasBackendFiltersRef = useRef(false); const performSearch = useCallback( async (filters: LogFilterState, page = 1) => { if (!accessToken) return; @@ -152,18 +160,25 @@ export function useLogFilterLogic({ [filters], ); + // Keep refs in sync on every render so the sort/page/time effect always reads + // the latest values without those values being in its dep array. + useEffect(() => { + filtersRef.current = filters; + hasBackendFiltersRef.current = hasBackendFilters; + }, [filters, hasBackendFilters]); + // Refetch when sort, page, or time range changes (backend filters use their own fetch, not the main query) useEffect(() => { - if (hasBackendFilters && accessToken) { + if (hasBackendFiltersRef.current && accessToken) { // Cancel any pending debounced search to prevent it from overwriting this page's results debouncedSearch.cancel(); - performSearch(filters, currentPage); + performSearch(filtersRef.current, currentPage); } - // Intentionally omitted from deps: - // - `filters` / `debouncedSearch` / `performSearch`: filter changes are handled by - // handleFilterChange → debouncedSearch; adding them here would double-fetch on filter apply. - // - `hasBackendFilters` / `accessToken`: stable across sort/page/time changes; including them - // would cause spurious re-runs when the filter state first becomes active. + // filters / hasBackendFilters are read via refs — avoids stale-closure bugs + // when sort/page/time changes after a filter (e.g. Key Alias) was set. + // debouncedSearch / performSearch: filter changes go through handleFilterChange + // → debouncedSearch; adding them here would cause double-fetches on filter apply. + // accessToken: stable across sort/page/time changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [sortBy, sortOrder, currentPage, startTime, endTime, isCustomDate]); From d26bcda52a034dd0a836395af8c739ac3913d1fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Braulio=20Vargas=20L=C3=B3pez?= <9081114+BraulioV@users.noreply.github.com> Date: Thu, 23 Apr 2026 04:55:00 +0200 Subject: [PATCH 25/48] refactor: replace substring check with startswith in is_model_gpt_5_model (#25793) The original check `"gpt-5-chat" not in model` already correctly classifies all current gpt-5 variants (including gpt-5.3-chat and gpt-5.1-chat, which do NOT contain the substring "gpt-5-chat"). This change replaces it with an explicit `startswith("gpt-5-chat")` prefix test on the provider-prefix-stripped model name. The new check is functionally equivalent for all existing model names but makes the classification boundary unambiguous and forward-safe: future model names that might contain "gpt-5-chat" as an interior substring won't accidentally be excluded from the GPT-5 reasoning path. Also moves the new regression test from tests/ root to tests/test_litellm/llms/openai/ so it is included in `make test-unit`. --- .../llms/azure/chat/gpt_5_transformation.py | 17 +- .../llms/openai/chat/gpt_5_transformation.py | 18 ++- .../llms/openai/test_is_model_gpt_5_model.py | 151 ++++++++++++++++++ 3 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py diff --git a/litellm/llms/azure/chat/gpt_5_transformation.py b/litellm/llms/azure/chat/gpt_5_transformation.py index bc7483bf64..e94f50380c 100644 --- a/litellm/llms/azure/chat/gpt_5_transformation.py +++ b/litellm/llms/azure/chat/gpt_5_transformation.py @@ -40,9 +40,22 @@ class AzureOpenAIGPT5Config(AzureOpenAIConfig, OpenAIGPT5Config): Accepts both explicit gpt-5 model names and the ``gpt5_series/`` prefix used for manual routing. """ - # gpt-5-chat* is a chat model and shouldn't go through GPT-5 reasoning restrictions. + # The gpt-5-chat* family (gpt-5-chat, gpt-5-chat-latest, gpt-5-chat-2025-08-07, + # …) are regular chat models: they support temperature and tool_choice but NOT + # reasoning_effort. They must NOT be routed through the GPT-5 reasoning path. + # + # Versioned chat models such as gpt-5.3-chat and gpt-5.1-chat ARE reasoning + # models and must stay on the GPT-5 path. The distinguishing feature is that + # the gpt-5-chat family has a literal "-chat" immediately after "gpt-5" + # (i.e. "gpt-5-chat…"), while versioned chat models interpose a minor version + # number (i.e. "gpt-5.-chat"). + # + # Using a startswith("gpt-5-chat") prefix check on the normalized name (rather + # than a substring check) makes this boundary explicit and avoids any ambiguity + # if future model names coincidentally contain "gpt-5-chat" as an interior run. + _normalized = model.split("/")[-1] # strip provider prefix, e.g. "azure/" return ( - "gpt-5" in model and "gpt-5-chat" not in model + "gpt-5" in model and not _normalized.startswith("gpt-5-chat") ) or "gpt5_series" in model def get_supported_openai_params(self, model: str) -> List[str]: diff --git a/litellm/llms/openai/chat/gpt_5_transformation.py b/litellm/llms/openai/chat/gpt_5_transformation.py index fc48704cd1..34941a545e 100644 --- a/litellm/llms/openai/chat/gpt_5_transformation.py +++ b/litellm/llms/openai/chat/gpt_5_transformation.py @@ -53,9 +53,21 @@ class OpenAIGPT5Config(OpenAIGPTConfig): @classmethod def is_model_gpt_5_model(cls, model: str) -> bool: - # gpt-5-chat* behaves like a regular chat model (supports temperature, etc.) - # Don't route it through GPT-5 reasoning-specific parameter restrictions. - return "gpt-5" in model and "gpt-5-chat" not in model + # The gpt-5-chat* family (gpt-5-chat, gpt-5-chat-latest, gpt-5-chat-2025-08-07, + # …) are regular chat models: they support temperature and tool_choice but NOT + # reasoning_effort. They must NOT be routed through the GPT-5 reasoning path. + # + # Versioned chat models such as gpt-5.3-chat and gpt-5.1-chat ARE reasoning + # models and must stay on the GPT-5 path. The distinguishing feature is that + # the gpt-5-chat family has a literal "-chat" immediately after "gpt-5" + # (i.e. "gpt-5-chat…"), while versioned chat models interpose a minor version + # number (i.e. "gpt-5.-chat"). + # + # Using a startswith("gpt-5-chat") prefix check on the normalized name (rather + # than a substring check) makes this boundary explicit and avoids any ambiguity + # if future model names coincidentally contain "gpt-5-chat" as an interior run. + _normalized = model.split("/")[-1] # strip provider prefix, e.g. "openai/" + return "gpt-5" in model and not _normalized.startswith("gpt-5-chat") @classmethod def is_model_gpt_5_search_model(cls, model: str) -> bool: diff --git a/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py new file mode 100644 index 0000000000..1d26287295 --- /dev/null +++ b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py @@ -0,0 +1,151 @@ +""" +Regression tests for is_model_gpt_5_model() in both OpenAI and Azure GPT-5 config +classes. + +Background +---------- +In v1.82.3 a substring check was introduced:: + + return "gpt-5" in model and "gpt-5-chat" not in model + +This inadvertently treated versioned chat models like ``gpt-5.3-chat`` and +``gpt-5.1-chat`` as *non*-GPT-5 models, because the string ``"gpt-5-chat"`` is +a substring of ``"gpt-5.3-chat"``. Those models were then routed through the +regular Azure chat path which does not suppress ``parallel_tool_calls``, causing +Azure to return ``finish_reason="stop"`` together with tool_calls and breaking +n8n AI-agent workflows. + +There are two distinct families: + +* **gpt-5-chat family** (``gpt-5-chat``, ``gpt-5-chat-latest``, + ``gpt-5-chat-2025-08-07``, …) — regular chat models that support ``temperature`` + and ``tool_choice`` but NOT ``reasoning_effort``. Must NOT be on the GPT-5 + reasoning path. + +* **Versioned chat models** (``gpt-5.1-chat``, ``gpt-5.2-chat``, + ``gpt-5.3-chat``, …) — ARE GPT-5 reasoning models and must stay on the GPT-5 + path. + +The fix uses a prefix check (``startswith("gpt-5-chat")``) on the normalised model +name instead of a substring check, which correctly distinguishes the two families. +""" + +import pytest + +from litellm.llms.openai.chat.gpt_5_transformation import OpenAIGPT5Config +from litellm.llms.azure.chat.gpt_5_transformation import AzureOpenAIGPT5Config + +# --------------------------------------------------------------------------- +# Parametrized fixtures +# --------------------------------------------------------------------------- + +# Models that MUST be classified as GPT-5 (routed through GPT-5 reasoning path) +GPT5_MODELS = [ + "gpt-5", + "gpt-5.1", + "gpt-5.2", + "gpt-5.3", + "gpt-5.4", + "gpt-5.1-chat", # versioned chat — THE KEY REGRESSION CASE + "gpt-5.2-chat", # versioned chat — also a regression case + "gpt-5.3-chat", # versioned chat — THE KEY REGRESSION CASE + "gpt-5.2-chat-latest", # versioned chat with date suffix + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5.1-mini", + "gpt-5-nano", + "gpt-5-mini", + "gpt-5-codex", +] + +# Models that must NOT be classified as GPT-5 (regular chat path) +NON_GPT5_MODELS = [ + "gpt-5-chat", # gpt-5-chat family — regular chat path + "gpt-5-chat-latest", # gpt-5-chat family with alias suffix + "gpt-5-chat-2025-08-07", # gpt-5-chat family with date suffix + "gpt-4", + "gpt-4o", + "gpt-4-turbo", + "gpt-3.5-turbo", + "o1", + "o3", + "o3-mini", +] + + +# --------------------------------------------------------------------------- +# OpenAIGPT5Config +# --------------------------------------------------------------------------- + + +class TestOpenAIGPT5ConfigIsModelGpt5Model: + + @pytest.mark.parametrize("model", GPT5_MODELS) + def test_gpt5_models_are_classified_as_gpt5(self, model: str): + assert OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected '{model}' to be classified as a GPT-5 model" + + @pytest.mark.parametrize("model", NON_GPT5_MODELS) + def test_non_gpt5_models_are_not_classified_as_gpt5(self, model: str): + assert not OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected '{model}' NOT to be classified as a GPT-5 model" + + def test_versioned_chat_models_are_not_excluded_by_prefix(self): + """Core regression guard: gpt-5-chat prefix must not match versioned models.""" + versioned_chat_models = ["gpt-5.1-chat", "gpt-5.2-chat", "gpt-5.3-chat"] + for model in versioned_chat_models: + assert OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Regression: '{model}' was incorrectly excluded from GPT-5 path" + + def test_gpt5_chat_family_is_excluded(self): + """gpt-5-chat family should stay on the regular chat path.""" + for model in ["gpt-5-chat", "gpt-5-chat-latest", "gpt-5-chat-2025-08-07"]: + assert not OpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected '{model}' (gpt-5-chat family) NOT to be on the GPT-5 path" + + +# --------------------------------------------------------------------------- +# AzureOpenAIGPT5Config +# --------------------------------------------------------------------------- + + +class TestAzureOpenAIGPT5ConfigIsModelGpt5Model: + + @pytest.mark.parametrize("model", GPT5_MODELS) + def test_gpt5_models_are_classified_as_gpt5(self, model: str): + assert AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected Azure '{model}' to be classified as a GPT-5 model" + + @pytest.mark.parametrize("model", NON_GPT5_MODELS) + def test_non_gpt5_models_are_not_classified_as_gpt5(self, model: str): + assert not AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected Azure '{model}' NOT to be classified as a GPT-5 model" + + def test_versioned_chat_models_are_not_excluded_by_prefix(self): + """Core regression guard: gpt-5-chat prefix must not match versioned models.""" + versioned_chat_models = ["gpt-5.1-chat", "gpt-5.2-chat", "gpt-5.3-chat"] + for model in versioned_chat_models: + assert AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Regression: Azure '{model}' was incorrectly excluded from GPT-5 path" + + def test_gpt5_chat_family_is_excluded(self): + """gpt-5-chat family should stay on the regular chat path.""" + for model in ["gpt-5-chat", "gpt-5-chat-latest", "gpt-5-chat-2025-08-07"]: + assert not AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Expected Azure '{model}' (gpt-5-chat family) NOT to be on the GPT-5 path" + + def test_gpt5_series_routing_prefix_is_always_classified_as_gpt5(self): + """Models using the gpt5_series/ manual-routing prefix must always match.""" + series_models = ["gpt5_series/my-deployment", "gpt5_series/prod"] + for model in series_models: + assert AzureOpenAIGPT5Config.is_model_gpt_5_model( + model + ), f"Azure '{model}' with gpt5_series/ prefix should be classified as GPT-5" From fcf917df6d8c4eb790acfa61963c3a448e56093c Mon Sep 17 00:00:00 2001 From: "Zark ." <87560774+Alpha-Zark@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:03:46 +0800 Subject: [PATCH 26/48] Feat(dashscope): add image generation support for qwen-image-2.0 and qwen-image-2.0-pro (#25672) * feat: add dashscope/qwen-image-2.0 and qwen-image-2.0-pro to model cost map * feat: implement DashScope image generation transformation class * feat: register DashScope in ProviderConfigManager for image generation * feat: add DashScope to image generation provider routing * feat: auto-route qwen-image /chat/completions requests to /images/generations * test: add unit tests for DashScope image generation (22 cases) * refactor: remove proxy-layer qwen-image auto-routing * feat: auto-redirect image_generation models in acompletion() * test: add acompletion auto-redirect test for image_generation models * fix: remove unused Union import in DashScope transformation * fix: scope acompletion redirect to dashscope and narrow exception handler * fix: move get_str_from_messages to module-level import and forward n param to aimage_generation * refactor: remove acompletion image_generation auto-redirect for dashscope * test: remove acompletion auto-redirect test for dashscope image models --------- Co-authored-by: zark.lin --- litellm/images/main.py | 1 + .../dashscope/image_generation/__init__.py | 9 + .../image_generation/transformation.py | 187 ++++++++++ ...odel_prices_and_context_window_backup.json | 16 + litellm/proxy/proxy_server.py | 1 + litellm/utils.py | 6 + model_prices_and_context_window.json | 16 + .../test_dashscope_image_generation.py | 328 ++++++++++++++++++ 8 files changed, 564 insertions(+) create mode 100644 litellm/llms/dashscope/image_generation/__init__.py create mode 100644 litellm/llms/dashscope/image_generation/transformation.py create mode 100644 tests/test_litellm/test_dashscope_image_generation.py diff --git a/litellm/images/main.py b/litellm/images/main.py index 0d3b2e9729..d95b7287d2 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -410,6 +410,7 @@ def image_generation( # noqa: PLR0915 litellm.LlmProviders.RUNWAYML, litellm.LlmProviders.VERTEX_AI, litellm.LlmProviders.OPENROUTER, + litellm.LlmProviders.DASHSCOPE, ): if image_generation_config is None: raise ValueError( diff --git a/litellm/llms/dashscope/image_generation/__init__.py b/litellm/llms/dashscope/image_generation/__init__.py new file mode 100644 index 0000000000..9fdb46586e --- /dev/null +++ b/litellm/llms/dashscope/image_generation/__init__.py @@ -0,0 +1,9 @@ +from litellm.llms.base_llm.image_generation.transformation import BaseImageGenerationConfig + +from .transformation import DashScopeImageGenerationConfig + +__all__ = ["DashScopeImageGenerationConfig"] + + +def get_dashscope_image_generation_config(model: str) -> BaseImageGenerationConfig: + return DashScopeImageGenerationConfig() diff --git a/litellm/llms/dashscope/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py new file mode 100644 index 0000000000..feac811df8 --- /dev/null +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -0,0 +1,187 @@ +""" +DashScope Image Generation Configuration + +Handles transformation between OpenAI-compatible format and DashScope multimodal-generation API. + +API endpoint: POST https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation + +Request format: +{ + "model": "qwen-image-2.0-pro", + "input": { + "messages": [{"role": "user", "content": [{"text": ""}]}] + }, + "parameters": {"size": "1024*1024", ...} +} + +Response format: +{ + "output": { + "choices": [{"message": {"content": [{"image": ""}]}}] + }, + "usage": {"input_tokens": 0, "output_tokens": 0, "width": 1024, "height": 1024, "image_count": 1} +} +""" + +from typing import TYPE_CHECKING, Any, List, Optional + +import httpx + +from litellm.llms.base_llm.image_generation.transformation import BaseImageGenerationConfig +from litellm.secret_managers.main import get_secret_str +from litellm.types.llms.openai import AllMessageValues, OpenAIImageGenerationOptionalParams +from litellm.types.utils import ImageObject, ImageResponse + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + + LiteLLMLoggingObj = _LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + +DEFAULT_API_BASE = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" + +# Maps OpenAI size strings (WxH) to DashScope size strings (W*H) +OPENAI_TO_DASHSCOPE_SIZE: dict = { + "256x256": "256*256", + "512x512": "512*512", + "1024x1024": "1024*1024", + "1792x1024": "1792*1024", + "1024x1792": "1024*1792", + "2048x2048": "2048*2048", +} + + +class DashScopeImageGenerationConfig(BaseImageGenerationConfig): + """ + Configuration for DashScope image generation (qwen-image-2.0, qwen-image-2.0-pro). + """ + + def get_supported_openai_params( + self, model: str + ) -> List[OpenAIImageGenerationOptionalParams]: + return ["n", "size"] + + def map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + drop_params: bool, + ) -> dict: + supported_params = self.get_supported_openai_params(model) + mapped: dict = {} + for k, v in non_default_params.items(): + if k in optional_params: + continue + if k not in supported_params: + continue + if k == "size": + # Convert "WxH" → "W*H" + mapped["size"] = OPENAI_TO_DASHSCOPE_SIZE.get(v, v.replace("x", "*")) + elif k == "n": + mapped["image_count"] = v + return mapped + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + return ( + api_base + or get_secret_str("DASHSCOPE_API_BASE_IMAGE") + or DEFAULT_API_BASE + ) + + def validate_environment( + self, + headers: dict, + model: str, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> dict: + final_api_key = api_key or get_secret_str("DASHSCOPE_API_KEY") + if not final_api_key: + raise ValueError("DASHSCOPE_API_KEY is not set") + headers["Authorization"] = f"Bearer {final_api_key}" + headers["Content-Type"] = "application/json" + return headers + + def transform_image_generation_request( + self, + model: str, + prompt: str, + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> dict: + """ + Transform OpenAI-style image generation request to DashScope multimodal-generation format. + """ + parameters: dict = {} + for k, v in optional_params.items(): + parameters[k] = v + + return { + "model": model, + "input": { + "messages": [ + { + "role": "user", + "content": [{"text": prompt}], + } + ] + }, + "parameters": parameters, + } + + def transform_image_generation_response( + self, + model: str, + raw_response: httpx.Response, + model_response: ImageResponse, + logging_obj: LiteLLMLoggingObj, + request_data: dict, + optional_params: dict, + litellm_params: dict, + encoding: Any, + api_key: Optional[str] = None, + json_mode: Optional[bool] = None, + ) -> ImageResponse: + """ + Transform DashScope response to litellm ImageResponse. + + DashScope response: output.choices[0].message.content[0].image + OpenAI response: data[0].url + """ + try: + response_data = raw_response.json() + except Exception as e: + raise self.get_error_class( + error_message=f"Failed to parse DashScope image generation response: {e}", + status_code=raw_response.status_code, + headers=raw_response.headers, + ) + + if not model_response.data: + model_response.data = [] + + choices = response_data.get("output", {}).get("choices", []) + for choice in choices: + content_list = ( + choice.get("message", {}).get("content", []) + ) + for content_item in content_list: + image_url = content_item.get("image") + if image_url: + model_response.data.append(ImageObject(url=image_url)) + + return model_response diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 05b59d45f9..5f6f433167 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -10383,6 +10383,22 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "dashscope/qwen-image-2.0": { + "litellm_provider": "dashscope", + "mode": "image_generation", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "dashscope/qwen-image-2.0-pro": { + "litellm_provider": "dashscope", + "mode": "image_generation", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, "databricks/databricks-bge-large-en": { "input_cost_per_token": 1.0003e-07, "input_dbu_cost_per_token": 1.429e-06, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 0efa1d452d..ebe38705d9 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -7246,6 +7246,7 @@ async def chat_completion( # noqa: PLR0915 and user_api_key_dict.agent_id is not None ): data["metadata"]["agent_id"] = user_api_key_dict.agent_id + base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data) try: result = await base_llm_response_processor.base_process_llm_request( diff --git a/litellm/utils.py b/litellm/utils.py index 7a9f62afa0..e1ad1db63e 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8952,6 +8952,12 @@ class ProviderConfigManager: ) return get_openrouter_image_generation_config(model) + elif LlmProviders.DASHSCOPE == provider: + from litellm.llms.dashscope.image_generation import ( + get_dashscope_image_generation_config, + ) + + return get_dashscope_image_generation_config(model) return None @staticmethod diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 8a28235f98..98723b80aa 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -10397,6 +10397,22 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "dashscope/qwen-image-2.0": { + "litellm_provider": "dashscope", + "mode": "image_generation", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "dashscope/qwen-image-2.0-pro": { + "litellm_provider": "dashscope", + "mode": "image_generation", + "source": "https://www.alibabacloud.com/help/en/model-studio/models", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, "databricks/databricks-bge-large-en": { "input_cost_per_token": 1.0003e-07, "input_dbu_cost_per_token": 1.429e-06, diff --git a/tests/test_litellm/test_dashscope_image_generation.py b/tests/test_litellm/test_dashscope_image_generation.py new file mode 100644 index 0000000000..b7680a7e7f --- /dev/null +++ b/tests/test_litellm/test_dashscope_image_generation.py @@ -0,0 +1,328 @@ +""" +Unit tests for DashScope image generation support (qwen-image-2.0, qwen-image-2.0-pro). + +Run in docker: pytest tests/test_litellm/test_dashscope_image_generation.py -v +""" + +import json +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +import litellm +from litellm.llms.dashscope.image_generation.transformation import ( + DashScopeImageGenerationConfig, + DEFAULT_API_BASE, +) +from litellm.types.utils import ImageObject, ImageResponse +from litellm.utils import get_llm_provider + + +# --------------------------------------------------------------------------- +# 1. Provider detection +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "model_string", + [ + "dashscope/qwen-image-2.0", + "dashscope/qwen-image-2.0-pro", + ], +) +def test_get_llm_provider_returns_dashscope(model_string: str): + model, provider, _, _ = get_llm_provider(model_string) + assert provider == "dashscope", f"Expected 'dashscope', got '{provider}'" + assert "qwen-image" in model + + +# --------------------------------------------------------------------------- +# 2. Model info: mode == "image_generation" +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "model_string, custom_provider", + [ + ("dashscope/qwen-image-2.0", "dashscope"), + ("dashscope/qwen-image-2.0-pro", "dashscope"), + ], +) +def test_get_model_info_mode_is_image_generation(model_string: str, custom_provider: str): + import os + + prev_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP") + prev_model_cost = litellm.model_cost + try: + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + info = litellm.get_model_info(model=model_string, custom_llm_provider=custom_provider) + assert info["mode"] == "image_generation", ( + f"Expected mode='image_generation', got '{info['mode']}'" + ) + finally: + if prev_env is None: + os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None) + else: + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = prev_env + litellm.model_cost = prev_model_cost + + +# --------------------------------------------------------------------------- +# 3. Request transformation +# --------------------------------------------------------------------------- + + +class TestDashScopeImageGenerationConfig: + def setup_method(self): + self.cfg = DashScopeImageGenerationConfig() + + def test_get_complete_url_default(self): + url = self.cfg.get_complete_url(None, None, "qwen-image-2.0", {}, {}) + assert url == DEFAULT_API_BASE + + def test_get_complete_url_custom(self): + custom = "https://custom.endpoint/generate" + url = self.cfg.get_complete_url(custom, None, "qwen-image-2.0", {}, {}) + assert url == custom + + def test_validate_environment_sets_auth_header(self): + headers = self.cfg.validate_environment( + headers={}, + model="qwen-image-2.0", + messages=[], + optional_params={}, + litellm_params={}, + api_key="sk-test-key", + ) + assert headers["Authorization"] == "Bearer sk-test-key" + assert headers["Content-Type"] == "application/json" + + def test_validate_environment_raises_without_key(self): + with patch("litellm.llms.dashscope.image_generation.transformation.get_secret_str", return_value=None): + with pytest.raises(ValueError, match="DASHSCOPE_API_KEY"): + self.cfg.validate_environment( + headers={}, + model="qwen-image-2.0", + messages=[], + optional_params={}, + litellm_params={}, + api_key=None, + ) + + def test_transform_request_structure(self): + req = self.cfg.transform_image_generation_request( + model="qwen-image-2.0", + prompt="a puppy on green grass", + optional_params={"size": "1024*1024"}, + litellm_params={}, + headers={}, + ) + assert req["model"] == "qwen-image-2.0" + messages = req["input"]["messages"] + assert len(messages) == 1 + assert messages[0]["role"] == "user" + assert messages[0]["content"][0]["text"] == "a puppy on green grass" + assert req["parameters"]["size"] == "1024*1024" + + def test_transform_request_empty_params(self): + req = self.cfg.transform_image_generation_request( + model="qwen-image-2.0-pro", + prompt="sunset over the ocean", + optional_params={}, + litellm_params={}, + headers={}, + ) + assert req["parameters"] == {} + + # --------------------------------------------------------------------------- + # 4. Response transformation + # --------------------------------------------------------------------------- + + def _make_mock_response(self, image_url: str) -> httpx.Response: + body = { + "status_code": 200, + "request_id": "test-request-id", + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [{"image": image_url}], + }, + } + ] + }, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "width": 1024, + "height": 1024, + "image_count": 1, + }, + } + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.headers = {} + mock_resp.json.return_value = body + return mock_resp + + def test_transform_response_extracts_url(self): + image_url = "https://example.oss.aliyuncs.com/generated/test.png" + mock_resp = self._make_mock_response(image_url) + model_response = ImageResponse() + result = self.cfg.transform_image_generation_response( + model="qwen-image-2.0", + raw_response=mock_resp, + model_response=model_response, + logging_obj=MagicMock(), + request_data={}, + optional_params={}, + litellm_params={}, + encoding=None, + ) + assert result.data is not None + assert len(result.data) == 1 + assert result.data[0].url == image_url + + def test_transform_response_multiple_images(self): + body = { + "output": { + "choices": [ + {"finish_reason": "stop", "message": {"role": "assistant", "content": [{"image": "https://example.com/img1.png"}]}}, + {"finish_reason": "stop", "message": {"role": "assistant", "content": [{"image": "https://example.com/img2.png"}]}}, + ] + }, + "usage": {}, + } + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.headers = {} + mock_resp.json.return_value = body + + model_response = ImageResponse() + result = self.cfg.transform_image_generation_response( + model="qwen-image-2.0", + raw_response=mock_resp, + model_response=model_response, + logging_obj=MagicMock(), + request_data={}, + optional_params={}, + litellm_params={}, + encoding=None, + ) + assert len(result.data) == 2 + assert result.data[0].url == "https://example.com/img1.png" + assert result.data[1].url == "https://example.com/img2.png" + + # --------------------------------------------------------------------------- + # 5. OpenAI → DashScope parameter mapping + # --------------------------------------------------------------------------- + + def test_map_openai_params_size_conversion(self): + mapped = self.cfg.map_openai_params( + non_default_params={"size": "1024x1024"}, + optional_params={}, + model="qwen-image-2.0", + drop_params=False, + ) + assert mapped["size"] == "1024*1024" + + def test_map_openai_params_n_to_image_count(self): + mapped = self.cfg.map_openai_params( + non_default_params={"n": 2}, + optional_params={}, + model="qwen-image-2.0", + drop_params=False, + ) + assert mapped["image_count"] == 2 + + def test_map_openai_params_unknown_size_uses_asterisk(self): + mapped = self.cfg.map_openai_params( + non_default_params={"size": "768x768"}, + optional_params={}, + model="qwen-image-2.0", + drop_params=False, + ) + assert mapped["size"] == "768*768" + + @pytest.mark.parametrize( + "openai_size, expected", + [ + ("256x256", "256*256"), + ("512x512", "512*512"), + ("1024x1024", "1024*1024"), + ("1792x1024", "1792*1024"), + ("1024x1792", "1024*1792"), + ("2048x2048", "2048*2048"), + ], + ) + def test_map_openai_params_size_table(self, openai_size: str, expected: str): + mapped = self.cfg.map_openai_params( + non_default_params={"size": openai_size}, + optional_params={}, + model="qwen-image-2.0", + drop_params=False, + ) + assert mapped["size"] == expected + + +# --------------------------------------------------------------------------- +# 6. End-to-end flow via litellm.image_generation (HTTP mocked) +# --------------------------------------------------------------------------- + + +def test_litellm_image_generation_dashscope_end_to_end(): + mock_response_body = { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [ + {"image": "https://dashscope-result.oss.aliyuncs.com/test.png"} + ], + }, + } + ] + }, + "usage": {"input_tokens": 0, "output_tokens": 0, "width": 1024, "height": 1024, "image_count": 1}, + } + + with patch( + "litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post" + ) as mock_post: + mock_http_response = MagicMock() + mock_http_response.json.return_value = mock_response_body + mock_http_response.status_code = 200 + mock_http_response.headers = {} + mock_post.return_value = mock_http_response + + response = litellm.image_generation( + model="dashscope/qwen-image-2.0", + prompt="a puppy playing on green grass", + api_key="sk-test-key", + size="1024x1024", + ) + + assert response is not None + assert response.data is not None + assert len(response.data) == 1 + assert response.data[0].url == "https://dashscope-result.oss.aliyuncs.com/test.png" + + # Verify the HTTP call was made to the DashScope endpoint + call_args = mock_post.call_args + called_url = call_args[0][0] if call_args[0] else call_args.kwargs.get("url", "") + assert "dashscope" in called_url or "aliyuncs" in called_url + + # Verify request body contains DashScope format + call_kwargs = call_args[1] if call_args[1] else {} + if "json" in call_kwargs: + body = call_kwargs["json"] + assert "input" in body + assert "messages" in body["input"] + From bea872a0345537d02414ba99a32e33077c8cf58d Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 21:08:15 -0700 Subject: [PATCH 27/48] [Infra] CCI: remove dead steps accumulated across jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clean out copy-paste debug and workaround lines that serve no purpose: - `pwd && ls` echoes at the top of 30 "Run tests" steps (CCI already logs working_directory on every step). - "Show git commit hash" in local_testing_part1/part2 and langfuse_logging_unit_tests (CCI shows the SHA in every job header). - "Verify Docker is available" stubs in 6 machine-executor jobs (machine executors always have Docker). - `sudo systemctl restart docker` in proxy_store_model_in_db_tests (one-off workaround; not used anywhere else). - Duplicated Black formatting step in local_testing_part1 and local_testing_part2 — Black runs in the lint job, no reason to run it again here. - Second back-to-back `helm test litellm --logs` invocation in helm_chart_testing (one call is enough). No behavior change — these are all log-only or no-op steps. --- .circleci/config.yml | 117 ------------------------------------------- 1 file changed, 117 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 3a2d6348ba..cd2876dffa 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -172,11 +172,6 @@ jobs: steps: - checkout - setup_google_dns - - run: - name: Show git commit hash - command: | - echo "Git commit hash: $CIRCLE_SHA1" - - restore_cache: keys: - v1-uv-cache-{{ checksum "uv.lock" }} @@ -197,13 +192,6 @@ jobs: chmod +x docker/entrypoint.sh ./docker/entrypoint.sh set -e - - run: - name: Black Formatting - command: | - cd litellm - uv run --no-sync python -m black . - cd .. - # Run pytest and generate JUnit XML report - run: name: Run tests (Part 1 - A-M) @@ -252,11 +240,6 @@ jobs: steps: - checkout - setup_google_dns - - run: - name: Show git commit hash - command: | - echo "Git commit hash: $CIRCLE_SHA1" - - restore_cache: keys: - v1-uv-cache-{{ checksum "uv.lock" }} @@ -277,13 +260,6 @@ jobs: chmod +x docker/entrypoint.sh ./docker/entrypoint.sh set -e - - run: - name: Black Formatting - command: | - cd litellm - uv run --no-sync python -m black . - cd .. - # Run pytest and generate JUnit XML report - run: name: Run tests (Part 2 - N-Z) @@ -333,11 +309,6 @@ jobs: steps: - checkout - setup_google_dns - - run: - name: Show git commit hash - command: | - echo "Git commit hash: $CIRCLE_SHA1" - - restore_cache: keys: - v1-uv-cache-{{ checksum "uv.lock" }} @@ -363,8 +334,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -v tests/local_testing -x --junitxml=test-results/junit.xml --durations=5 -k "langfuse" no_output_timeout: 15m # Store test results @@ -413,8 +382,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -v tests/proxy_admin_ui_tests -x --junitxml=test-results/junit.xml --durations=5 -n 2 no_output_timeout: 15m @@ -451,8 +418,6 @@ jobs: - run: name: Run tests command: | - pwd - ls TEST_FILES=$(circleci tests glob "tests/local_testing/**/test_*.py") echo "$TEST_FILES" | circleci tests run \ @@ -499,8 +464,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -v tests/router_unit_tests -x --junitxml=test-results/junit.xml --durations=5 -n 4 no_output_timeout: 15m # Store test results @@ -528,8 +491,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest tests/local_testing/ -v -k "assistants" -x --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m # Store test results @@ -563,8 +524,6 @@ jobs: - run: name: Run tests command: | - pwd - ls # Add --timeout to kill hanging tests after 120s (2 min) # Add --durations=20 to show 20 slowest tests for debugging # Subdirectories with dedicated jobs (maintain this list as new jobs are added) @@ -601,8 +560,6 @@ jobs: - run: name: Run realtime tests command: | - pwd - ls # Add --timeout to kill hanging tests after 120s (2 min) # Add --durations=20 to show 20 slowest tests for debugging uv run --no-sync python -m pytest -vv tests/llm_translation/realtime --cov=litellm --cov-report=xml -v --junitxml=test-results/junit.xml --durations=20 -n 4 --timeout=120 --timeout_method=thread @@ -641,8 +598,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/mcp_tests --cov=litellm --cov-report=xml -x -s -v --junitxml=test-results/junit.xml --durations=5 -n 2 no_output_timeout: 15m - run: @@ -679,8 +634,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/agent_tests --ignore=tests/agent_tests/local_only_agent_tests --cov=litellm --cov-report=xml -x -s -v --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m - run: @@ -717,8 +670,6 @@ jobs: - run: name: Run tests command: | - pwd - ls LITELLM_LOG=WARNING uv run --no-sync python -m pytest tests/guardrails_tests -vv --cov=litellm --cov-report=xml --junitxml=test-results/junit.xml --durations=5 -n 2 --timeout=120 --timeout_method=thread no_output_timeout: 15m - run: @@ -756,8 +707,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/unified_google_tests --cov=litellm --cov-report=xml -x -s -v --junitxml=test-results/junit.xml --durations=5 --retries 3 --retry-delay 5 no_output_timeout: 15m - run: @@ -803,8 +752,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -v tests/llm_responses_api_testing -x --junitxml=test-results/junit.xml --durations=5 -n 8 no_output_timeout: 15m @@ -831,8 +778,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/ocr_tests --cov=litellm --cov-report=xml -x -v --junitxml=test-results/junit.xml --durations=5 -n 4 no_output_timeout: 15m - run: @@ -869,8 +814,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/search_tests --cov=litellm --cov-report=xml -x -v --junitxml=test-results/junit.xml --durations=5 -n 4 no_output_timeout: 15m - run: @@ -947,8 +890,6 @@ jobs: - run: name: Run enterprise tests command: | - pwd - ls uv run --no-sync python -m prisma generate uv run --no-sync python -m pytest -v tests/enterprise -x --junitxml=test-results/junit-enterprise.xml --durations=10 -n 4 no_output_timeout: 15m @@ -975,8 +916,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/batches_tests --cov=litellm --cov-report=xml -x -s -v --junitxml=test-results/junit.xml --durations=5 -n 2 no_output_timeout: 15m - run: @@ -1013,8 +952,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/litellm_utils_tests --cov=litellm --cov-report=xml -x -s -v --junitxml=test-results/junit.xml --durations=5 -n 2 no_output_timeout: 15m - run: @@ -1052,8 +989,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/pass_through_unit_tests --cov=litellm --cov-report=xml -x -v --junitxml=test-results/junit.xml --durations=5 -n 4 no_output_timeout: 15m - run: @@ -1091,8 +1026,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -v tests/image_gen_tests -n 4 -x --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m # Store test results @@ -1119,8 +1052,6 @@ jobs: - run: name: Run tests command: | - pwd - ls LITELLM_LOG=WARNING uv run --no-sync python -m pytest tests/logging_callback_tests -vv --cov=litellm --cov-report=xml -n 4 --junitxml=test-results/junit.xml --durations=5 --timeout=120 --timeout_method=thread no_output_timeout: 15m - run: @@ -1157,8 +1088,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/audio_tests --cov=litellm --cov-report=xml -x -s -v --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m - run: @@ -1245,8 +1174,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/local_testing/test_basic_python_version.py -k "not v2_resolver" installing_litellm_on_python_3_13: @@ -1269,8 +1196,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -v tests/local_testing/test_basic_python_version.py -k "not v2_resolver" installing_litellm_on_python_v2_migration_resolver: @@ -1377,7 +1302,6 @@ jobs: # Run the helm tests helm test litellm --logs - helm test litellm --logs # Cleanup - run: @@ -1535,8 +1459,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -s -v tests/*.py -x --junitxml=test-results/junit.xml -n 4 --durations=5 --ignore=tests/otel_tests --ignore=tests/spend_tracking_tests --ignore=tests/pass_through_tests --ignore=tests/proxy_admin_ui_tests --ignore=tests/load_tests --ignore=tests/llm_translation --ignore=tests/llm_responses_api_testing --ignore=tests/mcp_tests --ignore=tests/guardrails_tests --ignore=tests/image_gen_tests --ignore=tests/pass_through_unit_tests no_output_timeout: 15m @@ -1551,10 +1473,6 @@ jobs: steps: - checkout - setup_google_dns - - run: - name: Verify Docker is available - command: | - docker version - install_uv - run: name: Install Dependencies @@ -1616,8 +1534,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -s -vv tests/openai_endpoints_tests --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m @@ -1632,10 +1548,6 @@ jobs: steps: - checkout - setup_google_dns - - run: - name: Verify Docker is available - command: | - docker version - install_uv - run: name: Install Dependencies @@ -1691,8 +1603,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -v tests/otel_tests -x --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m # Clean up first container @@ -1750,10 +1660,6 @@ jobs: steps: - checkout - setup_google_dns - - run: - name: Verify Docker is available - command: | - docker version - install_uv - run: name: Install Dependencies @@ -1805,8 +1711,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/spend_tracking_tests -x --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m # Clean up first container @@ -1824,10 +1728,6 @@ jobs: steps: - checkout - setup_google_dns - - run: - name: Verify Docker is available - command: | - docker version - install_uv - run: name: Install Dependencies @@ -1898,8 +1798,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/multi_instance_e2e_tests -x --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m # Clean up first container @@ -1915,11 +1813,6 @@ jobs: steps: - checkout - setup_google_dns - - run: - name: Verify Docker is available - command: | - docker version - sudo systemctl restart docker - install_uv - run: name: Install Dependencies @@ -1961,8 +1854,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -vv tests/store_model_in_db_tests -x --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m - run: @@ -2158,8 +2049,6 @@ jobs: - run: name: Run tests command: | - pwd - ls uv run --no-sync python -m pytest -v tests/pass_through_tests/ -x --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m @@ -2175,10 +2064,6 @@ jobs: steps: - checkout - setup_google_dns - - run: - name: Verify Docker is available - command: | - docker version - install_uv - run: name: Install Dependencies @@ -2223,8 +2108,6 @@ jobs: command: | export LITELLM_PROXY_URL="http://localhost:4000" export LITELLM_API_KEY="sk-1234" - pwd - ls uv run --no-sync python -m pytest -vv tests/proxy_e2e_anthropic_messages_tests/ -x -s --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m From 44362cb167e562575c31504980735ad8180e1ca0 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 21:24:06 -0700 Subject: [PATCH 28/48] [Infra] CCI: factor repeated filters and Python docker image to YAML anchors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same branch filter block appeared 46 times in the workflow declaration: filters: branches: only: - main - /litellm_.*/ And the same pinned Python docker image appeared 29 times in jobs: - image: cimg/python:3.12@sha256:9c796c... auth: username: ${DOCKERHUB_USERNAME} password: ${DOCKERHUB_PASSWORD} Replace with YAML anchors declared at first use: - `&main_branches` on using_litellm_on_windows's filters block; all other job entries reference it as `filters: *main_branches`. - `&python312_image` on local_testing_part1's first docker image entry; all other jobs reference `- *python312_image`, including the multi-image jobs (auth_ui_unit_tests, installing_litellm_on_python_v2_migration_resolver) which keep their postgres sidecar entry inline afterwards. Net result: one place to change when the image digest rolls or the branch-filter convention changes. No behavior change — YAML anchor resolution produces identical config at parse time. Also adds Docker Hub auth block to upload-coverage (previously pulled anonymously). No functional difference for a public image, but avoids Docker Hub rate limits now that we reuse the same entry. --- .circleci/config.yml | 412 ++++++++----------------------------------- 1 file changed, 76 insertions(+), 336 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index cd2876dffa..6a3c5e3aa1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -163,7 +163,8 @@ jobs: local_testing_part1: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c + - &python312_image + image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c auth: username: ${DOCKERHUB_USERNAME} password: ${DOCKERHUB_PASSWORD} @@ -231,10 +232,7 @@ jobs: - local_testing_part1_coverage local_testing_part2: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project parallelism: 4 steps: @@ -299,10 +297,7 @@ jobs: - local_testing_part2_coverage langfuse_logging_unit_tests: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: medium @@ -341,10 +336,7 @@ jobs: path: test-results auth_ui_unit_tests: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image - image: cimg/postgres:16.0@sha256:b125148bc76e8e8eee5eb3ad6020a3a14110a14e8192f1c645128afebe2e2f84 environment: POSTGRES_USER: postgres @@ -391,10 +383,7 @@ jobs: litellm_router_testing: # Runs all tests with the "router" keyword docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: large parallelism: 4 @@ -437,10 +426,7 @@ jobs: litellm_router_unit_testing: # Runs all tests with the "router" keyword docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: large @@ -471,10 +457,7 @@ jobs: path: test-results litellm_assistants_api_testing: # Runs all tests with the "assistants" keyword docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: medium @@ -498,10 +481,7 @@ jobs: path: test-results llm_translation_testing: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: xlarge @@ -542,10 +522,7 @@ jobs: path: test-results realtime_translation_testing: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: @@ -580,10 +557,7 @@ jobs: - realtime_translation_coverage mcp_testing: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: @@ -616,10 +590,7 @@ jobs: - mcp_coverage agent_testing: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: @@ -652,10 +623,7 @@ jobs: - agent_coverage guardrails_testing: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: @@ -689,10 +657,7 @@ jobs: google_generate_content_endpoint_testing: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: @@ -726,10 +691,7 @@ jobs: llm_responses_api_testing: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: large @@ -760,10 +722,7 @@ jobs: path: test-results ocr_testing: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: @@ -796,10 +755,7 @@ jobs: - ocr_coverage search_testing: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: @@ -833,10 +789,7 @@ jobs: # Split litellm_mapped_tests into parallel jobs litellm_mapped_tests_proxy_part1: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: large steps: @@ -852,10 +805,7 @@ jobs: path: test-results litellm_mapped_tests_proxy_part2: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: large steps: @@ -871,10 +821,7 @@ jobs: path: test-results litellm_mapped_enterprise_tests: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: large @@ -898,10 +845,7 @@ jobs: path: test-results batches_testing: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: @@ -934,10 +878,7 @@ jobs: - batches_coverage litellm_utils_testing: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: @@ -971,10 +912,7 @@ jobs: pass_through_unit_testing: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: @@ -1007,10 +945,7 @@ jobs: - pass_through_unit_tests_coverage image_gen_testing: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project resource_class: large @@ -1033,10 +968,7 @@ jobs: path: test-results logging_testing: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: @@ -1070,10 +1002,7 @@ jobs: - logging_coverage audio_testing: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: @@ -1106,10 +1035,7 @@ jobs: - audio_coverage redis_caching_unit_tests: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: @@ -1156,10 +1082,7 @@ jobs: - redis_caching_coverage installing_litellm_on_python: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image working_directory: ~/project steps: @@ -1200,10 +1123,7 @@ jobs: installing_litellm_on_python_v2_migration_resolver: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c - auth: - username: ${DOCKERHUB_USERNAME} - password: ${DOCKERHUB_PASSWORD} + - *python312_image - image: cimg/postgres:16.0@sha256:b125148bc76e8e8eee5eb3ad6020a3a14110a14e8192f1c645128afebe2e2f84 environment: POSTGRES_USER: postgres @@ -2117,7 +2037,7 @@ jobs: upload-coverage: docker: - - image: cimg/python:3.12@sha256:9c796c23c84e84a66a964acb508d39dc5433c81a47e07efd56dccbbc2427e07c + - *python312_image steps: - checkout - attach_workspace: @@ -2402,263 +2322,107 @@ workflows: build_and_test: jobs: - using_litellm_on_windows: - filters: + filters: &main_branches branches: only: - main - /litellm_.*/ - local_testing_part1: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - local_testing_part2: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - langfuse_logging_unit_tests: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - litellm_assistants_api_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - litellm_router_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - litellm_router_unit_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - ui_build: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - ui_unit_tests: requires: - ui_build - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - auth_ui_unit_tests: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - build_docker_database_image: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - e2e_ui_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - build_and_test: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - e2e_openai_endpoints: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - proxy_logging_guardrails_model_info_tests: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - proxy_spend_accuracy_tests: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - proxy_multi_instance_tests: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - proxy_store_model_in_db_tests: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - proxy_build_from_pip_tests: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - proxy_pass_through_endpoint_tests: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - proxy_e2e_anthropic_messages_tests: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - llm_translation_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - realtime_translation_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - mcp_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - agent_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - guardrails_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - google_generate_content_endpoint_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - llm_responses_api_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - ocr_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - search_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - litellm_mapped_enterprise_tests: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - litellm_mapped_tests_proxy_part1: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - litellm_mapped_tests_proxy_part2: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - batches_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - litellm_utils_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - pass_through_unit_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - image_gen_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - logging_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - audio_testing: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - redis_caching_unit_tests: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - upload-coverage: requires: - realtime_translation_testing @@ -2685,42 +2449,18 @@ workflows: - db_migration_disable_update_check: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - installing_litellm_on_python: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - installing_litellm_on_python_3_13: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - installing_litellm_on_python_v2_migration_resolver: - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - helm_chart_testing: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches - test_bad_database_url: requires: - build_docker_database_image - filters: - branches: - only: - - main - - /litellm_.*/ + filters: *main_branches From 547d60c64290000488de2273beab3b7d3379951e Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 21:25:22 -0700 Subject: [PATCH 29/48] [Infra] CCI: match Windows uv install path to Linux verification pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows uv install step was piping a remote install.ps1 into Invoke-Expression without any integrity check, while the Linux install steps (install_uv command, line 89) download to a file, verify SHA-256 against a hardcoded digest, and only then execute. Bring the Windows path to the same pattern. Also hardcode the kubectl v1.31.4 checksum in helm_chart_testing instead of fetching kubectl.sha256 from the same origin as the binary — if dl.k8s.io were ever to serve a tampered pair, a co-hosted checksum provides no additional integrity. --- .circleci/config.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 6a3c5e3aa1..0ad8841e26 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -146,7 +146,15 @@ jobs: - run: name: Install Dependencies command: | - Invoke-RestMethod https://astral.sh/uv/0.10.9/install.ps1 | Invoke-Expression + $installer = Join-Path $env:TEMP "uv-install.ps1" + Invoke-WebRequest -Uri https://astral.sh/uv/0.10.9/install.ps1 -OutFile $installer + $expected = "d43ffff8d28e7d1e7d1831a212465f12b24a43c7f87f386e95e2a5915aee5d7d" + $actual = (Get-FileHash -Path $installer -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected) { + throw "uv installer hash mismatch: expected $expected got $actual" + } + & $installer + Remove-Item $installer $uvBin = Join-Path $HOME ".local\bin" $env:Path = "$uvBin;$env:Path" if (!(Test-Path $PROFILE)) { @@ -1165,18 +1173,15 @@ jobs: - install_helm - install_kind - # Install kubectl (pinned version with official checksum verification) + # Install kubectl (pinned version with hardcoded checksum) - run: name: Install kubectl v1.31.4 command: | curl -sSLf -o /tmp/kubectl \ https://dl.k8s.io/release/v1.31.4/bin/linux/amd64/kubectl - curl -sSLf -o /tmp/kubectl.sha256 \ - https://dl.k8s.io/release/v1.31.4/bin/linux/amd64/kubectl.sha256 - echo "$(cat /tmp/kubectl.sha256) /tmp/kubectl" | sha256sum -c - + echo "298e19e9c6c17199011404278f0ff8168a7eca4217edad9097af577023a5620f /tmp/kubectl" | sha256sum -c - chmod +x /tmp/kubectl sudo mv /tmp/kubectl /usr/local/bin/ - rm -f /tmp/kubectl.sha256 # Create kind cluster - run: From a12a2190d7f91e94db031144f050175ef32dbece Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 21:26:19 -0700 Subject: [PATCH 30/48] [Infra] Flip remaining CI jobs to Python 3.12 Stragglers from the 2026-04-21 Python 3.12 standardization: - .github/workflows/check_duplicate_issues.yml (was 3.11) - .github/workflows/llm-translation-testing.yml (was 3.11) - .github/workflows/scan_duplicate_issues.yml (was 3.13) - .circleci proxy_build_from_pip_tests (was 3.13) The only intentional non-3.12 CI job is installing_litellm_on_python_3_13, which exists as an explicit "latest supported Python" smoke matrix. --- .circleci/config.yml | 2 +- .github/workflows/check_duplicate_issues.yml | 2 +- .github/workflows/llm-translation-testing.yml | 2 +- .github/workflows/scan_duplicate_issues.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0ad8841e26..e23550dbd6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1806,7 +1806,7 @@ jobs: - run: name: Install Dependencies command: | - uv sync --frozen --all-groups --all-extras --python 3.13 + uv sync --frozen --all-groups --all-extras --python 3.12 - run: name: Build Docker image command: | diff --git a/.github/workflows/check_duplicate_issues.yml b/.github/workflows/check_duplicate_issues.yml index 289d78880a..78198b2c7b 100644 --- a/.github/workflows/check_duplicate_issues.yml +++ b/.github/workflows/check_duplicate_issues.yml @@ -39,7 +39,7 @@ jobs: if: github.event.action == 'opened' uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - python-version: "3.11" + python-version: "3.12" - name: Auto-close if high-confidence duplicate if: github.event.action == 'opened' diff --git a/.github/workflows/llm-translation-testing.yml b/.github/workflows/llm-translation-testing.yml index 93b69e5c6a..8d9d52f4e5 100644 --- a/.github/workflows/llm-translation-testing.yml +++ b/.github/workflows/llm-translation-testing.yml @@ -29,7 +29,7 @@ jobs: - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 diff --git a/.github/workflows/scan_duplicate_issues.yml b/.github/workflows/scan_duplicate_issues.yml index 222ff11f30..ab0ac2aa3a 100644 --- a/.github/workflows/scan_duplicate_issues.yml +++ b/.github/workflows/scan_duplicate_issues.yml @@ -29,7 +29,7 @@ jobs: - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: - python-version: "3.13" + python-version: "3.12" - name: Scan for duplicate issues env: From eb6a2d043c56c71d628477d1923bee4ca1c5b7ce Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 21:28:42 -0700 Subject: [PATCH 31/48] [Infra] CCI: pin Ruby and Node.js installs in proxy_pass_through_endpoint_tests Align the Ruby, Node.js, and npm install path with the rest of the config. Three separate upstream installers were being invoked via \`curl ... | bash\` or unlocked \`npm install\`: - RVM's \`get.rvm.io/stable\` installer (mutable upstream script). Replace with a shallow git clone of the rvm/rvm repo at tag 1.29.12 and verify HEAD matches the published commit SHA before running the local \`./install\` script. Same pattern already used for the helm-unittest plugin in .github/workflows/helm_unit_test.yml. - NodeSource's \`deb.nodesource.com/setup_18.x\` piped into sudo bash. Replace with a direct download of the Node.js 18.20.8 linux-x64 tarball from nodejs.org, verified against the published SHASUMS256.txt digest before extraction. - \`npm install @google-cloud/vertexai @google/generative-ai\` and \`--save-dev jest\` resolved fresh from the npm registry on every run. Add \`tests/pass_through_tests/package.json\` with pinned direct-dep versions and commit the generated package-lock.json, then switch CI to \`npm ci\` (exact lockfile install, fails on drift). Also scopes the Ruby+JS test runners to \`tests/pass_through_tests/\` so they pick up the committed package.json rather than writing node_modules at repo root. --- .circleci/config.yml | 53 +- tests/pass_through_tests/package-lock.json | 3930 ++++++++++++++++++++ tests/pass_through_tests/package.json | 13 + 3 files changed, 3975 insertions(+), 21 deletions(-) create mode 100644 tests/pass_through_tests/package-lock.json create mode 100644 tests/pass_through_tests/package.json diff --git a/.circleci/config.yml b/.circleci/config.yml index e23550dbd6..680ce16eee 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1921,19 +1921,25 @@ jobs: - run: name: Install Ruby and Bundler command: | - # Import GPG keys first - gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB || { - curl -sSL https://rvm.io/mpapis.asc | gpg --import - - curl -sSL https://rvm.io/pkuczynski.asc | gpg --import - - } + # Clone RVM at pinned tag and verify the commit SHA matches the + # published tag before running its install script. + RVM_VERSION="1.29.12" + RVM_EXPECTED_SHA="6bfc9213c9d6914fe756f524eb034a403d51db81" + git clone --depth 1 --branch "$RVM_VERSION" https://github.com/rvm/rvm.git /tmp/rvm + RVM_ACTUAL_SHA="$(git -C /tmp/rvm rev-parse HEAD)" + if [ "$RVM_ACTUAL_SHA" != "$RVM_EXPECTED_SHA" ]; then + echo "RVM tag $RVM_VERSION resolved to $RVM_ACTUAL_SHA; expected $RVM_EXPECTED_SHA" >&2 + exit 1 + fi - # Install Ruby version manager (RVM) - curl -sSL https://get.rvm.io | bash -s stable + # Import RVM signing keys (used by `rvm install` to verify Ruby tarballs) + gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB - # Source RVM from the correct location - source $HOME/.rvm/scripts/rvm + # Install RVM from the verified checkout + /tmp/rvm/install --path "$HOME/.rvm" + source "$HOME/.rvm/scripts/rvm" - # Install Ruby 3.2.2 + # Install Ruby 3.2.2 (RVM verifies the tarball PGP signature) rvm install 3.2.2 rvm use 3.2.2 --default @@ -1948,28 +1954,33 @@ jobs: bundle install bundle exec rspec no_output_timeout: 30m - # New steps to run Node.js test + # Install Node.js directly from nodejs.org with SHA256 verification, + # instead of piping NodeSource's setup_18.x apt-repo installer into + # sudo bash (which runs a mutable upstream script unattended). - run: - name: Install Node.js + name: Install Node.js 18.20.8 command: | - export DEBIAN_FRONTEND=noninteractive - curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - - sudo apt-get update - sudo apt-get install -y nodejs + NODE_VERSION="18.20.8" + NODE_TARBALL="node-v${NODE_VERSION}-linux-x64.tar.xz" + NODE_EXPECTED_SHA="5467ee62d6af1411d46b6a10e3fb5cacc92734dbcef465fea14e7b90993001c9" + curl -sSLf -o "/tmp/${NODE_TARBALL}" "https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TARBALL}" + echo "${NODE_EXPECTED_SHA} /tmp/${NODE_TARBALL}" | sha256sum -c - + sudo tar -xJf "/tmp/${NODE_TARBALL}" -C /usr/local --strip-components=1 + rm -f "/tmp/${NODE_TARBALL}" node --version npm --version - run: - name: Install Node.js dependencies + name: Install Node.js test dependencies command: | - npm install @google-cloud/vertexai - npm install @google/generative-ai - npm install --save-dev jest + cd tests/pass_through_tests + npm ci - run: name: Run Vertex AI, Google AI Studio Node.js tests command: | - npx jest tests/pass_through_tests --verbose + cd tests/pass_through_tests + npx jest . --verbose no_output_timeout: 30m - run: name: Run tests diff --git a/tests/pass_through_tests/package-lock.json b/tests/pass_through_tests/package-lock.json new file mode 100644 index 0000000000..f1d70e37c6 --- /dev/null +++ b/tests/pass_through_tests/package-lock.json @@ -0,0 +1,3930 @@ +{ + "name": "litellm-pass-through-tests", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "litellm-pass-through-tests", + "version": "0.0.0", + "dependencies": { + "@google-cloud/vertexai": "1.9.3", + "@google/generative-ai": "0.21.0" + }, + "devDependencies": { + "jest": "29.7.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@google-cloud/vertexai": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@google-cloud/vertexai/-/vertexai-1.9.3.tgz", + "integrity": "sha512-35o5tIEMLW3JeFJOaaMNR2e5sq+6rpnhrF97PuAxeOm0GlqVTESKhkGj7a5B5mmJSSSU3hUfIhcQCRRsw4Ipzg==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@google/generative-ai": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.21.0.tgz", + "integrity": "sha512-7XhUbtnlkSEZK15kN3t+tzIMxsbKm/dSkKBFalj+20NvPKe1kBY7mR2P7vuijEn+f06z5+A8bVGKO0v39cr6Wg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/tests/pass_through_tests/package.json b/tests/pass_through_tests/package.json new file mode 100644 index 0000000000..a500c14cce --- /dev/null +++ b/tests/pass_through_tests/package.json @@ -0,0 +1,13 @@ +{ + "name": "litellm-pass-through-tests", + "version": "0.0.0", + "private": true, + "description": "JS pass-through tests for Vertex AI / Google AI Studio routes. CI-only; not published.", + "dependencies": { + "@google-cloud/vertexai": "1.9.3", + "@google/generative-ai": "0.21.0" + }, + "devDependencies": { + "jest": "29.7.0" + } +} From 03a022436b70d611e6f70abe6e405c5e3f46fbbd Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Apr 2026 21:50:53 -0700 Subject: [PATCH 32/48] [Infra] CCI: run RVM install from its own checkout dir The rvm/install script sources scripts/functions/installer using paths relative to the caller's working directory (not $0), so invoking /tmp/rvm/install from /home/circleci/project fails with 'No such file or directory'. Switch to (cd /tmp/rvm && ./install). --- .circleci/config.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 680ce16eee..eabf4c6129 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1935,8 +1935,10 @@ jobs: # Import RVM signing keys (used by `rvm install` to verify Ruby tarballs) gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB - # Install RVM from the verified checkout - /tmp/rvm/install --path "$HOME/.rvm" + # Install RVM from the verified checkout. The install script + # sources `scripts/functions/installer` using paths relative to + # its own working directory, so it must be run from /tmp/rvm. + (cd /tmp/rvm && ./install --path "$HOME/.rvm") source "$HOME/.rvm/scripts/rvm" # Install Ruby 3.2.2 (RVM verifies the tarball PGP signature) From 1385d46e9974a7ac59843421682268bacde06918 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 23 Apr 2026 17:17:06 +0530 Subject: [PATCH 33/48] FIx mypy issues --- .../anthropic/experimental_pass_through/utils.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/utils.py b/litellm/llms/anthropic/experimental_pass_through/utils.py index d975bee0bc..4fd68ef535 100644 --- a/litellm/llms/anthropic/experimental_pass_through/utils.py +++ b/litellm/llms/anthropic/experimental_pass_through/utils.py @@ -2,6 +2,7 @@ import os from typing import Optional import litellm +from litellm.types.utils import ModelInfo def is_reasoning_auto_summary_enabled() -> bool: @@ -31,25 +32,26 @@ def normalize_reasoning_effort_value( from litellm.utils import get_model_info + model_info: Optional[ModelInfo] = None try: model_info = get_model_info( model=model, custom_llm_provider=custom_llm_provider ) except Exception: - model_info = {} + model_info = None if effort == "max": - if model_info.get("supports_max_reasoning_effort"): + if model_info and model_info.get("supports_max_reasoning_effort"): return "max" - if model_info.get("supports_xhigh_reasoning_effort"): + if model_info and model_info.get("supports_xhigh_reasoning_effort"): return "xhigh" return "high" elif effort == "xhigh": - if model_info.get("supports_xhigh_reasoning_effort"): + if model_info and model_info.get("supports_xhigh_reasoning_effort"): return "xhigh" return "high" elif effort == "minimal": - if model_info.get("supports_minimal_reasoning_effort"): + if model_info and model_info.get("supports_minimal_reasoning_effort"): return "minimal" return "low" return "medium" From 2e3a4bb27a27f875f303b8b66abd26dfad86c155 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 23 Apr 2026 18:32:24 +0530 Subject: [PATCH 34/48] Fix black --- .../llms/dashscope/image_generation/__init__.py | 4 +++- .../image_generation/transformation.py | 17 +++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/litellm/llms/dashscope/image_generation/__init__.py b/litellm/llms/dashscope/image_generation/__init__.py index 9fdb46586e..aa5724b4d8 100644 --- a/litellm/llms/dashscope/image_generation/__init__.py +++ b/litellm/llms/dashscope/image_generation/__init__.py @@ -1,4 +1,6 @@ -from litellm.llms.base_llm.image_generation.transformation import BaseImageGenerationConfig +from litellm.llms.base_llm.image_generation.transformation import ( + BaseImageGenerationConfig, +) from .transformation import DashScopeImageGenerationConfig diff --git a/litellm/llms/dashscope/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py index feac811df8..152c4791bf 100644 --- a/litellm/llms/dashscope/image_generation/transformation.py +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -27,9 +27,14 @@ from typing import TYPE_CHECKING, Any, List, Optional import httpx -from litellm.llms.base_llm.image_generation.transformation import BaseImageGenerationConfig +from litellm.llms.base_llm.image_generation.transformation import ( + BaseImageGenerationConfig, +) from litellm.secret_managers.main import get_secret_str -from litellm.types.llms.openai import AllMessageValues, OpenAIImageGenerationOptionalParams +from litellm.types.llms.openai import ( + AllMessageValues, + OpenAIImageGenerationOptionalParams, +) from litellm.types.utils import ImageObject, ImageResponse if TYPE_CHECKING: @@ -93,9 +98,7 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): stream: Optional[bool] = None, ) -> str: return ( - api_base - or get_secret_str("DASHSCOPE_API_BASE_IMAGE") - or DEFAULT_API_BASE + api_base or get_secret_str("DASHSCOPE_API_BASE_IMAGE") or DEFAULT_API_BASE ) def validate_environment( @@ -176,9 +179,7 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): choices = response_data.get("output", {}).get("choices", []) for choice in choices: - content_list = ( - choice.get("message", {}).get("content", []) - ) + content_list = choice.get("message", {}).get("content", []) for content_item in content_list: image_url = content_item.get("image") if image_url: From 2d1cc68e228cc43264d6e7e21a7f5ffc855ebf5f Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 23 Apr 2026 18:41:01 +0530 Subject: [PATCH 35/48] fix(dashscope): fail fast on image generation API errors Prevent silent empty image responses by raising provider errors for non-200 HTTP statuses and DashScope API-level error payloads, with regression tests covering both paths. Made-with: Cursor --- .../image_generation/transformation.py | 16 +++ .../test_dashscope_image_generation.py | 97 ++++++++++++++++--- 2 files changed, 101 insertions(+), 12 deletions(-) diff --git a/litellm/llms/dashscope/image_generation/transformation.py b/litellm/llms/dashscope/image_generation/transformation.py index 152c4791bf..77676b11d5 100644 --- a/litellm/llms/dashscope/image_generation/transformation.py +++ b/litellm/llms/dashscope/image_generation/transformation.py @@ -165,6 +165,13 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): DashScope response: output.choices[0].message.content[0].image OpenAI response: data[0].url """ + if raw_response.status_code != 200: + raise self.get_error_class( + error_message=raw_response.text, + status_code=raw_response.status_code, + headers=raw_response.headers, + ) + try: response_data = raw_response.json() except Exception as e: @@ -174,6 +181,15 @@ class DashScopeImageGenerationConfig(BaseImageGenerationConfig): headers=raw_response.headers, ) + # DashScope can return API-level errors in a 200 response body. + # Example: {"code": "InvalidParameter", "message": "Size not supported"} + if "code" in response_data and "output" not in response_data: + raise self.get_error_class( + error_message=str(response_data.get("message", response_data)), + status_code=raw_response.status_code, + headers=raw_response.headers, + ) + if not model_response.data: model_response.data = [] diff --git a/tests/test_litellm/test_dashscope_image_generation.py b/tests/test_litellm/test_dashscope_image_generation.py index b7680a7e7f..af95e2ca6b 100644 --- a/tests/test_litellm/test_dashscope_image_generation.py +++ b/tests/test_litellm/test_dashscope_image_generation.py @@ -49,7 +49,9 @@ def test_get_llm_provider_returns_dashscope(model_string: str): ("dashscope/qwen-image-2.0-pro", "dashscope"), ], ) -def test_get_model_info_mode_is_image_generation(model_string: str, custom_provider: str): +def test_get_model_info_mode_is_image_generation( + model_string: str, custom_provider: str +): import os prev_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP") @@ -58,10 +60,12 @@ def test_get_model_info_mode_is_image_generation(model_string: str, custom_provi os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" litellm.model_cost = litellm.get_model_cost_map(url="") - info = litellm.get_model_info(model=model_string, custom_llm_provider=custom_provider) - assert info["mode"] == "image_generation", ( - f"Expected mode='image_generation', got '{info['mode']}'" + info = litellm.get_model_info( + model=model_string, custom_llm_provider=custom_provider ) + assert ( + info["mode"] == "image_generation" + ), f"Expected mode='image_generation', got '{info['mode']}'" finally: if prev_env is None: os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None) @@ -101,7 +105,10 @@ class TestDashScopeImageGenerationConfig: assert headers["Content-Type"] == "application/json" def test_validate_environment_raises_without_key(self): - with patch("litellm.llms.dashscope.image_generation.transformation.get_secret_str", return_value=None): + with patch( + "litellm.llms.dashscope.image_generation.transformation.get_secret_str", + return_value=None, + ): with pytest.raises(ValueError, match="DASHSCOPE_API_KEY"): self.cfg.validate_environment( headers={}, @@ -192,8 +199,20 @@ class TestDashScopeImageGenerationConfig: body = { "output": { "choices": [ - {"finish_reason": "stop", "message": {"role": "assistant", "content": [{"image": "https://example.com/img1.png"}]}}, - {"finish_reason": "stop", "message": {"role": "assistant", "content": [{"image": "https://example.com/img2.png"}]}}, + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [{"image": "https://example.com/img1.png"}], + }, + }, + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [{"image": "https://example.com/img2.png"}], + }, + }, ] }, "usage": {}, @@ -218,6 +237,49 @@ class TestDashScopeImageGenerationConfig: assert result.data[0].url == "https://example.com/img1.png" assert result.data[1].url == "https://example.com/img2.png" + def test_transform_response_raises_on_non_200_status(self): + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 400 + mock_resp.headers = {} + mock_resp.text = '{"code":"InvalidParameter","message":"Size not supported"}' + mock_resp.json.return_value = { + "code": "InvalidParameter", + "message": "Size not supported", + } + + with pytest.raises(Exception): + self.cfg.transform_image_generation_response( + model="qwen-image-2.0", + raw_response=mock_resp, + model_response=ImageResponse(), + logging_obj=MagicMock(), + request_data={}, + optional_params={}, + litellm_params={}, + encoding=None, + ) + + def test_transform_response_raises_on_api_error_body(self): + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + mock_resp.headers = {} + mock_resp.json.return_value = { + "code": "InvalidParameter", + "message": "Size not supported", + } + + with pytest.raises(Exception): + self.cfg.transform_image_generation_response( + model="qwen-image-2.0", + raw_response=mock_resp, + model_response=ImageResponse(), + logging_obj=MagicMock(), + request_data={}, + optional_params={}, + litellm_params={}, + encoding=None, + ) + # --------------------------------------------------------------------------- # 5. OpenAI → DashScope parameter mapping # --------------------------------------------------------------------------- @@ -284,13 +346,21 @@ def test_litellm_image_generation_dashscope_end_to_end(): "message": { "role": "assistant", "content": [ - {"image": "https://dashscope-result.oss.aliyuncs.com/test.png"} + { + "image": "https://dashscope-result.oss.aliyuncs.com/test.png" + } ], }, } ] }, - "usage": {"input_tokens": 0, "output_tokens": 0, "width": 1024, "height": 1024, "image_count": 1}, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "width": 1024, + "height": 1024, + "image_count": 1, + }, } with patch( @@ -312,11 +382,15 @@ def test_litellm_image_generation_dashscope_end_to_end(): assert response is not None assert response.data is not None assert len(response.data) == 1 - assert response.data[0].url == "https://dashscope-result.oss.aliyuncs.com/test.png" + assert ( + response.data[0].url == "https://dashscope-result.oss.aliyuncs.com/test.png" + ) # Verify the HTTP call was made to the DashScope endpoint call_args = mock_post.call_args - called_url = call_args[0][0] if call_args[0] else call_args.kwargs.get("url", "") + called_url = ( + call_args[0][0] if call_args[0] else call_args.kwargs.get("url", "") + ) assert "dashscope" in called_url or "aliyuncs" in called_url # Verify request body contains DashScope format @@ -325,4 +399,3 @@ def test_litellm_image_generation_dashscope_end_to_end(): body = call_kwargs["json"] assert "input" in body assert "messages" in body["input"] - From 994e35135dc53badf26455e12d04d87981bc3561 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 11:20:20 -0700 Subject: [PATCH 36/48] fix: correct image size limit enforcement and vertex_location None passthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit token_counter.py: the previous size-limit raises were inside except Exception: pass, so they were silently swallowed. The post-read raise was worse — img_data was already assigned the full body before the raise, so the oversized value was used downstream. Restructured to only assign img_data when the body is within bounds. vertex_ai/common_utils.py and llm_passthrough_endpoints.py: the is-not-None guard skipped validation for None, falling through to produce "https://None-aiplatform..." Added explicit None check that raises before the regex guard. --- litellm/litellm_core_utils/token_counter.py | 9 +++++---- litellm/llms/vertex_ai/common_utils.py | 6 +++--- .../pass_through_endpoints/llm_passthrough_endpoints.py | 6 +++--- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index d893b98078..e6a68de07e 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -219,10 +219,11 @@ def get_image_dimensions( max_bytes = int(MAX_IMAGE_URL_DOWNLOAD_SIZE_MB * 1024 * 1024) content_length = response.headers.get("Content-Length") if content_length is not None and int(content_length) > max_bytes: - raise ValueError("Image response exceeds size limit") - img_data = response.read() - if len(img_data) > max_bytes: - raise ValueError("Image response exceeds size limit") + pass # skip download; img_data stays None + else: + body = response.read() + if len(body) <= max_bytes: + img_data = body except Exception: pass if img_data is None: diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index c13f6a86f8..fb8fd90340 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -232,9 +232,9 @@ def get_vertex_base_url( """ if vertex_location == "global": return "https://aiplatform.googleapis.com" - if vertex_location is not None and not re.match( - r"^[a-z][a-z0-9-]*$", vertex_location - ): + if vertex_location is None: + raise ValueError("vertex_location is required") + if not re.match(r"^[a-z][a-z0-9-]*$", vertex_location): raise ValueError("Invalid vertex_location format") return f"https://{vertex_location}-aiplatform.googleapis.com" diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 3cf155739c..8a86b98fee 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -1501,9 +1501,9 @@ def get_vertex_base_url(vertex_location: Optional[str]) -> str: """ if vertex_location == "global": return "https://aiplatform.googleapis.com/" - if vertex_location is not None and not re.match( - r"^[a-z][a-z0-9-]*$", vertex_location - ): + if vertex_location is None: + raise ValueError("vertex_location is required") + if not re.match(r"^[a-z][a-z0-9-]*$", vertex_location): raise ValueError("Invalid vertex_location format") return f"https://{vertex_location}-aiplatform.googleapis.com/" From daf29d6a4ad3c7ae1b42c27ee08deb2f6c891a4e Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 12:02:25 -0700 Subject: [PATCH 37/48] [Infra] Add standalone create-release-branch workflow Extracts release branch creation into a separate reusable workflow (create-release-branch.yml) that can be triggered independently via workflow_dispatch or called from other workflows via workflow_call. create-release.yml now dispatches it as a dependent job after the release publishes, keeping both workflows decoupled. --- .github/workflows/create-release-branch.yml | 65 +++++++++++++++++++++ .github/workflows/create-release.yml | 9 +++ 2 files changed, 74 insertions(+) create mode 100644 .github/workflows/create-release-branch.yml diff --git a/.github/workflows/create-release-branch.yml b/.github/workflows/create-release-branch.yml new file mode 100644 index 0000000000..13b76c94df --- /dev/null +++ b/.github/workflows/create-release-branch.yml @@ -0,0 +1,65 @@ +name: Create Release Branch + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v1.83.0-stable) — branch will be named release/" + required: true + type: string + commit_hash: + description: "Full 40-char commit SHA the branch should point to" + required: true + type: string + workflow_call: + inputs: + tag: + description: "Release tag" + required: true + type: string + commit_hash: + description: "Full 40-char commit SHA the branch should point to" + required: true + type: string + +permissions: {} + +jobs: + create-branch: + name: Create Release Branch + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Validate inputs + env: + TAG: ${{ inputs.tag }} + COMMIT_HASH: ${{ inputs.commit_hash }} + run: | + if ! echo "${COMMIT_HASH}" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error::commit_hash must be a full 40-character commit SHA" + exit 1 + fi + if ! echo "${TAG}" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+'; then + echo "::error::tag must start with vX.Y.Z" + exit 1 + fi + + - name: Create release branch + env: + TAG: ${{ inputs.tag }} + COMMIT_HASH: ${{ inputs.commit_hash }} + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const tag = process.env.TAG; + const commitHash = process.env.COMMIT_HASH; + const branchName = `release/${tag}`; + + await github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/heads/${branchName}`, + sha: commitHash, + }); + core.info(`Created branch ${branchName} at ${commitHash}`); diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index b863397985..a5b3dd8113 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -102,6 +102,15 @@ jobs: body: updatedBody, draft: false, }); + } catch (error) { core.setFailed(error.message); } + + create-branch: + name: Create Release Branch + needs: release + uses: ./.github/workflows/create-release-branch.yml + with: + tag: ${{ inputs.tag }} + commit_hash: ${{ inputs.commit_hash }} From 46336b1ac34c75292982e787839a0ae8c19e86fc Mon Sep 17 00:00:00 2001 From: Michael Riad Zaky Date: Thu, 23 Apr 2026 12:08:10 -0700 Subject: [PATCH 38/48] fix linting --- litellm/proxy/auth/auth_checks.py | 9 +++++++-- litellm/proxy/proxy_server.py | 4 +--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 1c89b0bfc0..840f64cfed 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -3290,10 +3290,15 @@ async def _check_team_member_budget( # Per-member override wins; otherwise fall back to the team-level # default configured via team.metadata["team_member_budget_id"]. team_member_budget: Optional[float] = None - if team_membership is not None and team_membership.litellm_budget_table is not None: + if ( + team_membership is not None + and team_membership.litellm_budget_table is not None + ): team_member_budget = team_membership.litellm_budget_table.max_budget else: - default_budget_id = (team_object.metadata or {}).get("team_member_budget_id") + default_budget_id = (team_object.metadata or {}).get( + "team_member_budget_id" + ) if isinstance(default_budget_id, str): default_budget = await get_team_member_default_budget( budget_id=default_budget_id, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 546d8df14c..dafc496ccc 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2009,9 +2009,7 @@ async def _init_and_increment_spend_counter( key=counter_key, value=base_spend ) - await spend_counter_cache.async_increment_cache( - key=counter_key, value=increment - ) + await spend_counter_cache.async_increment_cache(key=counter_key, value=increment) async def update_cache( # noqa: PLR0915 From c41567eaa0caaffc6070b2806adc6369b61fe017 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 12:26:25 -0700 Subject: [PATCH 39/48] fix(budget_reset): use raw SQL for IS NOT NULL filter on Json? columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The periodic budget-window reset job filtered keys/teams with `where={"budget_limits": {"not": None}}`. The prisma-client-python library does not support null-filtering on `Json?` columns (no DbNull/JsonNull sentinel — upstream issue #714). The client drops the `None` value during serialization and the engine rejects the query with `MissingRequiredValueError: where.budget_limits.not: A value is required but not set`, so neither the key nor team reset path runs. Switch those two `find_many` calls to `query_raw` with `WHERE budget_limits IS NOT NULL`, selecting only the PK and the `budget_limits` column. Writes still go through the ORM. Add unit tests covering the expired/unexpired paths for keys and teams, string-encoded JSON payloads, empty payloads, error isolation between the two paths, and a regression guard asserting the query still uses `IS NOT NULL`. --- .../proxy/common_utils/reset_budget_job.py | 34 +-- .../common_utils/test_reset_budget_job.py | 236 +++++++++++++++++- 2 files changed, 253 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index b11af04e2b..e486336cec 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -632,20 +632,27 @@ class ResetBudgetJob: now = datetime.utcnow() + # Note on raw SQL: prisma-client-python does not support null-filtering + # on `Json?` columns (no DbNull/JsonNull sentinel — see + # RobertCraigie/prisma-client-py#714). We use `query_raw` with + # `IS NOT NULL` so we don't materialize every key/team row on each + # tick of the reset job. Writes still go through the ORM. + # --- Keys --- try: - all_keys = await self.prisma_client.db.litellm_verificationtoken.find_many( - where={"budget_limits": {"not": None}} # type: ignore[arg-type] + key_rows = await self.prisma_client.db.query_raw( + 'SELECT token, budget_limits FROM "LiteLLM_VerificationToken" ' + "WHERE budget_limits IS NOT NULL" ) - for key in all_keys: - raw = key.budget_limits # type: ignore[attr-defined] + for row in key_rows: + raw = row["budget_limits"] if not raw: continue windows: list = raw if isinstance(raw, list) else json.loads(raw) changed = False for window in windows: counter_key = ( - f"spend:key:{key.token}:window:{window['budget_duration']}" + f"spend:key:{row['token']}:window:{window['budget_duration']}" ) if await ResetBudgetJob._reset_expired_window( window, counter_key, spend_counter_cache, now @@ -653,7 +660,7 @@ class ResetBudgetJob: changed = True if changed: await self.prisma_client.db.litellm_verificationtoken.update( - where={"token": key.token}, + where={"token": row["token"]}, data={"budget_limits": json.dumps(windows)}, # type: ignore[arg-type] ) except Exception as e: @@ -663,26 +670,25 @@ class ResetBudgetJob: # --- Teams --- try: - all_teams = await self.prisma_client.db.litellm_teamtable.find_many( - where={"budget_limits": {"not": None}} # type: ignore[arg-type] + team_rows = await self.prisma_client.db.query_raw( + 'SELECT team_id, budget_limits FROM "LiteLLM_TeamTable" ' + "WHERE budget_limits IS NOT NULL" ) - for team in all_teams: - raw = team.budget_limits # type: ignore[attr-defined] + for row in team_rows: + raw = row["budget_limits"] if not raw: continue windows = raw if isinstance(raw, list) else json.loads(raw) changed = False for window in windows: - counter_key = ( - f"spend:team:{team.team_id}:window:{window['budget_duration']}" - ) + counter_key = f"spend:team:{row['team_id']}:window:{window['budget_duration']}" if await ResetBudgetJob._reset_expired_window( window, counter_key, spend_counter_cache, now ): changed = True if changed: await self.prisma_client.db.litellm_teamtable.update( - where={"team_id": team.team_id}, + where={"team_id": row["team_id"]}, data={"budget_limits": json.dumps(windows)}, # type: ignore[arg-type] ) except Exception as e: diff --git a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py index 32f043be5b..379ccf4d9a 100644 --- a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py +++ b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py @@ -1,7 +1,9 @@ import asyncio +import json import os import sys import time +import types from datetime import datetime, timedelta, timezone from typing import Any, Dict, List from unittest.mock import AsyncMock, MagicMock @@ -696,9 +698,9 @@ def test_reset_budget_resets_endusers_with_null_budget_id( # Both end users should have been reset updated = mock_prisma_client.updated_data["enduser"] - assert len(updated) == 2, ( - f"Expected 2 endusers reset (1 explicit + 1 implicit), got {len(updated)}" - ) + assert ( + len(updated) == 2 + ), f"Expected 2 endusers reset (1 explicit + 1 implicit), got {len(updated)}" user_ids = {u.user_id for u in updated} assert "enduser-explicit" in user_ids @@ -819,3 +821,231 @@ def test_reset_budget_for_team_members_preserves_total_spend(): assert call_kwargs["where"]["budget_id"]["in"] == ["budget-1"] assert call_kwargs["data"] == {"spend": 0} assert "total_spend" not in call_kwargs["data"] + + +# --------------------------------------------------------------------------- +# reset_budget_windows (per-key / per-team concurrent window resets) +# --------------------------------------------------------------------------- + + +def _make_reset_budget_windows_job( + monkeypatch, + key_rows: List[Dict[str, Any]], + team_rows: List[Dict[str, Any]], +): + """Build a ResetBudgetJob with a fully-mocked prisma client and a fake + `litellm.proxy.proxy_server` module exposing a stub `spend_counter_cache`. + + Returns (job, prisma_client_mock, spend_counter_cache_mock). + """ + prisma_client = MagicMock() + + async def fake_query_raw(query: str, *args, **kwargs): + # Dispatch by table name in the SQL so a single stub covers both calls. + if '"LiteLLM_VerificationToken"' in query: + return key_rows + if '"LiteLLM_TeamTable"' in query: + return team_rows + raise AssertionError(f"Unexpected query_raw call: {query}") + + prisma_client.db.query_raw = AsyncMock(side_effect=fake_query_raw) + prisma_client.db.litellm_verificationtoken.update = AsyncMock(return_value=None) + prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=None) + + # Stub out litellm.proxy.proxy_server so the in-function + # `from litellm.proxy.proxy_server import spend_counter_cache` resolves + # without importing the real (heavy) module. + spend_counter_cache = MagicMock() + spend_counter_cache.in_memory_cache.set_cache = MagicMock() + spend_counter_cache.redis_cache = None # skip the async redis branch + + fake_module = types.ModuleType("litellm.proxy.proxy_server") + fake_module.spend_counter_cache = spend_counter_cache + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_module) + + job = ResetBudgetJob(proxy_logging_obj=MagicMock(), prisma_client=prisma_client) + return job, prisma_client, spend_counter_cache + + +def test_reset_budget_windows_uses_is_not_null_filter(monkeypatch): + """Regression guard for the Prisma client limitation documented in + RobertCraigie/prisma-client-py#714: `{"not": None}` on a `Json?` column + raises `MissingRequiredValueError`. We work around it by using `query_raw` + with `IS NOT NULL`. If someone reverts to the ORM filter, this test fails. + """ + job, prisma_client, _ = _make_reset_budget_windows_job( + monkeypatch, key_rows=[], team_rows=[] + ) + + asyncio.run(job.reset_budget_windows()) + + queries = [call.args[0] for call in prisma_client.db.query_raw.await_args_list] + assert len(queries) == 2, queries + key_query, team_query = queries + + assert '"LiteLLM_VerificationToken"' in key_query + assert "budget_limits IS NOT NULL" in key_query + assert '"LiteLLM_TeamTable"' in team_query + assert "budget_limits IS NOT NULL" in team_query + + +def test_reset_budget_windows_resets_expired_key_window(monkeypatch): + """A key whose window's `reset_at` has passed gets an update with a new + `reset_at` in the future, and the in-memory spend counter is cleared.""" + now = datetime.utcnow() + expired = (now - timedelta(minutes=5)).isoformat() + "Z" + + key_rows = [ + { + "token": "sk-expired", + "budget_limits": [{"budget_duration": "1d", "reset_at": expired}], + } + ] + job, prisma_client, spend_counter_cache = _make_reset_budget_windows_job( + monkeypatch, key_rows=key_rows, team_rows=[] + ) + + asyncio.run(job.reset_budget_windows()) + + # Update should have been called exactly once with the expired token. + prisma_client.db.litellm_verificationtoken.update.assert_awaited_once() + call_kwargs = prisma_client.db.litellm_verificationtoken.update.await_args.kwargs + assert call_kwargs["where"] == {"token": "sk-expired"} + + # The `budget_limits` payload is re-serialized JSON with a bumped reset_at. + written_windows = json.loads(call_kwargs["data"]["budget_limits"]) + assert len(written_windows) == 1 + new_reset_at = datetime.fromisoformat( + written_windows[0]["reset_at"].replace("Z", "+00:00") + ).replace(tzinfo=None) + assert new_reset_at > now + + # The spend counter for this key+window was cleared. + spend_counter_cache.in_memory_cache.set_cache.assert_any_call( + key="spend:key:sk-expired:window:1d", value=0.0 + ) + + +def test_reset_budget_windows_skips_unexpired_key_window(monkeypatch): + """If `reset_at` is in the future, no write should happen for that key.""" + now = datetime.utcnow() + future = (now + timedelta(hours=1)).isoformat() + "Z" + + key_rows = [ + { + "token": "sk-future", + "budget_limits": [{"budget_duration": "1d", "reset_at": future}], + } + ] + job, prisma_client, _ = _make_reset_budget_windows_job( + monkeypatch, key_rows=key_rows, team_rows=[] + ) + + asyncio.run(job.reset_budget_windows()) + + prisma_client.db.litellm_verificationtoken.update.assert_not_awaited() + + +def test_reset_budget_windows_resets_expired_team_window(monkeypatch): + """Same as the key test, but for teams.""" + now = datetime.utcnow() + expired = (now - timedelta(minutes=1)).isoformat() + "Z" + + team_rows = [ + { + "team_id": "team-expired", + "budget_limits": [{"budget_duration": "30d", "reset_at": expired}], + } + ] + job, prisma_client, spend_counter_cache = _make_reset_budget_windows_job( + monkeypatch, key_rows=[], team_rows=team_rows + ) + + asyncio.run(job.reset_budget_windows()) + + prisma_client.db.litellm_teamtable.update.assert_awaited_once() + call_kwargs = prisma_client.db.litellm_teamtable.update.await_args.kwargs + assert call_kwargs["where"] == {"team_id": "team-expired"} + assert "budget_limits" in call_kwargs["data"] + + spend_counter_cache.in_memory_cache.set_cache.assert_any_call( + key="spend:team:team-expired:window:30d", value=0.0 + ) + + +def test_reset_budget_windows_handles_string_budget_limits(monkeypatch): + """Defensive: if `query_raw` returns `budget_limits` as a JSON-encoded + string (driver-dependent), the code still parses and resets it. + """ + now = datetime.utcnow() + expired = (now - timedelta(minutes=1)).isoformat() + "Z" + + key_rows = [ + { + "token": "sk-string-limits", + "budget_limits": json.dumps( + [{"budget_duration": "1d", "reset_at": expired}] + ), + } + ] + job, prisma_client, _ = _make_reset_budget_windows_job( + monkeypatch, key_rows=key_rows, team_rows=[] + ) + + asyncio.run(job.reset_budget_windows()) + + prisma_client.db.litellm_verificationtoken.update.assert_awaited_once() + + +def test_reset_budget_windows_skips_row_with_empty_budget_limits(monkeypatch): + """A row whose `budget_limits` comes back as an empty/falsy payload + (shouldn't happen given the WHERE filter, but we guard anyway) must not + trigger an update or crash the loop.""" + key_rows = [ + {"token": "sk-empty-list", "budget_limits": []}, + {"token": "sk-empty-str", "budget_limits": ""}, + ] + job, prisma_client, _ = _make_reset_budget_windows_job( + monkeypatch, key_rows=key_rows, team_rows=[] + ) + + asyncio.run(job.reset_budget_windows()) + + prisma_client.db.litellm_verificationtoken.update.assert_not_awaited() + + +def test_reset_budget_windows_query_error_does_not_break_team_path(monkeypatch): + """If the key query raises, the teams path still runs (and vice-versa). + Each side has its own try/except; this locks that in.""" + now = datetime.utcnow() + expired = (now - timedelta(minutes=1)).isoformat() + "Z" + + prisma_client = MagicMock() + + async def fake_query_raw(query: str, *args, **kwargs): + if '"LiteLLM_VerificationToken"' in query: + raise RuntimeError("boom") + if '"LiteLLM_TeamTable"' in query: + return [ + { + "team_id": "team-ok", + "budget_limits": [{"budget_duration": "1d", "reset_at": expired}], + } + ] + raise AssertionError(query) + + prisma_client.db.query_raw = AsyncMock(side_effect=fake_query_raw) + prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=None) + + spend_counter_cache = MagicMock() + spend_counter_cache.in_memory_cache.set_cache = MagicMock() + spend_counter_cache.redis_cache = None + fake_module = types.ModuleType("litellm.proxy.proxy_server") + fake_module.spend_counter_cache = spend_counter_cache + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_module) + + job = ResetBudgetJob(proxy_logging_obj=MagicMock(), prisma_client=prisma_client) + + asyncio.run(job.reset_budget_windows()) # must not raise + + prisma_client.db.litellm_teamtable.update.assert_awaited_once() From 3950f5ea72ffd176779e5929af138290cc4b7914 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:05:22 -0700 Subject: [PATCH 40/48] feat: add gpt-5.5 to model cost map (#26345) * feat: add gpt-5.5 to model cost map Add gpt-5.5 entry with pricing from OpenAI flagship page: input $5/1M, cached input $0.50/1M, output $30/1M, 272K context. * test: add gpt-5.5 coverage for model cost map and gpt-5 routing - Add gpt-5.5 to GPT5_MODELS parametrized list so both OpenAIGPT5Config and AzureOpenAIGPT5Config routing tests cover the new model. - Add test_generic_cost_per_token_gpt55 verifying the new entry's cost-map values ($5/$0.50/$30 per 1M) and that generic_cost_per_token returns the expected prompt/completion costs. --- ...odel_prices_and_context_window_backup.json | 36 ++++++++++++++++++ model_prices_and_context_window.json | 36 ++++++++++++++++++ .../llm_cost_calc/test_llm_cost_calc_utils.py | 37 +++++++++++++++++++ .../llms/openai/test_is_model_gpt_5_model.py | 1 + 4 files changed, 110 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index bd959e3103..1cf7c1f6c7 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -19273,6 +19273,42 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, + "gpt-5.5": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_service_tier": true, + "supports_vision": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": true + }, "gpt-5.4": { "cache_read_input_token_cost": 2.5e-07, "cache_read_input_token_cost_above_272k_tokens": 5e-07, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index e60c88be08..8dcd52cae2 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -19287,6 +19287,42 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, + "gpt-5.5": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_service_tier": true, + "supports_vision": true, + "supports_none_reasoning_effort": true, + "supports_xhigh_reasoning_effort": true, + "supports_minimal_reasoning_effort": true + }, "gpt-5.4": { "cache_read_input_token_cost": 2.5e-07, "cache_read_input_token_cost_above_272k_tokens": 5e-07, diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 91b2c49d2b..7144279ad0 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -328,6 +328,43 @@ def test_generic_cost_per_token_gpt54_above_272k_tokens(): assert round(completion_cost, 10) == round(expected_completion, 10) +def test_generic_cost_per_token_gpt55(): + """gpt-5.5: base pricing — $5/1M input, $30/1M output, $0.50/1M cached input.""" + model = "gpt-5.5" + custom_llm_provider = "openai" + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + model_cost_map = litellm.model_cost[model] + + # Sanity-check the map values match OpenAI's published pricing. + assert model_cost_map["input_cost_per_token"] == 5e-6 + assert model_cost_map["output_cost_per_token"] == 3e-5 + assert model_cost_map["cache_read_input_token_cost"] == 5e-7 + assert model_cost_map["litellm_provider"] == "openai" + assert model_cost_map["mode"] == "chat" + assert model_cost_map["max_input_tokens"] == 272000 + + prompt_tokens = 1000 + completion_tokens = 500 + usage = Usage( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=prompt_tokens + completion_tokens, + ) + prompt_cost, completion_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider=custom_llm_provider, + ) + assert round(prompt_cost, 10) == round( + model_cost_map["input_cost_per_token"] * prompt_tokens, 10 + ) + assert round(completion_cost, 10) == round( + model_cost_map["output_cost_per_token"] * completion_tokens, 10 + ) + + def test_generic_cost_per_token_anthropic_prompt_caching(): model = "claude-sonnet-4@20250514" usage = Usage( diff --git a/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py index 1d26287295..e611d5e6b7 100644 --- a/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py +++ b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py @@ -46,6 +46,7 @@ GPT5_MODELS = [ "gpt-5.2", "gpt-5.3", "gpt-5.4", + "gpt-5.5", "gpt-5.1-chat", # versioned chat — THE KEY REGRESSION CASE "gpt-5.2-chat", # versioned chat — also a regression case "gpt-5.3-chat", # versioned chat — THE KEY REGRESSION CASE From e37d1b0cb63d1a5d7f23918efa4e64c9e93a9166 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 14:13:55 -0700 Subject: [PATCH 41/48] [Fix] Deflake spend tracking tests Two independent deflakes: 1. test_ui_view_spend_logs_unauthorized (unit) was returning 400 instead of 401/403 when earlier tests in the file left proxy-auth globals (prisma_client, master_key, user_custom_auth, general_settings, user_api_key_cache) in a state that let invalid tokens pass auth and fall through to the endpoint's own start_date/end_date validation. Add an autouse fixture that pins those globals to their import-time defaults for every test in the file. Harden the assertion to include response body so future flakes are diagnosable. 2. test_basic_spend_accuracy (CI job proxy_spend_accuracy_tests) depends on the Redis transaction buffer flushing spend to Postgres. The buffer uses a single global pod-lock key (cronjob_lock:db_spend_update_job) and a single global buffer list key. Pointing the proxy at the shared remote Redis means concurrent CI pipelines contend for the same lock and can drain each other's buffer into the wrong database. Add a start_redis reusable command that boots a per-job redis:7-alpine container (digest-pinned), and switch proxy_spend_accuracy_tests to REDIS_HOST=host.docker.internal:6379 so lock and buffer state are isolated per CI run. --- .circleci/config.yml | 29 +++++++++++++++---- .../test_spend_management_endpoints.py | 24 +++++++++++++-- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0a59b7ef0d..e9b805fd45 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -98,6 +98,19 @@ commands: - wait_for_service: url: tcp://localhost:5432 timeout: "60" + start_redis: + description: "Start a redis container on port 6379 and wait until it accepts connections. Use this to isolate a job from the shared remote Redis so concurrent CI pipelines don't contend for pod locks or buffer keys." + steps: + - run: + name: Start Redis + command: | + docker run -d \ + --name redis-cache \ + -p 6379:6379 \ + redis:7-alpine@sha256:7aec734b2bb298a1d769fd8729f13b8514a41bf90fcdd1f38ec52267fbaa8ee6 + - wait_for_service: + url: tcp://localhost:6379 + timeout: "60" setup_litellm_enterprise_pip: steps: - run: @@ -1775,6 +1788,7 @@ jobs: command: | uv sync --frozen --all-groups --all-extras --python 3.12 - start_postgres + - start_redis - attach_workspace: at: ~/project - run: @@ -1784,15 +1798,18 @@ jobs: docker images | grep litellm-docker-database - run: name: Run Docker container - # intentionally give bad redis credentials here - # the OTEL test - should get this as a trace + # Point the proxy at the job-local Redis (start_redis) instead of the + # shared remote Redis. The Redis transaction buffer uses a single + # global pod-lock key (cronjob_lock:db_spend_update_job) and a single + # global buffer list (litellm_spend_update_buffer); sharing those + # across concurrent CI pipelines causes spend flushes to stall or + # land in the wrong DB, which is what makes this test flaky. command: | docker run -d \ -p 4000:4000 \ -e DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5432/circle_test \ - -e REDIS_HOST=$REDIS_HOST \ - -e REDIS_PASSWORD=$REDIS_PASSWORD \ - -e REDIS_PORT=$REDIS_PORT \ + -e REDIS_HOST=host.docker.internal \ + -e REDIS_PORT=6379 \ -e LITELLM_MASTER_KEY="sk-1234" \ -e OPENAI_API_KEY=$OPENAI_API_KEY \ -e LITELLM_LICENSE=$LITELLM_LICENSE \ @@ -1830,6 +1847,8 @@ jobs: command: | docker stop my-app docker rm my-app + docker stop redis-cache + docker rm redis-cache proxy_multi_instance_tests: machine: 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 1e2e398139..2370d5df30 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 @@ -422,6 +422,26 @@ def reset_router_callbacks(): litellm.logging_callback_manager._reset_all_callbacks() +@pytest.fixture(autouse=True) +def reset_proxy_auth_globals(monkeypatch): + """ + Pin proxy auth-related globals to a known baseline so tests don't inherit + leaked state (master_key, prisma_client, custom auth, cached tokens) from + earlier tests. Individual tests can still override via their own + monkeypatch calls — those run after this fixture and revert first. + """ + import litellm.proxy.proxy_server as ps + + monkeypatch.setattr(ps, "prisma_client", None) + monkeypatch.setattr(ps, "master_key", None) + monkeypatch.setattr(ps, "user_custom_auth", None) + monkeypatch.setattr(ps, "general_settings", {}) + try: + ps.user_api_key_cache.in_memory_cache.cache_dict.clear() + except AttributeError: + pass + + @pytest.mark.asyncio async def test_ui_view_spend_logs_with_user_id(client, monkeypatch): mock_spend_logs = [ @@ -1150,14 +1170,14 @@ async def test_ui_view_spend_logs_date_range_filter(client, monkeypatch): async def test_ui_view_spend_logs_unauthorized(client): # Test without authorization header response = client.get("/spend/logs/ui") - assert response.status_code == 401 or response.status_code == 403 + assert response.status_code in (401, 403), response.text # Test with invalid authorization response = client.get( "/spend/logs/ui", headers={"Authorization": "Bearer invalid-token"}, ) - assert response.status_code == 401 or response.status_code == 403 + assert response.status_code in (401, 403), response.text @pytest.mark.asyncio From 8adb3a6a8ff6bbd727e4d69fc588c3fa68862a61 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 23 Apr 2026 14:18:06 -0700 Subject: [PATCH 42/48] Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .circleci/config.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index e9b805fd45..cb657fc2d1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1844,6 +1844,9 @@ jobs: # Clean up first container - run: name: Stop and remove first container + - run: + name: Stop and remove first container + when: always command: | docker stop my-app docker rm my-app From 4af2b6735740b703563e78e8651d86149d0956de Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 14:21:14 -0700 Subject: [PATCH 43/48] [Fix] Drop orphan teardown step from Greptile merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit from greptile-apps added a new `when: always` teardown step without removing the prior `name:`-only step, leaving a `- run` block with no `command:` — CircleCI config validation rejects that. Collapse back to a single teardown step that runs on success and failure. --- .circleci/config.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index cb657fc2d1..535dd1a9ef 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1841,9 +1841,6 @@ jobs: ls uv run --no-sync python -m pytest -vv tests/spend_tracking_tests -x --junitxml=test-results/junit.xml --durations=5 no_output_timeout: 15m - # Clean up first container - - run: - name: Stop and remove first container - run: name: Stop and remove first container when: always From c4ea0e93c80772d1615e36eb1052b2179465885f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 14:48:02 -0700 Subject: [PATCH 44/48] fix: drain logging worker in test_router_caching_ttl to remove flake The mocked async_increment_cache_pipeline is invoked from Router's deployment_callback_on_success, registered as an async success callback. Those callbacks are enqueued to GLOBAL_LOGGING_WORKER and run on a background task, so the mock may not have been called yet when the test asserts on it. Flush the worker before asserting. --- tests/local_testing/test_tpm_rpm_routing_v2.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/local_testing/test_tpm_rpm_routing_v2.py b/tests/local_testing/test_tpm_rpm_routing_v2.py index 9de5625c63..211af56642 100644 --- a/tests/local_testing/test_tpm_rpm_routing_v2.py +++ b/tests/local_testing/test_tpm_rpm_routing_v2.py @@ -547,6 +547,8 @@ async def test_router_caching_ttl(): assert router.cache.redis_cache is not None + from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER + increment_cache_kwargs = {} with patch.object( router.cache, @@ -555,6 +557,10 @@ async def test_router_caching_ttl(): ) as mock_client: await router.acompletion(model=model, messages=messages) + # Async success callbacks are dispatched to GLOBAL_LOGGING_WORKER's + # background queue; drain it before asserting the mock was invoked. + await GLOBAL_LOGGING_WORKER.flush() + # mock_client.assert_called_once() print(f"mock_client.call_args.kwargs: {mock_client.call_args.kwargs}") print(f"mock_client.call_args.args: {mock_client.call_args.args}") From c14a73fa59138eaf86c268c1b1879f57d9c7689c Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 15:06:33 -0700 Subject: [PATCH 45/48] fix: make LoggingWorker.flush() wait for in-flight callbacks The previous `while not self._queue.empty(): await self._queue.join()` pattern skipped the join entirely when the worker had already dequeued a task but not yet called task_done(). asyncio.Queue.join() tracks _unfinished_tasks (incremented by put, decremented by task_done), not queue depth, so it already handles that case on its own. --- litellm/litellm_core_utils/logging_worker.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/logging_worker.py b/litellm/litellm_core_utils/logging_worker.py index 7f00c47c1f..3db3700ee0 100644 --- a/litellm/litellm_core_utils/logging_worker.py +++ b/litellm/litellm_core_utils/logging_worker.py @@ -370,11 +370,17 @@ class LoggingWorker: self._running_tasks.clear() async def flush(self) -> None: - """Flush the logging queue.""" + """Flush the logging queue. + + Waits until every enqueued task has completed. ``queue.join()`` blocks + on the queue's unfinished-task counter (decremented by ``task_done()``), + so it correctly handles items that have been dequeued but whose + callback hasn't finished yet — ``queue.empty()`` would return True in + that window and cause us to skip the wait. + """ if self._queue is None: return - while not self._queue.empty(): - await self._queue.join() + await self._queue.join() async def clear_queue(self): """ From 4a2deae92c761af4943ab97780c174ab2dd2e68c Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Apr 2026 15:11:12 -0700 Subject: [PATCH 46/48] [Fix] Infra: grant contents:write to create-release-branch caller job The create-branch job in create-release.yml calls the reusable create-release-branch.yml workflow, which requires contents: write. The top-level permissions: {} blocks the inherited default, and only the release job overrode it, so the nested call failed with: The nested job 'create-branch' is requesting 'contents: write', but is only allowed 'contents: none'. Add the permission at the calling job level so the reusable workflow is granted what it needs. --- .github/workflows/create-release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index a5b3dd8113..68ab397d82 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -110,6 +110,8 @@ jobs: create-branch: name: Create Release Branch needs: release + permissions: + contents: write uses: ./.github/workflows/create-release-branch.yml with: tag: ${{ inputs.tag }} From b6d0f6b649bcc36074abb32e59f77d7db84a2511 Mon Sep 17 00:00:00 2001 From: milan-berri Date: Fri, 24 Apr 2026 01:58:02 +0300 Subject: [PATCH 47/48] fix(vertex_ai): use aiplatform.{geo}.rep.googleapis.com for multi-region locations (#26281) Vertex multi-region endpoints (e.g. us, eu) use the rep host pattern, not {geo}-aiplatform.googleapis.com. Regional IDs still contain a hyphen. common_utils.get_vertex_base_url centralizes the rule for SDK/API URL building. Proxy pass-through duplicates the same branching in a local get_vertex_base_url (with trailing slashes) to avoid importing from common_utils there; live WebSocket passthrough uses the same multi-region host logic for wss://. Tests cover us/eu for the common_utils helper. Made-with: Cursor --- litellm/llms/vertex_ai/common_utils.py | 6 ++++ .../llm_passthrough_endpoints.py | 15 +++++----- .../test_vertex_global_url_support.py | 3 ++ .../test_llm_pass_through_endpoints.py | 30 +++++++++++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index fb8fd90340..ccd4d4f293 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -229,6 +229,10 @@ def get_vertex_base_url( ) -> str: """ Get the base URL for Vertex AI API calls. + + - ``global`` uses the global control plane host. + - Multi-region geographies (e.g. ``us``, ``eu``) use ``aiplatform.{geo}.rep.googleapis.com``. + - Regional locations (e.g. ``us-central1``) use ``{region}-aiplatform.googleapis.com``. """ if vertex_location == "global": return "https://aiplatform.googleapis.com" @@ -236,6 +240,8 @@ def get_vertex_base_url( raise ValueError("vertex_location is required") if not re.match(r"^[a-z][a-z0-9-]*$", vertex_location): raise ValueError("Invalid vertex_location format") + if "-" not in vertex_location: + return f"https://aiplatform.{vertex_location}.rep.googleapis.com" return f"https://{vertex_location}-aiplatform.googleapis.com" diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 8a86b98fee..418715cb9c 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -1497,7 +1497,9 @@ class VertexAIPassThroughHandler(BaseVertexAIPassThroughHandler): def get_vertex_base_url(vertex_location: Optional[str]) -> str: """ - Returns the base URL for Vertex AI based on the provided location. + Base URL for Vertex AI pass-through (trailing slash for URL joining). + + Keep location rules aligned with ``litellm.llms.vertex_ai.common_utils.get_vertex_base_url``. """ if vertex_location == "global": return "https://aiplatform.googleapis.com/" @@ -1505,6 +1507,8 @@ def get_vertex_base_url(vertex_location: Optional[str]) -> str: raise ValueError("vertex_location is required") if not re.match(r"^[a-z][a-z0-9-]*$", vertex_location): raise ValueError("Invalid vertex_location format") + if "-" not in vertex_location: + return f"https://aiplatform.{vertex_location}.rep.googleapis.com/" return f"https://{vertex_location}-aiplatform.googleapis.com/" @@ -1708,7 +1712,8 @@ async def _base_vertex_proxy_route( Base function for Vertex AI passthrough routes. Handles common logic for all Vertex AI services. - Default base_target_url is `https://{vertex_location}-aiplatform.googleapis.com/` + Default base_target_url is derived from ``get_vertex_base_url`` in this module + (regional, ``global``, or multi-region ``.rep.`` hosts), with a trailing slash. Args: endpoint: The endpoint path @@ -2280,11 +2285,7 @@ async def vertex_ai_live_websocket_passthrough( return host_location = resolved_location or vertex_llm_base.get_default_vertex_location() - host = ( - "aiplatform.googleapis.com" - if host_location == "global" - else f"{host_location}-aiplatform.googleapis.com" - ) + host = get_vertex_base_url(host_location).removeprefix("https://").rstrip("/") service_url = ( f"wss://{host}/ws/google.cloud.aiplatform.v1.LlmBidiService/BidiGenerateContent" ) diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_global_url_support.py b/tests/test_litellm/llms/vertex_ai/test_vertex_global_url_support.py index 7b359af9b8..5a007f5a4f 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_global_url_support.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_global_url_support.py @@ -5,6 +5,7 @@ This test suite ensures that all Vertex AI endpoints properly handle the 'global which uses a different URL format than regional endpoints. Regional: https://{region}-aiplatform.googleapis.com/... +Multi-region: https://aiplatform.{geo}.rep.googleapis.com/... Global: https://aiplatform.googleapis.com/... """ @@ -30,6 +31,8 @@ class TestVertexBaseURL: ("europe-west1", "https://europe-west1-aiplatform.googleapis.com"), ("asia-northeast1", "https://asia-northeast1-aiplatform.googleapis.com"), ("global", "https://aiplatform.googleapis.com"), + ("us", "https://aiplatform.us.rep.googleapis.com"), + ("eu", "https://aiplatform.eu.rep.googleapis.com"), ], ) def test_get_vertex_base_url(self, vertex_location, expected_base_url): diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index cafdff9997..06748e3f47 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -21,6 +21,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( bedrock_llm_proxy_route, create_pass_through_route, cursor_proxy_route, + get_vertex_base_url, llm_passthrough_factory_proxy_route, milvus_proxy_route, openai_proxy_route, @@ -31,6 +32,35 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( from litellm.types.passthrough_endpoints.vertex_ai import VertexPassThroughCredentials +class TestVertexPassthroughGetVertexBaseUrl: + """Module-local get_vertex_base_url (trailing slash); rules match common_utils.""" + + @pytest.mark.parametrize( + "vertex_location, expected", + [ + ("global", "https://aiplatform.googleapis.com/"), + ("us-central1", "https://us-central1-aiplatform.googleapis.com/"), + ("us", "https://aiplatform.us.rep.googleapis.com/"), + ("eu", "https://aiplatform.eu.rep.googleapis.com/"), + ], + ) + def test_returns_base_with_trailing_slash(self, vertex_location, expected): + assert get_vertex_base_url(vertex_location) == expected + + @pytest.mark.parametrize( + "vertex_location, expected_host", + [ + ("global", "aiplatform.googleapis.com"), + ("us-central1", "us-central1-aiplatform.googleapis.com"), + ("us", "aiplatform.us.rep.googleapis.com"), + ("eu", "aiplatform.eu.rep.googleapis.com"), + ], + ) + def test_websocket_host_strips_scheme(self, vertex_location, expected_host): + host = get_vertex_base_url(vertex_location).removeprefix("https://").rstrip("/") + assert host == expected_host + + class TestBaseOpenAIPassThroughHandler: def test_join_url_paths(self): print("\nTesting _join_url_paths method...") From 2001d91b279a64f628320b1fcbdb9f099a6891e4 Mon Sep 17 00:00:00 2001 From: milan-berri Date: Fri, 24 Apr 2026 02:21:27 +0300 Subject: [PATCH 48/48] fix(mcp): share temporary MCP OAuth sessions across instances via Redis (#26162) (#26318) Temporary MCP OAuth sessions were kept in process-local memory, so on multi-instance/LB proxy deployments a session created on instance A could not be found when the follow-up /server/oauth/{server_id}/... request landed on instance B. Persist temporary session records to Redis (encrypted with the existing proxy encryption helpers) as a best-effort L2 cache alongside the current in-memory L1. Convert get_cached_temporary_mcp_server to async and await it from the authorize/token/register OAuth endpoints. Made-with: Cursor --- .../mcp_management_endpoints.py | 131 +++++++++- .../test_mcp_management_endpoints.py | 242 +++++++++++++++++- 2 files changed, 356 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index f18e699045..a68c8ca9fa 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -52,12 +52,17 @@ from litellm.proxy._experimental.mcp_server.utils import ( from litellm.proxy._experimental.mcp_server.utils import ( validate_and_normalize_mcp_server_payload as _base_validate_and_normalize_mcp_server_payload, ) +from litellm.proxy.common_utils.encrypt_decrypt_utils import ( + decrypt_value_helper, + encrypt_value_helper, +) router = APIRouter(prefix="/v1/mcp", tags=["mcp"]) MCP_AVAILABLE: bool = True TEMPORARY_MCP_SERVER_TTL_SECONDS = 300 +TEMPORARY_MCP_SERVER_REDIS_KEY_PREFIX = "litellm:mcp:temporary_server" def does_mcp_server_exist( @@ -329,13 +334,115 @@ if MCP_AVAILABLE: ) return server - def get_cached_temporary_mcp_server( + async def _cache_temporary_mcp_server_in_redis( + server: MCPServer, ttl_seconds: int + ) -> None: + """ + Best-effort write-through to Redis so temporary MCP OAuth sessions are + shared across proxy instances. Keep local in-memory cache as fallback. + """ + if litellm.cache is None or not hasattr(litellm.cache, "cache"): + return + cache_backend = getattr(litellm.cache, "cache", None) + if cache_backend is None or not hasattr(cache_backend, "async_set_cache"): + return + + payload: Dict[str, Any] = server.model_dump(mode="json") + payload_json = json.dumps(payload) + try: + encrypted_payload = encrypt_value_helper(payload_json) + except Exception as e: + verbose_proxy_logger.debug( + f"Failed to encrypt temporary MCP server payload for Redis cache: {str(e)}" + ) + return + + if not isinstance(encrypted_payload, str): + verbose_proxy_logger.debug( + "Encrypted temporary MCP payload is not a string; skipping Redis cache write" + ) + return + + try: + await cache_backend.async_set_cache( + key=f"{TEMPORARY_MCP_SERVER_REDIS_KEY_PREFIX}:{server.server_id}", + value=encrypted_payload, + ttl=max(1, ttl_seconds), + ) + except Exception as e: + verbose_proxy_logger.debug( + f"Failed to write temporary MCP server to Redis cache: {str(e)}" + ) + + async def _get_temporary_mcp_server_from_redis( + server_id: str, + ) -> Optional[MCPServer]: + """ + Best-effort read from Redis shared cache. Returns None on miss/errors. + + Values must be encrypted strings (same contract as _cache_temporary_mcp_server_in_redis); + legacy plaintext dict payloads are rejected. + """ + if litellm.cache is None or not hasattr(litellm.cache, "cache"): + return None + cache_backend = getattr(litellm.cache, "cache", None) + if cache_backend is None or not hasattr(cache_backend, "async_get_cache"): + return None + + try: + cached_server = await cache_backend.async_get_cache( + key=f"{TEMPORARY_MCP_SERVER_REDIS_KEY_PREFIX}:{server_id}" + ) + except Exception as e: + verbose_proxy_logger.debug( + f"Failed reading temporary MCP server from Redis cache: {str(e)}" + ) + return None + + if not isinstance(cached_server, str): + verbose_proxy_logger.debug( + "Temporary MCP Redis cache value must be an encrypted string; rejecting non-string payload" + ) + return None + + decrypted_json = decrypt_value_helper( + value=cached_server, + key="temporary_mcp_server", + exception_type="debug", + ) + if decrypted_json is None: + return None + try: + loaded = json.loads(decrypted_json) + except Exception as e: + verbose_proxy_logger.debug( + f"Invalid decrypted temporary MCP payload in Redis cache: {str(e)}" + ) + return None + if not isinstance(loaded, dict): + return None + payload_dict: Dict[str, Any] = loaded + + try: + return MCPServer(**payload_dict) + except Exception as e: + verbose_proxy_logger.debug( + f"Invalid temporary MCP server payload in Redis cache: {str(e)}" + ) + return None + + async def get_cached_temporary_mcp_server( server_id: str, ) -> Optional[MCPServer]: _prune_expired_temporary_mcp_servers() entry = _temporary_mcp_servers.get(server_id) if entry is None: - return None + redis_server = await _get_temporary_mcp_server_from_redis(server_id) + if redis_server is None: + return None + # Intentionally avoid repopulating local cache from Redis to prevent + # extending effective lifetime beyond the remaining Redis TTL. + return redis_server return entry.server def _redact_mcp_credentials( @@ -1325,6 +1432,10 @@ if MCP_AVAILABLE: temporary_server, ttl_seconds=TEMPORARY_MCP_SERVER_TTL_SECONDS, ) + await _cache_temporary_mcp_server_in_redis( + temporary_server, + ttl_seconds=TEMPORARY_MCP_SERVER_TTL_SECONDS, + ) except Exception as e: verbose_proxy_logger.exception( f"Error caching temporary mcp server: {str(e)}" @@ -1336,10 +1447,10 @@ if MCP_AVAILABLE: return _redact_mcp_credentials(temp_record) - def _get_cached_temporary_mcp_server_or_404( + async def _get_cached_temporary_mcp_server_or_404( server_id: str, request: Optional[Request] = None ) -> MCPServer: - server = get_cached_temporary_mcp_server(server_id) + server = await get_cached_temporary_mcp_server(server_id) if server is None: # Fall back to real DB/config server (e.g. for the user-side OAuth flow # which calls these endpoints with a real server_id, not a temp session id). @@ -1378,7 +1489,9 @@ if MCP_AVAILABLE: response_type: Optional[str] = None, scope: Optional[str] = None, ): - mcp_server = _get_cached_temporary_mcp_server_or_404(server_id, request=request) + mcp_server = await _get_cached_temporary_mcp_server_or_404( + server_id, request=request + ) # Use the server's stored client_id when the caller doesn't supply one resolved_client_id = mcp_server.client_id or client_id or "" if not resolved_client_id: @@ -1422,7 +1535,9 @@ if MCP_AVAILABLE: refresh_token: Optional[str] = Form(None), scope: Optional[str] = Form(None), ): - mcp_server = _get_cached_temporary_mcp_server_or_404(server_id, request=request) + mcp_server = await _get_cached_temporary_mcp_server_or_404( + server_id, request=request + ) resolved_client_id = mcp_server.client_id or client_id or "" if not resolved_client_id: raise HTTPException( @@ -1458,7 +1573,9 @@ if MCP_AVAILABLE: server_id: str, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): - mcp_server = _get_cached_temporary_mcp_server_or_404(server_id, request=request) + mcp_server = await _get_cached_temporary_mcp_server_or_404( + server_id, request=request + ) request_data = await _read_request_body(request=request) data: dict = {**request_data} diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index c1a1acb433..442265d3af 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -1,6 +1,7 @@ import os import sys import types +import json from datetime import datetime, timedelta from types import SimpleNamespace from typing import List, Optional @@ -1311,7 +1312,8 @@ class TestTemporaryMCPSessionEndpoints: assert cache["temp-cache"].server is server assert cache["temp-cache"].expires_at > datetime.utcnow() - def test_get_cached_temporary_mcp_server_prunes_expired_entries(self): + @pytest.mark.asyncio + async def test_get_cached_temporary_mcp_server_prunes_expired_entries(self): from litellm.proxy.management_endpoints.mcp_management_endpoints import ( _TemporaryMCPServerEntry, get_cached_temporary_mcp_server, @@ -1327,12 +1329,13 @@ class TestTemporaryMCPSessionEndpoints: "litellm.proxy.management_endpoints.mcp_management_endpoints._temporary_mcp_servers", cache, ): - result = get_cached_temporary_mcp_server("expired") + result = await get_cached_temporary_mcp_server("expired") assert result is None assert "expired" not in cache - def test_get_cached_temporary_mcp_server_or_404(self): + @pytest.mark.asyncio + async def test_get_cached_temporary_mcp_server_or_404(self): from litellm.proxy.management_endpoints.mcp_management_endpoints import ( _get_cached_temporary_mcp_server_or_404, ) @@ -1343,17 +1346,17 @@ class TestTemporaryMCPSessionEndpoints: "litellm.proxy.management_endpoints.mcp_management_endpoints.get_cached_temporary_mcp_server", return_value=server, ) as get_cached: - result = _get_cached_temporary_mcp_server_or_404("cached") + result = await _get_cached_temporary_mcp_server_or_404("cached") assert result is server - get_cached.assert_called_once_with("cached") + get_cached.assert_awaited_once_with("cached") with patch( "litellm.proxy.management_endpoints.mcp_management_endpoints.get_cached_temporary_mcp_server", return_value=None, ): with pytest.raises(HTTPException) as exc_info: - _get_cached_temporary_mcp_server_or_404("missing") + await _get_cached_temporary_mcp_server_or_404("missing") assert exc_info.value.status_code == 404 @@ -1403,6 +1406,10 @@ class TestTemporaryMCPSessionEndpoints: "litellm.proxy.management_endpoints.mcp_management_endpoints._cache_temporary_mcp_server", MagicMock(), ) as cache_mock, + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints._cache_temporary_mcp_server_in_redis", + AsyncMock(), + ) as redis_cache_mock, ): response = await add_session_mcp_server( payload=payload, @@ -1414,6 +1421,9 @@ class TestTemporaryMCPSessionEndpoints: cache_mock.assert_called_once_with( built_server, ttl_seconds=TEMPORARY_MCP_SERVER_TTL_SECONDS ) + redis_cache_mock.assert_awaited_once_with( + built_server, ttl_seconds=TEMPORARY_MCP_SERVER_TTL_SECONDS + ) args, _ = mock_manager.build_mcp_server_from_table.call_args temp_record = args[0] @@ -1486,7 +1496,7 @@ class TestTemporaryMCPSessionEndpoints: ) assert result is authorize_response - get_server.assert_called_once_with("server-1", request=request) + get_server.assert_awaited_once_with("server-1", request=request) authorize_mock.assert_awaited_once_with( request=request, mcp_server=server, @@ -1533,7 +1543,7 @@ class TestTemporaryMCPSessionEndpoints: ) assert result is exchange_response - get_server.assert_called_once_with("server-1", request=request) + get_server.assert_awaited_once_with("server-1", request=request) exchange_mock.assert_awaited_once_with( request=request, mcp_server=server, @@ -1581,7 +1591,7 @@ class TestTemporaryMCPSessionEndpoints: ) assert result is exchange_response - get_server.assert_called_once_with("server-1", request=request) + get_server.assert_awaited_once_with("server-1", request=request) exchange_mock.assert_awaited_once_with( request=request, mcp_server=server, @@ -1628,7 +1638,7 @@ class TestTemporaryMCPSessionEndpoints: result = await mcp_register(request=request, server_id="server-1") assert result is register_response - get_server.assert_called_once_with("server-1", request=request) + get_server.assert_awaited_once_with("server-1", request=request) read_body.assert_awaited_once_with(request=request) register_mock.assert_awaited_once_with( request=request, @@ -1640,6 +1650,218 @@ class TestTemporaryMCPSessionEndpoints: fallback_client_id="server-1", ) + @pytest.mark.asyncio + async def test_get_cached_temporary_mcp_server_falls_back_to_redis(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + get_cached_temporary_mcp_server, + ) + + server = generate_mock_mcp_server_config_record(server_id="from-redis") + serialized = json.dumps(server.model_dump(mode="json")) + mock_cache_backend = SimpleNamespace( + async_get_cache=AsyncMock(return_value="encrypted-payload") + ) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints._temporary_mcp_servers", + {}, + ), patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.decrypt_value_helper", + return_value=serialized, + ): + result = await get_cached_temporary_mcp_server("from-redis") + finally: + mgmt_endpoints.litellm.cache = original_cache + + assert result is not None + assert result.server_id == "from-redis" + mock_cache_backend.async_get_cache.assert_awaited_once_with( + key="litellm:mcp:temporary_server:from-redis" + ) + + @pytest.mark.asyncio + async def test_cache_temporary_mcp_server_in_redis_uses_ttl_and_key(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _cache_temporary_mcp_server_in_redis, + ) + + server = generate_mock_mcp_server_config_record(server_id="to-redis") + mock_cache_backend = SimpleNamespace(async_set_cache=AsyncMock()) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.encrypt_value_helper", + return_value="encrypted-payload", + ): + await _cache_temporary_mcp_server_in_redis(server, ttl_seconds=123) + finally: + mgmt_endpoints.litellm.cache = original_cache + + mock_cache_backend.async_set_cache.assert_awaited_once() + call_kwargs = mock_cache_backend.async_set_cache.await_args.kwargs + assert call_kwargs["key"] == "litellm:mcp:temporary_server:to-redis" + assert call_kwargs["ttl"] == 123 + + @pytest.mark.asyncio + async def test_cache_temporary_mcp_server_in_redis_encrypts_payload(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _cache_temporary_mcp_server_in_redis, + ) + + server = generate_mock_mcp_server_config_record(server_id="to-redis-encrypted") + mock_cache_backend = SimpleNamespace(async_set_cache=AsyncMock()) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.encrypt_value_helper", + return_value="encrypted-payload", + ) as encrypt_mock: + await _cache_temporary_mcp_server_in_redis(server, ttl_seconds=60) + finally: + mgmt_endpoints.litellm.cache = original_cache + + encrypt_mock.assert_called_once() + call_kwargs = mock_cache_backend.async_set_cache.await_args.kwargs + assert call_kwargs["value"] == "encrypted-payload" + + @pytest.mark.asyncio + async def test_get_temporary_mcp_server_from_redis_decrypts_payload(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _get_temporary_mcp_server_from_redis, + ) + + server = generate_mock_mcp_server_config_record(server_id="from-redis-encrypted") + serialized = json.dumps(server.model_dump(mode="json")) + mock_cache_backend = SimpleNamespace( + async_get_cache=AsyncMock(return_value="encrypted-payload") + ) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.decrypt_value_helper", + return_value=serialized, + ) as decrypt_mock: + result = await _get_temporary_mcp_server_from_redis( + "from-redis-encrypted" + ) + finally: + mgmt_endpoints.litellm.cache = original_cache + + assert result is not None + assert result.server_id == "from-redis-encrypted" + decrypt_mock.assert_called_once() + + @pytest.mark.asyncio + async def test_cache_temporary_mcp_server_in_redis_skips_on_encrypt_failure(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _cache_temporary_mcp_server_in_redis, + ) + + server = generate_mock_mcp_server_config_record(server_id="encrypt-fail") + mock_cache_backend = SimpleNamespace(async_set_cache=AsyncMock()) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.encrypt_value_helper", + side_effect=Exception("boom"), + ): + await _cache_temporary_mcp_server_in_redis(server, ttl_seconds=60) + finally: + mgmt_endpoints.litellm.cache = original_cache + + mock_cache_backend.async_set_cache.assert_not_awaited() + + @pytest.mark.asyncio + async def test_cache_temporary_mcp_server_in_redis_skips_non_string_encryption_result( + self, + ): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _cache_temporary_mcp_server_in_redis, + ) + + server = generate_mock_mcp_server_config_record(server_id="encrypt-non-string") + mock_cache_backend = SimpleNamespace(async_set_cache=AsyncMock()) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.encrypt_value_helper", + return_value={"not": "a-string"}, + ): + await _cache_temporary_mcp_server_in_redis(server, ttl_seconds=60) + finally: + mgmt_endpoints.litellm.cache = original_cache + + mock_cache_backend.async_set_cache.assert_not_awaited() + + @pytest.mark.asyncio + async def test_get_temporary_mcp_server_from_redis_returns_none_on_invalid_decrypt_json( + self, + ): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _get_temporary_mcp_server_from_redis, + ) + + mock_cache_backend = SimpleNamespace(async_get_cache=AsyncMock(return_value="enc")) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.decrypt_value_helper", + return_value="{not json}", + ): + result = await _get_temporary_mcp_server_from_redis("bad-json") + finally: + mgmt_endpoints.litellm.cache = original_cache + + assert result is None + + @pytest.mark.asyncio + async def test_get_temporary_mcp_server_from_redis_returns_none_on_decrypt_none(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _get_temporary_mcp_server_from_redis, + ) + + mock_cache_backend = SimpleNamespace(async_get_cache=AsyncMock(return_value="enc")) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.decrypt_value_helper", + return_value=None, + ): + result = await _get_temporary_mcp_server_from_redis("decrypt-none") + finally: + mgmt_endpoints.litellm.cache = original_cache + + assert result is None + + @pytest.mark.asyncio + async def test_get_temporary_mcp_server_from_redis_rejects_plain_dict_payload(self): + """Plain dict values in Redis are not accepted (write path is encrypted-only).""" + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + _get_temporary_mcp_server_from_redis, + ) + + server = generate_mock_mcp_server_config_record(server_id="legacy-dict") + mock_cache_backend = SimpleNamespace( + async_get_cache=AsyncMock(return_value=server.model_dump(mode="json")) + ) + original_cache = mgmt_endpoints.litellm.cache + mgmt_endpoints.litellm.cache = SimpleNamespace(cache=mock_cache_backend) + try: + result = await _get_temporary_mcp_server_from_redis("legacy-dict") + finally: + mgmt_endpoints.litellm.cache = original_cache + + assert result is None + class TestUpdateMCPServer: """Test suite for update MCP server functionality"""